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
b4bd46d0
Commit
b4bd46d0
authored
Feb 05, 2026
by
LLLLLLiulei
Browse files
feat: add data import/export bundle
parent
6d0152c8
Changes
19
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/account_data.go
0 → 100644
View file @
b4bd46d0
package
admin
import
(
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
const
(
dataType
=
"sub2api-data"
legacyDataType
=
"sub2api-bundle"
dataVersion
=
1
dataPageCap
=
1000
)
type
DataPayload
struct
{
Type
string
`json:"type"`
Version
int
`json:"version"`
ExportedAt
string
`json:"exported_at"`
Proxies
[]
DataProxy
`json:"proxies"`
Accounts
[]
DataAccount
`json:"accounts"`
}
type
DataProxy
struct
{
ProxyKey
string
`json:"proxy_key"`
Name
string
`json:"name"`
Protocol
string
`json:"protocol"`
Host
string
`json:"host"`
Port
int
`json:"port"`
Username
string
`json:"username,omitempty"`
Password
string
`json:"password,omitempty"`
Status
string
`json:"status"`
}
type
DataAccount
struct
{
Name
string
`json:"name"`
Notes
*
string
`json:"notes,omitempty"`
Platform
string
`json:"platform"`
Type
string
`json:"type"`
Credentials
map
[
string
]
any
`json:"credentials"`
Extra
map
[
string
]
any
`json:"extra,omitempty"`
ProxyKey
*
string
`json:"proxy_key,omitempty"`
Concurrency
int
`json:"concurrency"`
Priority
int
`json:"priority"`
RateMultiplier
*
float64
`json:"rate_multiplier,omitempty"`
ExpiresAt
*
int64
`json:"expires_at,omitempty"`
AutoPauseOnExpired
*
bool
`json:"auto_pause_on_expired,omitempty"`
}
type
DataImportRequest
struct
{
Data
DataPayload
`json:"data"`
SkipDefaultGroupBind
*
bool
`json:"skip_default_group_bind"`
}
type
DataImportResult
struct
{
ProxyCreated
int
`json:"proxy_created"`
ProxyReused
int
`json:"proxy_reused"`
ProxyFailed
int
`json:"proxy_failed"`
AccountCreated
int
`json:"account_created"`
AccountFailed
int
`json:"account_failed"`
Errors
[]
DataImportError
`json:"errors,omitempty"`
}
type
DataImportError
struct
{
Kind
string
`json:"kind"`
Name
string
`json:"name,omitempty"`
ProxyKey
string
`json:"proxy_key,omitempty"`
Message
string
`json:"message"`
}
func
buildProxyKey
(
protocol
,
host
string
,
port
int
,
username
,
password
string
)
string
{
return
fmt
.
Sprintf
(
"%s|%s|%d|%s|%s"
,
strings
.
TrimSpace
(
protocol
),
strings
.
TrimSpace
(
host
),
port
,
strings
.
TrimSpace
(
username
),
strings
.
TrimSpace
(
password
))
}
func
(
h
*
AccountHandler
)
ExportData
(
c
*
gin
.
Context
)
{
ctx
:=
c
.
Request
.
Context
()
selectedIDs
,
err
:=
parseAccountIDs
(
c
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
accounts
,
err
:=
h
.
resolveExportAccounts
(
ctx
,
selectedIDs
,
c
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
includeProxies
,
err
:=
parseIncludeProxies
(
c
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
var
proxies
[]
service
.
Proxy
if
includeProxies
{
proxies
,
err
=
h
.
resolveExportProxies
(
ctx
,
accounts
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
}
else
{
proxies
=
[]
service
.
Proxy
{}
}
proxyKeyByID
:=
make
(
map
[
int64
]
string
,
len
(
proxies
))
dataProxies
:=
make
([]
DataProxy
,
0
,
len
(
proxies
))
for
i
:=
range
proxies
{
p
:=
proxies
[
i
]
key
:=
buildProxyKey
(
p
.
Protocol
,
p
.
Host
,
p
.
Port
,
p
.
Username
,
p
.
Password
)
proxyKeyByID
[
p
.
ID
]
=
key
dataProxies
=
append
(
dataProxies
,
DataProxy
{
ProxyKey
:
key
,
Name
:
p
.
Name
,
Protocol
:
p
.
Protocol
,
Host
:
p
.
Host
,
Port
:
p
.
Port
,
Username
:
p
.
Username
,
Password
:
p
.
Password
,
Status
:
p
.
Status
,
})
}
dataAccounts
:=
make
([]
DataAccount
,
0
,
len
(
accounts
))
for
i
:=
range
accounts
{
acc
:=
accounts
[
i
]
var
proxyKey
*
string
if
acc
.
ProxyID
!=
nil
{
if
key
,
ok
:=
proxyKeyByID
[
*
acc
.
ProxyID
];
ok
{
proxyKey
=
&
key
}
}
var
expiresAt
*
int64
if
acc
.
ExpiresAt
!=
nil
{
v
:=
acc
.
ExpiresAt
.
Unix
()
expiresAt
=
&
v
}
dataAccounts
=
append
(
dataAccounts
,
DataAccount
{
Name
:
acc
.
Name
,
Notes
:
acc
.
Notes
,
Platform
:
acc
.
Platform
,
Type
:
acc
.
Type
,
Credentials
:
acc
.
Credentials
,
Extra
:
acc
.
Extra
,
ProxyKey
:
proxyKey
,
Concurrency
:
acc
.
Concurrency
,
Priority
:
acc
.
Priority
,
RateMultiplier
:
acc
.
RateMultiplier
,
ExpiresAt
:
expiresAt
,
AutoPauseOnExpired
:
&
acc
.
AutoPauseOnExpired
,
})
}
payload
:=
DataPayload
{
Type
:
dataType
,
Version
:
dataVersion
,
ExportedAt
:
time
.
Now
()
.
UTC
()
.
Format
(
time
.
RFC3339
),
Proxies
:
dataProxies
,
Accounts
:
dataAccounts
,
}
response
.
Success
(
c
,
payload
)
}
func
(
h
*
AccountHandler
)
ImportData
(
c
*
gin
.
Context
)
{
var
req
DataImportRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
dataPayload
:=
req
.
Data
if
err
:=
validateDataHeader
(
dataPayload
);
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
skipDefaultGroupBind
:=
true
if
req
.
SkipDefaultGroupBind
!=
nil
{
skipDefaultGroupBind
=
*
req
.
SkipDefaultGroupBind
}
result
:=
DataImportResult
{}
existingProxies
,
err
:=
h
.
listAllProxies
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
proxyKeyToID
:=
make
(
map
[
string
]
int64
,
len
(
existingProxies
))
for
i
:=
range
existingProxies
{
p
:=
existingProxies
[
i
]
key
:=
buildProxyKey
(
p
.
Protocol
,
p
.
Host
,
p
.
Port
,
p
.
Username
,
p
.
Password
)
proxyKeyToID
[
key
]
=
p
.
ID
}
for
i
:=
range
dataPayload
.
Proxies
{
item
:=
dataPayload
.
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
existingID
,
ok
:=
proxyKeyToID
[
key
];
ok
{
proxyKeyToID
[
key
]
=
existingID
result
.
ProxyReused
++
continue
}
created
,
err
:=
h
.
adminService
.
CreateProxy
(
c
.
Request
.
Context
(),
&
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
}
proxyKeyToID
[
key
]
=
created
.
ID
result
.
ProxyCreated
++
if
item
.
Status
!=
""
&&
item
.
Status
!=
created
.
Status
{
_
,
_
=
h
.
adminService
.
UpdateProxy
(
c
.
Request
.
Context
(),
created
.
ID
,
&
service
.
UpdateProxyInput
{
Status
:
item
.
Status
,
})
}
}
for
i
:=
range
dataPayload
.
Accounts
{
item
:=
dataPayload
.
Accounts
[
i
]
if
err
:=
validateDataAccount
(
item
);
err
!=
nil
{
result
.
AccountFailed
++
result
.
Errors
=
append
(
result
.
Errors
,
DataImportError
{
Kind
:
"account"
,
Name
:
item
.
Name
,
Message
:
err
.
Error
(),
})
continue
}
var
proxyID
*
int64
if
item
.
ProxyKey
!=
nil
&&
*
item
.
ProxyKey
!=
""
{
if
id
,
ok
:=
proxyKeyToID
[
*
item
.
ProxyKey
];
ok
{
proxyID
=
&
id
}
else
{
result
.
AccountFailed
++
result
.
Errors
=
append
(
result
.
Errors
,
DataImportError
{
Kind
:
"account"
,
Name
:
item
.
Name
,
ProxyKey
:
*
item
.
ProxyKey
,
Message
:
"proxy_key not found"
,
})
continue
}
}
accountInput
:=
&
service
.
CreateAccountInput
{
Name
:
item
.
Name
,
Notes
:
item
.
Notes
,
Platform
:
item
.
Platform
,
Type
:
item
.
Type
,
Credentials
:
item
.
Credentials
,
Extra
:
item
.
Extra
,
ProxyID
:
proxyID
,
Concurrency
:
item
.
Concurrency
,
Priority
:
item
.
Priority
,
RateMultiplier
:
item
.
RateMultiplier
,
GroupIDs
:
nil
,
ExpiresAt
:
item
.
ExpiresAt
,
AutoPauseOnExpired
:
item
.
AutoPauseOnExpired
,
SkipDefaultGroupBind
:
skipDefaultGroupBind
,
}
if
_
,
err
:=
h
.
adminService
.
CreateAccount
(
c
.
Request
.
Context
(),
accountInput
);
err
!=
nil
{
result
.
AccountFailed
++
result
.
Errors
=
append
(
result
.
Errors
,
DataImportError
{
Kind
:
"account"
,
Name
:
item
.
Name
,
Message
:
err
.
Error
(),
})
continue
}
result
.
AccountCreated
++
}
response
.
Success
(
c
,
result
)
}
func
(
h
*
AccountHandler
)
listAllAccounts
(
ctx
context
.
Context
)
([]
service
.
Account
,
error
)
{
page
:=
1
pageSize
:=
dataPageCap
var
out
[]
service
.
Account
for
{
items
,
total
,
err
:=
h
.
adminService
.
ListAccounts
(
ctx
,
page
,
pageSize
,
""
,
""
,
""
,
""
)
if
err
!=
nil
{
return
nil
,
err
}
out
=
append
(
out
,
items
...
)
if
len
(
out
)
>=
int
(
total
)
||
len
(
items
)
==
0
{
break
}
page
++
}
return
out
,
nil
}
func
(
h
*
AccountHandler
)
listAllProxies
(
ctx
context
.
Context
)
([]
service
.
Proxy
,
error
)
{
page
:=
1
pageSize
:=
dataPageCap
var
out
[]
service
.
Proxy
for
{
items
,
total
,
err
:=
h
.
adminService
.
ListProxies
(
ctx
,
page
,
pageSize
,
""
,
""
,
""
)
if
err
!=
nil
{
return
nil
,
err
}
out
=
append
(
out
,
items
...
)
if
len
(
out
)
>=
int
(
total
)
||
len
(
items
)
==
0
{
break
}
page
++
}
return
out
,
nil
}
func
(
h
*
AccountHandler
)
listAccountsFiltered
(
ctx
context
.
Context
,
platform
,
accountType
,
status
,
search
string
)
([]
service
.
Account
,
error
)
{
page
:=
1
pageSize
:=
dataPageCap
var
out
[]
service
.
Account
for
{
items
,
total
,
err
:=
h
.
adminService
.
ListAccounts
(
ctx
,
page
,
pageSize
,
platform
,
accountType
,
status
,
search
)
if
err
!=
nil
{
return
nil
,
err
}
out
=
append
(
out
,
items
...
)
if
len
(
out
)
>=
int
(
total
)
||
len
(
items
)
==
0
{
break
}
page
++
}
return
out
,
nil
}
func
(
h
*
AccountHandler
)
resolveExportAccounts
(
ctx
context
.
Context
,
ids
[]
int64
,
c
*
gin
.
Context
)
([]
service
.
Account
,
error
)
{
if
len
(
ids
)
>
0
{
accounts
,
err
:=
h
.
adminService
.
GetAccountsByIDs
(
ctx
,
ids
)
if
err
!=
nil
{
return
nil
,
err
}
out
:=
make
([]
service
.
Account
,
0
,
len
(
accounts
))
for
_
,
acc
:=
range
accounts
{
if
acc
==
nil
{
continue
}
out
=
append
(
out
,
*
acc
)
}
return
out
,
nil
}
platform
:=
c
.
Query
(
"platform"
)
accountType
:=
c
.
Query
(
"type"
)
status
:=
c
.
Query
(
"status"
)
search
:=
strings
.
TrimSpace
(
c
.
Query
(
"search"
))
if
len
(
search
)
>
100
{
search
=
search
[
:
100
]
}
return
h
.
listAccountsFiltered
(
ctx
,
platform
,
accountType
,
status
,
search
)
}
func
(
h
*
AccountHandler
)
resolveExportProxies
(
ctx
context
.
Context
,
accounts
[]
service
.
Account
)
([]
service
.
Proxy
,
error
)
{
_
=
accounts
return
h
.
listAllProxies
(
ctx
)
}
func
parseAccountIDs
(
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 account id: %s"
,
part
)
}
ids
=
append
(
ids
,
id
)
}
}
return
ids
,
nil
}
func
parseIncludeProxies
(
c
*
gin
.
Context
)
(
bool
,
error
)
{
raw
:=
strings
.
TrimSpace
(
strings
.
ToLower
(
c
.
Query
(
"include_proxies"
)))
if
raw
==
""
{
return
true
,
nil
}
switch
raw
{
case
"1"
,
"true"
,
"yes"
,
"on"
:
return
true
,
nil
case
"0"
,
"false"
,
"no"
,
"off"
:
return
false
,
nil
default
:
return
true
,
fmt
.
Errorf
(
"invalid include_proxies value: %s"
,
raw
)
}
}
func
validateDataHeader
(
payload
DataPayload
)
error
{
if
payload
.
Type
==
""
{
return
errors
.
New
(
"data type is required"
)
}
if
payload
.
Type
!=
dataType
&&
payload
.
Type
!=
legacyDataType
{
return
fmt
.
Errorf
(
"unsupported data type: %s"
,
payload
.
Type
)
}
if
payload
.
Version
!=
dataVersion
{
return
fmt
.
Errorf
(
"unsupported data version: %d"
,
payload
.
Version
)
}
return
nil
}
func
validateDataProxy
(
item
DataProxy
)
error
{
if
strings
.
TrimSpace
(
item
.
Protocol
)
==
""
{
return
errors
.
New
(
"proxy protocol is required"
)
}
if
strings
.
TrimSpace
(
item
.
Host
)
==
""
{
return
errors
.
New
(
"proxy host is required"
)
}
if
item
.
Port
<=
0
||
item
.
Port
>
65535
{
return
errors
.
New
(
"proxy port is invalid"
)
}
switch
item
.
Protocol
{
case
"http"
,
"https"
,
"socks5"
,
"socks5h"
:
default
:
return
fmt
.
Errorf
(
"proxy protocol is invalid: %s"
,
item
.
Protocol
)
}
return
nil
}
func
validateDataAccount
(
item
DataAccount
)
error
{
if
strings
.
TrimSpace
(
item
.
Name
)
==
""
{
return
errors
.
New
(
"account name is required"
)
}
if
strings
.
TrimSpace
(
item
.
Platform
)
==
""
{
return
errors
.
New
(
"account platform is required"
)
}
if
strings
.
TrimSpace
(
item
.
Type
)
==
""
{
return
errors
.
New
(
"account type is required"
)
}
if
len
(
item
.
Credentials
)
==
0
{
return
errors
.
New
(
"account credentials is required"
)
}
switch
item
.
Type
{
case
service
.
AccountTypeOAuth
,
service
.
AccountTypeSetupToken
,
service
.
AccountTypeAPIKey
,
service
.
AccountTypeUpstream
:
default
:
return
fmt
.
Errorf
(
"account type is invalid: %s"
,
item
.
Type
)
}
if
item
.
RateMultiplier
!=
nil
&&
*
item
.
RateMultiplier
<
0
{
return
errors
.
New
(
"rate_multiplier must be >= 0"
)
}
if
item
.
Concurrency
<
0
{
return
errors
.
New
(
"concurrency must be >= 0"
)
}
if
item
.
Priority
<
0
{
return
errors
.
New
(
"priority must be >= 0"
)
}
return
nil
}
func
defaultProxyName
(
name
string
)
string
{
if
strings
.
TrimSpace
(
name
)
==
""
{
return
"imported-proxy"
}
return
name
}
backend/internal/handler/admin/account_data_handler_test.go
0 → 100644
View file @
b4bd46d0
package
admin
import
(
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type
dataResponse
struct
{
Code
int
`json:"code"`
Data
dataPayload
`json:"data"`
}
type
dataPayload
struct
{
Type
string
`json:"type"`
Version
int
`json:"version"`
Proxies
[]
dataProxy
`json:"proxies"`
Accounts
[]
dataAccount
`json:"accounts"`
}
type
dataProxy
struct
{
ProxyKey
string
`json:"proxy_key"`
Name
string
`json:"name"`
Protocol
string
`json:"protocol"`
Host
string
`json:"host"`
Port
int
`json:"port"`
Username
string
`json:"username"`
Password
string
`json:"password"`
Status
string
`json:"status"`
}
type
dataAccount
struct
{
Name
string
`json:"name"`
Platform
string
`json:"platform"`
Type
string
`json:"type"`
Credentials
map
[
string
]
any
`json:"credentials"`
Extra
map
[
string
]
any
`json:"extra"`
ProxyKey
*
string
`json:"proxy_key"`
Concurrency
int
`json:"concurrency"`
Priority
int
`json:"priority"`
}
func
setupAccountDataRouter
()
(
*
gin
.
Engine
,
*
stubAdminService
)
{
gin
.
SetMode
(
gin
.
TestMode
)
router
:=
gin
.
New
()
adminSvc
:=
newStubAdminService
()
h
:=
NewAccountHandler
(
adminSvc
,
nil
,
nil
,
nil
,
nil
,
nil
,
nil
,
nil
,
nil
,
nil
,
nil
,
nil
,
)
router
.
GET
(
"/api/v1/admin/accounts/data"
,
h
.
ExportData
)
router
.
POST
(
"/api/v1/admin/accounts/data"
,
h
.
ImportData
)
return
router
,
adminSvc
}
func
TestExportDataIncludesSecrets
(
t
*
testing
.
T
)
{
router
,
adminSvc
:=
setupAccountDataRouter
()
proxyID
:=
int64
(
11
)
adminSvc
.
proxies
=
[]
service
.
Proxy
{
{
ID
:
proxyID
,
Name
:
"proxy"
,
Protocol
:
"http"
,
Host
:
"127.0.0.1"
,
Port
:
8080
,
Username
:
"user"
,
Password
:
"pass"
,
Status
:
service
.
StatusActive
,
},
{
ID
:
12
,
Name
:
"orphan"
,
Protocol
:
"https"
,
Host
:
"10.0.0.1"
,
Port
:
443
,
Username
:
"o"
,
Password
:
"p"
,
Status
:
service
.
StatusActive
,
},
}
adminSvc
.
accounts
=
[]
service
.
Account
{
{
ID
:
21
,
Name
:
"account"
,
Platform
:
service
.
PlatformOpenAI
,
Type
:
service
.
AccountTypeOAuth
,
Credentials
:
map
[
string
]
any
{
"token"
:
"secret"
},
Extra
:
map
[
string
]
any
{
"note"
:
"x"
},
ProxyID
:
&
proxyID
,
Concurrency
:
3
,
Priority
:
50
,
Status
:
service
.
StatusDisabled
,
},
}
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/accounts/data"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
var
resp
dataResponse
require
.
NoError
(
t
,
json
.
Unmarshal
(
rec
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Equal
(
t
,
dataType
,
resp
.
Data
.
Type
)
require
.
Len
(
t
,
resp
.
Data
.
Proxies
,
2
)
require
.
Equal
(
t
,
"pass"
,
resp
.
Data
.
Proxies
[
0
]
.
Password
)
require
.
Len
(
t
,
resp
.
Data
.
Accounts
,
1
)
require
.
Equal
(
t
,
"secret"
,
resp
.
Data
.
Accounts
[
0
]
.
Credentials
[
"token"
])
}
func
TestExportDataWithoutProxies
(
t
*
testing
.
T
)
{
router
,
adminSvc
:=
setupAccountDataRouter
()
proxyID
:=
int64
(
11
)
adminSvc
.
proxies
=
[]
service
.
Proxy
{
{
ID
:
proxyID
,
Name
:
"proxy"
,
Protocol
:
"http"
,
Host
:
"127.0.0.1"
,
Port
:
8080
,
Username
:
"user"
,
Password
:
"pass"
,
Status
:
service
.
StatusActive
,
},
}
adminSvc
.
accounts
=
[]
service
.
Account
{
{
ID
:
21
,
Name
:
"account"
,
Platform
:
service
.
PlatformOpenAI
,
Type
:
service
.
AccountTypeOAuth
,
Credentials
:
map
[
string
]
any
{
"token"
:
"secret"
},
ProxyID
:
&
proxyID
,
Concurrency
:
3
,
Priority
:
50
,
Status
:
service
.
StatusDisabled
,
},
}
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/accounts/data?include_proxies=false"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
var
resp
dataResponse
require
.
NoError
(
t
,
json
.
Unmarshal
(
rec
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Len
(
t
,
resp
.
Data
.
Proxies
,
0
)
require
.
Len
(
t
,
resp
.
Data
.
Accounts
,
1
)
require
.
Nil
(
t
,
resp
.
Data
.
Accounts
[
0
]
.
ProxyKey
)
}
func
TestImportDataReusesProxyAndSkipsDefaultGroup
(
t
*
testing
.
T
)
{
router
,
adminSvc
:=
setupAccountDataRouter
()
adminSvc
.
proxies
=
[]
service
.
Proxy
{
{
ID
:
1
,
Name
:
"proxy"
,
Protocol
:
"socks5"
,
Host
:
"1.2.3.4"
,
Port
:
1080
,
Username
:
"u"
,
Password
:
"p"
,
Status
:
service
.
StatusActive
,
},
}
dataPayload
:=
map
[
string
]
any
{
"data"
:
map
[
string
]
any
{
"type"
:
dataType
,
"version"
:
dataVersion
,
"proxies"
:
[]
map
[
string
]
any
{
{
"proxy_key"
:
"socks5|1.2.3.4|1080|u|p"
,
"name"
:
"proxy"
,
"protocol"
:
"socks5"
,
"host"
:
"1.2.3.4"
,
"port"
:
1080
,
"username"
:
"u"
,
"password"
:
"p"
,
"status"
:
"active"
,
},
},
"accounts"
:
[]
map
[
string
]
any
{
{
"name"
:
"acc"
,
"platform"
:
service
.
PlatformOpenAI
,
"type"
:
service
.
AccountTypeOAuth
,
"credentials"
:
map
[
string
]
any
{
"token"
:
"x"
},
"proxy_key"
:
"socks5|1.2.3.4|1080|u|p"
,
"concurrency"
:
3
,
"priority"
:
50
,
},
},
},
"skip_default_group_bind"
:
true
,
}
body
,
_
:=
json
.
Marshal
(
dataPayload
)
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/accounts/data"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Len
(
t
,
adminSvc
.
createdProxies
,
0
)
require
.
Len
(
t
,
adminSvc
.
createdAccounts
,
1
)
require
.
True
(
t
,
adminSvc
.
createdAccounts
[
0
]
.
SkipDefaultGroupBind
)
}
backend/internal/handler/admin/account_handler.go
View file @
b4bd46d0
...
@@ -696,11 +696,61 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
...
@@ -696,11 +696,61 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
return
return
}
}
// Return mock data for now
ctx
:=
c
.
Request
.
Context
()
success
:=
0
failed
:=
0
results
:=
make
([]
gin
.
H
,
0
,
len
(
req
.
Accounts
))
for
_
,
item
:=
range
req
.
Accounts
{
if
item
.
RateMultiplier
!=
nil
&&
*
item
.
RateMultiplier
<
0
{
failed
++
results
=
append
(
results
,
gin
.
H
{
"name"
:
item
.
Name
,
"success"
:
false
,
"error"
:
"rate_multiplier must be >= 0"
,
})
continue
}
skipCheck
:=
item
.
ConfirmMixedChannelRisk
!=
nil
&&
*
item
.
ConfirmMixedChannelRisk
account
,
err
:=
h
.
adminService
.
CreateAccount
(
ctx
,
&
service
.
CreateAccountInput
{
Name
:
item
.
Name
,
Notes
:
item
.
Notes
,
Platform
:
item
.
Platform
,
Type
:
item
.
Type
,
Credentials
:
item
.
Credentials
,
Extra
:
item
.
Extra
,
ProxyID
:
item
.
ProxyID
,
Concurrency
:
item
.
Concurrency
,
Priority
:
item
.
Priority
,
RateMultiplier
:
item
.
RateMultiplier
,
GroupIDs
:
item
.
GroupIDs
,
ExpiresAt
:
item
.
ExpiresAt
,
AutoPauseOnExpired
:
item
.
AutoPauseOnExpired
,
SkipMixedChannelCheck
:
skipCheck
,
})
if
err
!=
nil
{
failed
++
results
=
append
(
results
,
gin
.
H
{
"name"
:
item
.
Name
,
"success"
:
false
,
"error"
:
err
.
Error
(),
})
continue
}
success
++
results
=
append
(
results
,
gin
.
H
{
"name"
:
item
.
Name
,
"id"
:
account
.
ID
,
"success"
:
true
,
})
}
response
.
Success
(
c
,
gin
.
H
{
response
.
Success
(
c
,
gin
.
H
{
"success"
:
len
(
req
.
Accounts
)
,
"success"
:
success
,
"failed"
:
0
,
"failed"
:
failed
,
"results"
:
[]
gin
.
H
{}
,
"results"
:
results
,
})
})
}
}
...
...
backend/internal/handler/admin/admin_service_stub_test.go
View file @
b4bd46d0
...
@@ -2,19 +2,22 @@ package admin
...
@@ -2,19 +2,22 @@ package admin
import
(
import
(
"context"
"context"
"strings"
"time"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/service"
)
)
type
stubAdminService
struct
{
type
stubAdminService
struct
{
users
[]
service
.
User
users
[]
service
.
User
apiKeys
[]
service
.
APIKey
apiKeys
[]
service
.
APIKey
groups
[]
service
.
Group
groups
[]
service
.
Group
accounts
[]
service
.
Account
accounts
[]
service
.
Account
proxies
[]
service
.
Proxy
proxies
[]
service
.
Proxy
proxyCounts
[]
service
.
ProxyWithAccountCount
proxyCounts
[]
service
.
ProxyWithAccountCount
redeems
[]
service
.
RedeemCode
redeems
[]
service
.
RedeemCode
createdAccounts
[]
*
service
.
CreateAccountInput
createdProxies
[]
*
service
.
CreateProxyInput
}
}
func
newStubAdminService
()
*
stubAdminService
{
func
newStubAdminService
()
*
stubAdminService
{
...
@@ -177,6 +180,7 @@ func (s *stubAdminService) GetAccountsByIDs(ctx context.Context, ids []int64) ([
...
@@ -177,6 +180,7 @@ func (s *stubAdminService) GetAccountsByIDs(ctx context.Context, ids []int64) ([
}
}
func
(
s
*
stubAdminService
)
CreateAccount
(
ctx
context
.
Context
,
input
*
service
.
CreateAccountInput
)
(
*
service
.
Account
,
error
)
{
func
(
s
*
stubAdminService
)
CreateAccount
(
ctx
context
.
Context
,
input
*
service
.
CreateAccountInput
)
(
*
service
.
Account
,
error
)
{
s
.
createdAccounts
=
append
(
s
.
createdAccounts
,
input
)
account
:=
service
.
Account
{
ID
:
300
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
account
:=
service
.
Account
{
ID
:
300
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
return
&
account
,
nil
}
}
...
@@ -214,7 +218,25 @@ func (s *stubAdminService) BulkUpdateAccounts(ctx context.Context, input *servic
...
@@ -214,7 +218,25 @@ func (s *stubAdminService) BulkUpdateAccounts(ctx context.Context, input *servic
}
}
func
(
s
*
stubAdminService
)
ListProxies
(
ctx
context
.
Context
,
page
,
pageSize
int
,
protocol
,
status
,
search
string
)
([]
service
.
Proxy
,
int64
,
error
)
{
func
(
s
*
stubAdminService
)
ListProxies
(
ctx
context
.
Context
,
page
,
pageSize
int
,
protocol
,
status
,
search
string
)
([]
service
.
Proxy
,
int64
,
error
)
{
return
s
.
proxies
,
int64
(
len
(
s
.
proxies
)),
nil
search
=
strings
.
TrimSpace
(
strings
.
ToLower
(
search
))
filtered
:=
make
([]
service
.
Proxy
,
0
,
len
(
s
.
proxies
))
for
_
,
proxy
:=
range
s
.
proxies
{
if
protocol
!=
""
&&
proxy
.
Protocol
!=
protocol
{
continue
}
if
status
!=
""
&&
proxy
.
Status
!=
status
{
continue
}
if
search
!=
""
{
name
:=
strings
.
ToLower
(
proxy
.
Name
)
host
:=
strings
.
ToLower
(
proxy
.
Host
)
if
!
strings
.
Contains
(
name
,
search
)
&&
!
strings
.
Contains
(
host
,
search
)
{
continue
}
}
filtered
=
append
(
filtered
,
proxy
)
}
return
filtered
,
int64
(
len
(
filtered
)),
nil
}
}
func
(
s
*
stubAdminService
)
ListProxiesWithAccountCount
(
ctx
context
.
Context
,
page
,
pageSize
int
,
protocol
,
status
,
search
string
)
([]
service
.
ProxyWithAccountCount
,
int64
,
error
)
{
func
(
s
*
stubAdminService
)
ListProxiesWithAccountCount
(
ctx
context
.
Context
,
page
,
pageSize
int
,
protocol
,
status
,
search
string
)
([]
service
.
ProxyWithAccountCount
,
int64
,
error
)
{
...
@@ -235,6 +257,7 @@ func (s *stubAdminService) GetProxy(ctx context.Context, id int64) (*service.Pro
...
@@ -235,6 +257,7 @@ func (s *stubAdminService) GetProxy(ctx context.Context, id int64) (*service.Pro
}
}
func
(
s
*
stubAdminService
)
CreateProxy
(
ctx
context
.
Context
,
input
*
service
.
CreateProxyInput
)
(
*
service
.
Proxy
,
error
)
{
func
(
s
*
stubAdminService
)
CreateProxy
(
ctx
context
.
Context
,
input
*
service
.
CreateProxyInput
)
(
*
service
.
Proxy
,
error
)
{
s
.
createdProxies
=
append
(
s
.
createdProxies
,
input
)
proxy
:=
service
.
Proxy
{
ID
:
400
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
proxy
:=
service
.
Proxy
{
ID
:
400
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
proxy
,
nil
return
&
proxy
,
nil
}
}
...
...
backend/internal/handler/admin/proxy_data.go
0 → 100644
View file @
b4bd46d0
package
admin
import
(
"context"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// ExportData exports proxy-only data for migration.
func
(
h
*
ProxyHandler
)
ExportData
(
c
*
gin
.
Context
)
{
ctx
:=
c
.
Request
.
Context
()
protocol
:=
c
.
Query
(
"protocol"
)
status
:=
c
.
Query
(
"status"
)
search
:=
strings
.
TrimSpace
(
c
.
Query
(
"search"
))
if
len
(
search
)
>
100
{
search
=
search
[
:
100
]
}
proxies
,
err
:=
h
.
listProxiesFiltered
(
ctx
,
protocol
,
status
,
search
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
dataProxies
:=
make
([]
DataProxy
,
0
,
len
(
proxies
))
for
i
:=
range
proxies
{
p
:=
proxies
[
i
]
key
:=
buildProxyKey
(
p
.
Protocol
,
p
.
Host
,
p
.
Port
,
p
.
Username
,
p
.
Password
)
dataProxies
=
append
(
dataProxies
,
DataProxy
{
ProxyKey
:
key
,
Name
:
p
.
Name
,
Protocol
:
p
.
Protocol
,
Host
:
p
.
Host
,
Port
:
p
.
Port
,
Username
:
p
.
Username
,
Password
:
p
.
Password
,
Status
:
p
.
Status
,
})
}
payload
:=
DataPayload
{
Type
:
dataType
,
Version
:
dataVersion
,
ExportedAt
:
time
.
Now
()
.
UTC
()
.
Format
(
time
.
RFC3339
),
Proxies
:
dataProxies
,
Accounts
:
[]
DataAccount
{},
}
response
.
Success
(
c
,
payload
)
}
func
(
h
*
ProxyHandler
)
listProxiesFiltered
(
ctx
context
.
Context
,
protocol
,
status
,
search
string
)
([]
service
.
Proxy
,
error
)
{
page
:=
1
pageSize
:=
dataPageCap
var
out
[]
service
.
Proxy
for
{
items
,
total
,
err
:=
h
.
adminService
.
ListProxies
(
ctx
,
page
,
pageSize
,
protocol
,
status
,
search
)
if
err
!=
nil
{
return
nil
,
err
}
out
=
append
(
out
,
items
...
)
if
len
(
out
)
>=
int
(
total
)
||
len
(
items
)
==
0
{
break
}
page
++
}
return
out
,
nil
}
backend/internal/handler/admin/proxy_data_handler_test.go
0 → 100644
View file @
b4bd46d0
package
admin
import
(
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type
proxyDataResponse
struct
{
Code
int
`json:"code"`
Data
DataPayload
`json:"data"`
}
func
setupProxyDataRouter
()
(
*
gin
.
Engine
,
*
stubAdminService
)
{
gin
.
SetMode
(
gin
.
TestMode
)
router
:=
gin
.
New
()
adminSvc
:=
newStubAdminService
()
h
:=
NewProxyHandler
(
adminSvc
)
router
.
GET
(
"/api/v1/admin/proxies/data"
,
h
.
ExportData
)
return
router
,
adminSvc
}
func
TestProxyExportDataRespectsFilters
(
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?protocol=https"
,
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
.
Equal
(
t
,
dataType
,
resp
.
Data
.
Type
)
require
.
Len
(
t
,
resp
.
Data
.
Proxies
,
1
)
require
.
Len
(
t
,
resp
.
Data
.
Accounts
,
0
)
require
.
Equal
(
t
,
"https"
,
resp
.
Data
.
Proxies
[
0
]
.
Protocol
)
}
backend/internal/server/routes/admin.go
View file @
b4bd46d0
...
@@ -219,6 +219,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
...
@@ -219,6 +219,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts
.
POST
(
"/:id/schedulable"
,
h
.
Admin
.
Account
.
SetSchedulable
)
accounts
.
POST
(
"/:id/schedulable"
,
h
.
Admin
.
Account
.
SetSchedulable
)
accounts
.
GET
(
"/:id/models"
,
h
.
Admin
.
Account
.
GetAvailableModels
)
accounts
.
GET
(
"/:id/models"
,
h
.
Admin
.
Account
.
GetAvailableModels
)
accounts
.
POST
(
"/batch"
,
h
.
Admin
.
Account
.
BatchCreate
)
accounts
.
POST
(
"/batch"
,
h
.
Admin
.
Account
.
BatchCreate
)
accounts
.
GET
(
"/data"
,
h
.
Admin
.
Account
.
ExportData
)
accounts
.
POST
(
"/data"
,
h
.
Admin
.
Account
.
ImportData
)
accounts
.
POST
(
"/batch-update-credentials"
,
h
.
Admin
.
Account
.
BatchUpdateCredentials
)
accounts
.
POST
(
"/batch-update-credentials"
,
h
.
Admin
.
Account
.
BatchUpdateCredentials
)
accounts
.
POST
(
"/batch-refresh-tier"
,
h
.
Admin
.
Account
.
BatchRefreshTier
)
accounts
.
POST
(
"/batch-refresh-tier"
,
h
.
Admin
.
Account
.
BatchRefreshTier
)
accounts
.
POST
(
"/bulk-update"
,
h
.
Admin
.
Account
.
BulkUpdate
)
accounts
.
POST
(
"/bulk-update"
,
h
.
Admin
.
Account
.
BulkUpdate
)
...
@@ -278,6 +280,7 @@ func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
...
@@ -278,6 +280,7 @@ func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
{
{
proxies
.
GET
(
""
,
h
.
Admin
.
Proxy
.
List
)
proxies
.
GET
(
""
,
h
.
Admin
.
Proxy
.
List
)
proxies
.
GET
(
"/all"
,
h
.
Admin
.
Proxy
.
GetAll
)
proxies
.
GET
(
"/all"
,
h
.
Admin
.
Proxy
.
GetAll
)
proxies
.
GET
(
"/data"
,
h
.
Admin
.
Proxy
.
ExportData
)
proxies
.
GET
(
"/:id"
,
h
.
Admin
.
Proxy
.
GetByID
)
proxies
.
GET
(
"/:id"
,
h
.
Admin
.
Proxy
.
GetByID
)
proxies
.
POST
(
""
,
h
.
Admin
.
Proxy
.
Create
)
proxies
.
POST
(
""
,
h
.
Admin
.
Proxy
.
Create
)
proxies
.
PUT
(
"/:id"
,
h
.
Admin
.
Proxy
.
Update
)
proxies
.
PUT
(
"/:id"
,
h
.
Admin
.
Proxy
.
Update
)
...
...
backend/internal/service/admin_service.go
View file @
b4bd46d0
...
@@ -166,6 +166,8 @@ type CreateAccountInput struct {
...
@@ -166,6 +166,8 @@ type CreateAccountInput struct {
GroupIDs
[]
int64
GroupIDs
[]
int64
ExpiresAt
*
int64
ExpiresAt
*
int64
AutoPauseOnExpired
*
bool
AutoPauseOnExpired
*
bool
// SkipDefaultGroupBind prevents auto-binding to platform default group when GroupIDs is empty.
SkipDefaultGroupBind
bool
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
// This should only be set when the caller has explicitly confirmed the risk.
// This should only be set when the caller has explicitly confirmed the risk.
SkipMixedChannelCheck
bool
SkipMixedChannelCheck
bool
...
@@ -1004,7 +1006,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
...
@@ -1004,7 +1006,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
// 绑定分组
// 绑定分组
groupIDs
:=
input
.
GroupIDs
groupIDs
:=
input
.
GroupIDs
// 如果没有指定分组,自动绑定对应平台的默认分组
// 如果没有指定分组,自动绑定对应平台的默认分组
if
len
(
groupIDs
)
==
0
{
if
len
(
groupIDs
)
==
0
&&
!
input
.
SkipDefaultGroupBind
{
defaultGroupName
:=
input
.
Platform
+
"-default"
defaultGroupName
:=
input
.
Platform
+
"-default"
groups
,
err
:=
s
.
groupRepo
.
ListActiveByPlatform
(
ctx
,
input
.
Platform
)
groups
,
err
:=
s
.
groupRepo
.
ListActiveByPlatform
(
ctx
,
input
.
Platform
)
if
err
==
nil
{
if
err
==
nil
{
...
...
frontend/src/__tests__/integration/data-import.spec.ts
0 → 100644
View file @
b4bd46d0
import
{
describe
,
it
,
expect
,
vi
,
beforeEach
}
from
'
vitest
'
import
{
mount
}
from
'
@vue/test-utils
'
import
ImportDataModal
from
'
@/components/admin/account/ImportDataModal.vue
'
const
showError
=
vi
.
fn
()
const
showSuccess
=
vi
.
fn
()
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
showError
,
showSuccess
})
}))
vi
.
mock
(
'
@/api/admin
'
,
()
=>
({
adminAPI
:
{
accounts
:
{
importData
:
vi
.
fn
()
}
}
}))
vi
.
mock
(
'
vue-i18n
'
,
()
=>
({
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
})
}))
describe
(
'
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.accounts.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.accounts.dataImportParseFailed
'
)
})
})
frontend/src/api/admin/accounts.ts
View file @
b4bd46d0
...
@@ -13,7 +13,9 @@ import type {
...
@@ -13,7 +13,9 @@ import type {
WindowStats
,
WindowStats
,
ClaudeModel
,
ClaudeModel
,
AccountUsageStatsResponse
,
AccountUsageStatsResponse
,
TempUnschedulableStatus
TempUnschedulableStatus
,
AdminDataPayload
,
AdminDataImportResult
}
from
'
@/types
'
}
from
'
@/types
'
/**
/**
...
@@ -347,6 +349,44 @@ export async function syncFromCrs(params: {
...
@@ -347,6 +349,44 @@ export async function syncFromCrs(params: {
return
data
return
data
}
}
export
async
function
exportData
(
options
?:
{
ids
?:
number
[]
filters
?:
{
platform
?:
string
type
?:
string
status
?:
string
search
?:
string
}
includeProxies
?:
boolean
}):
Promise
<
AdminDataPayload
>
{
const
params
:
Record
<
string
,
string
>
=
{}
if
(
options
?.
ids
&&
options
.
ids
.
length
>
0
)
{
params
.
ids
=
options
.
ids
.
join
(
'
,
'
)
}
else
if
(
options
?.
filters
)
{
const
{
platform
,
type
,
status
,
search
}
=
options
.
filters
if
(
platform
)
params
.
platform
=
platform
if
(
type
)
params
.
type
=
type
if
(
status
)
params
.
status
=
status
if
(
search
)
params
.
search
=
search
}
if
(
options
?.
includeProxies
===
false
)
{
params
.
include_proxies
=
'
false
'
}
const
{
data
}
=
await
apiClient
.
get
<
AdminDataPayload
>
(
'
/admin/accounts/data
'
,
{
params
})
return
data
}
export
async
function
importData
(
payload
:
{
data
:
AdminDataPayload
skip_default_group_bind
?:
boolean
}):
Promise
<
AdminDataImportResult
>
{
const
{
data
}
=
await
apiClient
.
post
<
AdminDataImportResult
>
(
'
/admin/accounts/data
'
,
{
data
:
payload
.
data
,
skip_default_group_bind
:
payload
.
skip_default_group_bind
})
return
data
}
export
const
accountsAPI
=
{
export
const
accountsAPI
=
{
list
,
list
,
getById
,
getById
,
...
@@ -370,7 +410,9 @@ export const accountsAPI = {
...
@@ -370,7 +410,9 @@ export const accountsAPI = {
batchCreate
,
batchCreate
,
batchUpdateCredentials
,
batchUpdateCredentials
,
bulkUpdate
,
bulkUpdate
,
syncFromCrs
syncFromCrs
,
exportData
,
importData
}
}
export
default
accountsAPI
export
default
accountsAPI
frontend/src/api/admin/proxies.ts
View file @
b4bd46d0
...
@@ -9,7 +9,8 @@ import type {
...
@@ -9,7 +9,8 @@ import type {
ProxyAccountSummary
,
ProxyAccountSummary
,
CreateProxyRequest
,
CreateProxyRequest
,
UpdateProxyRequest
,
UpdateProxyRequest
,
PaginatedResponse
PaginatedResponse
,
AdminDataPayload
}
from
'
@/types
'
}
from
'
@/types
'
/**
/**
...
@@ -208,6 +209,17 @@ export async function batchDelete(ids: number[]): Promise<{
...
@@ -208,6 +209,17 @@ export async function batchDelete(ids: number[]): Promise<{
return
data
return
data
}
}
export
async
function
exportData
(
filters
?:
{
protocol
?:
string
status
?:
'
active
'
|
'
inactive
'
search
?:
string
}):
Promise
<
AdminDataPayload
>
{
const
{
data
}
=
await
apiClient
.
get
<
AdminDataPayload
>
(
'
/admin/proxies/data
'
,
{
params
:
filters
})
return
data
}
export
const
proxiesAPI
=
{
export
const
proxiesAPI
=
{
list
,
list
,
getAll
,
getAll
,
...
@@ -221,7 +233,8 @@ export const proxiesAPI = {
...
@@ -221,7 +233,8 @@ export const proxiesAPI = {
getStats
,
getStats
,
getProxyAccounts
,
getProxyAccounts
,
batchCreate
,
batchCreate
,
batchDelete
batchDelete
,
exportData
}
}
export
default
proxiesAPI
export
default
proxiesAPI
frontend/src/components/admin/account/AccountTableActions.vue
View file @
b4bd46d0
...
@@ -7,6 +7,7 @@
...
@@ -7,6 +7,7 @@
<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>
<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>
</div>
</div>
</
template
>
</
template
>
...
...
frontend/src/components/admin/account/ImportDataModal.vue
0 → 100644
View file @
b4bd46d0
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.accounts.dataImportTitle')"
width=
"normal"
close-on-click-outside
@
close=
"handleClose"
>
<form
id=
"import-data-form"
class=
"space-y-4"
@
submit.prevent=
"handleImport"
>
<div
class=
"text-sm text-gray-600 dark:text-dark-300"
>
{{
t
(
'
admin.accounts.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.accounts.dataImportWarning
'
)
}}
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.dataImportFile
'
)
}}
</label>
<input
type=
"file"
class=
"input"
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
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.accounts.dataImportResult
'
)
}}
</div>
<div
class=
"text-sm text-gray-700 dark:text-dark-300"
>
{{
t
(
'
admin.accounts.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.accounts.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-data-form"
:disabled=
"importing"
>
{{
importing
?
t
(
'
admin.accounts.dataImporting
'
)
:
t
(
'
admin.accounts.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
fileName
=
computed
(()
=>
file
.
value
?.
name
||
''
)
const
errorItems
=
computed
(()
=>
result
.
value
?.
errors
||
[])
watch
(
()
=>
props
.
show
,
(
open
)
=>
{
if
(
open
)
{
file
.
value
=
null
result
.
value
=
null
}
}
)
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.accounts.dataImportSelectFile
'
))
return
}
importing
.
value
=
true
try
{
const
text
=
await
file
.
value
.
text
()
const
dataPayload
=
JSON
.
parse
(
text
)
const
res
=
await
adminAPI
.
accounts
.
importData
({
data
:
dataPayload
,
skip_default_group_bind
:
true
})
result
.
value
=
res
const
msgParams
:
Record
<
string
,
unknown
>
=
{
account_created
:
res
.
account_created
,
account_failed
:
res
.
account_failed
,
proxy_created
:
res
.
proxy_created
,
proxy_reused
:
res
.
proxy_reused
,
proxy_failed
:
res
.
proxy_failed
,
}
if
(
res
.
account_failed
>
0
||
res
.
proxy_failed
>
0
)
{
appStore
.
showError
(
t
(
'
admin.accounts.dataImportCompletedWithErrors
'
,
msgParams
))
}
else
{
appStore
.
showSuccess
(
t
(
'
admin.accounts.dataImportSuccess
'
,
msgParams
))
emit
(
'
imported
'
)
}
}
catch
(
error
:
any
)
{
if
(
error
instanceof
SyntaxError
)
{
appStore
.
showError
(
t
(
'
admin.accounts.dataImportParseFailed
'
))
}
else
{
appStore
.
showError
(
error
?.
message
||
t
(
'
admin.accounts.dataImportFailed
'
))
}
}
finally
{
importing
.
value
=
false
}
}
</
script
>
frontend/src/components/common/ConfirmDialog.vue
View file @
b4bd46d0
...
@@ -2,6 +2,7 @@
...
@@ -2,6 +2,7 @@
<BaseDialog
:show=
"show"
:title=
"title"
width=
"narrow"
@
close=
"handleCancel"
>
<BaseDialog
:show=
"show"
:title=
"title"
width=
"narrow"
@
close=
"handleCancel"
>
<div
class=
"space-y-4"
>
<div
class=
"space-y-4"
>
<p
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
message
}}
</p>
<p
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
message
}}
</p>
<slot></slot>
</div>
</div>
<template
#footer
>
<template
#footer
>
...
...
frontend/src/i18n/locales/en.ts
View file @
b4bd46d0
...
@@ -1188,6 +1188,28 @@ export default {
...
@@ -1188,6 +1188,28 @@ export default {
refreshInterval30s
:
'
30 seconds
'
,
refreshInterval30s
:
'
30 seconds
'
,
autoRefreshCountdown
:
'
Auto refresh: {seconds}s
'
,
autoRefreshCountdown
:
'
Auto refresh: {seconds}s
'
,
syncFromCrs
:
'
Sync from CRS
'
,
syncFromCrs
:
'
Sync from CRS
'
,
dataExport
:
'
Export
'
,
dataExportSelected
:
'
Export Selected
'
,
dataExportIncludeProxies
:
'
Include proxies (unchecked = no proxy linkage on import)
'
,
dataImport
:
'
Import
'
,
dataExportConfirmMessage
:
'
The exported data contains sensitive account and proxy information. Store it securely.
'
,
dataExportConfirm
:
'
Confirm Export
'
,
dataExported
:
'
Data exported successfully
'
,
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.
'
,
dataImportFile
:
'
Data file
'
,
dataImportButton
:
'
Start Import
'
,
dataImporting
:
'
Importing...
'
,
dataImportSelectFile
:
'
Please select a data file
'
,
dataImportParseFailed
:
'
Failed to parse data file
'
,
dataImportFailed
:
'
Data import failed
'
,
dataImportResult
:
'
Import Result
'
,
dataImportResultSummary
:
'
Proxies created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}; Accounts created {account_created}, failed {account_failed}
'
,
dataImportErrors
:
'
Error Details
'
,
dataImportSuccess
:
'
Import completed: accounts {account_created}, failed {account_failed}
'
,
dataImportCompletedWithErrors
:
'
Import completed with errors: account failed {account_failed}, proxy failed {proxy_failed}
'
,
syncFromCrsTitle
:
'
Sync Accounts from CRS
'
,
syncFromCrsTitle
:
'
Sync Accounts from CRS
'
,
syncFromCrsDesc
:
syncFromCrsDesc
:
'
Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).
'
,
'
Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).
'
,
...
@@ -1879,6 +1901,11 @@ export default {
...
@@ -1879,6 +1901,11 @@ export default {
createProxy
:
'
Create Proxy
'
,
createProxy
:
'
Create Proxy
'
,
editProxy
:
'
Edit Proxy
'
,
editProxy
:
'
Edit Proxy
'
,
deleteProxy
:
'
Delete Proxy
'
,
deleteProxy
:
'
Delete Proxy
'
,
dataExport
:
'
Export
'
,
dataExportConfirmMessage
:
'
The exported data contains sensitive proxy information. Store it securely.
'
,
dataExportConfirm
:
'
Confirm Export
'
,
dataExported
:
'
Data exported successfully
'
,
dataExportFailed
:
'
Failed to export data
'
,
searchProxies
:
'
Search proxies...
'
,
searchProxies
:
'
Search proxies...
'
,
allProtocols
:
'
All Protocols
'
,
allProtocols
:
'
All Protocols
'
,
allStatus
:
'
All Status
'
,
allStatus
:
'
All Status
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
b4bd46d0
...
@@ -1273,6 +1273,28 @@ export default {
...
@@ -1273,6 +1273,28 @@ export default {
refreshInterval30s
:
'
30 秒
'
,
refreshInterval30s
:
'
30 秒
'
,
autoRefreshCountdown
:
'
自动刷新:{seconds}s
'
,
autoRefreshCountdown
:
'
自动刷新:{seconds}s
'
,
syncFromCrs
:
'
从 CRS 同步
'
,
syncFromCrs
:
'
从 CRS 同步
'
,
dataExport
:
'
导出
'
,
dataExportSelected
:
'
导出选中
'
,
dataExportIncludeProxies
:
'
导出代理(取消后导入时不关联代理)
'
,
dataImport
:
'
导入
'
,
dataExportConfirmMessage
:
'
导出的数据包含账号与代理的敏感信息,请妥善保存。
'
,
dataExportConfirm
:
'
确认导出
'
,
dataExported
:
'
数据导出成功
'
,
dataExportFailed
:
'
数据导出失败
'
,
dataImportTitle
:
'
导入数据
'
,
dataImportHint
:
'
上传导出的 JSON 文件以批量导入账号与代理。
'
,
dataImportWarning
:
'
导入将创建新账号与代理,分组需手工绑定;请确认目标实例已有数据不会冲突。
'
,
dataImportFile
:
'
数据文件
'
,
dataImportButton
:
'
开始导入
'
,
dataImporting
:
'
导入中...
'
,
dataImportSelectFile
:
'
请选择数据文件
'
,
dataImportParseFailed
:
'
数据解析失败
'
,
dataImportFailed
:
'
数据导入失败
'
,
dataImportResult
:
'
导入结果
'
,
dataImportResultSummary
:
'
代理创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed};账号创建 {account_created},失败 {account_failed}
'
,
dataImportErrors
:
'
失败详情
'
,
dataImportSuccess
:
'
导入完成:账号 {account_created},失败 {account_failed}
'
,
dataImportCompletedWithErrors
:
'
导入完成但有错误:账号失败 {account_failed},代理失败 {proxy_failed}
'
,
syncFromCrsTitle
:
'
从 CRS 同步账号
'
,
syncFromCrsTitle
:
'
从 CRS 同步账号
'
,
syncFromCrsDesc
:
syncFromCrsDesc
:
'
将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。
'
,
'
将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。
'
,
...
@@ -1988,6 +2010,11 @@ export default {
...
@@ -1988,6 +2010,11 @@ export default {
deleteProxy
:
'
删除代理
'
,
deleteProxy
:
'
删除代理
'
,
deleteConfirmMessage
:
"
确定要删除代理 '{name}' 吗?
"
,
deleteConfirmMessage
:
"
确定要删除代理 '{name}' 吗?
"
,
testProxy
:
'
测试代理
'
,
testProxy
:
'
测试代理
'
,
dataExport
:
'
导出
'
,
dataExportConfirmMessage
:
'
导出的数据包含代理的敏感信息,请妥善保存。
'
,
dataExportConfirm
:
'
确认导出
'
,
dataExported
:
'
数据导出成功
'
,
dataExportFailed
:
'
数据导出失败
'
,
columns
:
{
columns
:
{
name
:
'
名称
'
,
name
:
'
名称
'
,
protocol
:
'
协议
'
,
protocol
:
'
协议
'
,
...
...
frontend/src/types/index.ts
View file @
b4bd46d0
...
@@ -727,6 +727,56 @@ export interface UpdateProxyRequest {
...
@@ -727,6 +727,56 @@ export interface UpdateProxyRequest {
status
?:
'
active
'
|
'
inactive
'
status
?:
'
active
'
|
'
inactive
'
}
}
export
interface
AdminDataPayload
{
type
:
string
version
:
number
exported_at
:
string
proxies
:
AdminDataProxy
[]
accounts
:
AdminDataAccount
[]
}
export
interface
AdminDataProxy
{
proxy_key
:
string
name
:
string
protocol
:
ProxyProtocol
host
:
string
port
:
number
username
?:
string
|
null
password
?:
string
|
null
status
:
'
active
'
|
'
inactive
'
}
export
interface
AdminDataAccount
{
name
:
string
notes
?:
string
|
null
platform
:
AccountPlatform
type
:
AccountType
credentials
:
Record
<
string
,
unknown
>
extra
?:
Record
<
string
,
unknown
>
proxy_key
?:
string
|
null
concurrency
:
number
priority
:
number
rate_multiplier
?:
number
|
null
expires_at
?:
number
|
null
auto_pause_on_expired
?:
boolean
}
export
interface
AdminDataImportError
{
kind
:
'
proxy
'
|
'
account
'
name
?:
string
proxy_key
?:
string
message
:
string
}
export
interface
AdminDataImportResult
{
proxy_created
:
number
proxy_reused
:
number
proxy_failed
:
number
account_created
:
number
account_failed
:
number
errors
?:
AdminDataImportError
[]
}
// ==================== Usage & Redeem Types ====================
// ==================== Usage & Redeem Types ====================
export
type
RedeemCodeType
=
'
balance
'
|
'
concurrency
'
|
'
subscription
'
|
'
invitation
'
export
type
RedeemCodeType
=
'
balance
'
|
'
concurrency
'
|
'
subscription
'
|
'
invitation
'
...
...
frontend/src/views/admin/AccountsView.vue
View file @
b4bd46d0
...
@@ -96,6 +96,14 @@
...
@@ -96,6 +96,14 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/template
>
<
/template
>
<
template
#
afterCreate
>
<
button
@
click
=
"
showImportData = true
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
admin.accounts.dataImport
'
)
}}
<
/button
>
<
button
@
click
=
"
openExportDataDialog
"
class
=
"
btn btn-secondary
"
>
{{
selIds
.
length
?
t
(
'
admin.accounts.dataExportSelected
'
)
:
t
(
'
admin.accounts.dataExport
'
)
}}
<
/button
>
<
/template
>
<
/AccountTableActions
>
<
/AccountTableActions
>
<
/div
>
<
/div
>
<
/template
>
<
/template
>
...
@@ -218,9 +226,16 @@
...
@@ -218,9 +226,16 @@
<
AccountStatsModal
:
show
=
"
showStats
"
:
account
=
"
statsAcc
"
@
close
=
"
closeStatsModal
"
/>
<
AccountStatsModal
:
show
=
"
showStats
"
:
account
=
"
statsAcc
"
@
close
=
"
closeStatsModal
"
/>
<
AccountActionMenu
:
show
=
"
menu.show
"
:
account
=
"
menu.acc
"
:
position
=
"
menu.pos
"
@
close
=
"
menu.show = false
"
@
test
=
"
handleTest
"
@
stats
=
"
handleViewStats
"
@
reauth
=
"
handleReAuth
"
@
refresh
-
token
=
"
handleRefresh
"
@
reset
-
status
=
"
handleResetStatus
"
@
clear
-
rate
-
limit
=
"
handleClearRateLimit
"
/>
<
AccountActionMenu
:
show
=
"
menu.show
"
:
account
=
"
menu.acc
"
:
position
=
"
menu.pos
"
@
close
=
"
menu.show = false
"
@
test
=
"
handleTest
"
@
stats
=
"
handleViewStats
"
@
reauth
=
"
handleReAuth
"
@
refresh
-
token
=
"
handleRefresh
"
@
reset
-
status
=
"
handleResetStatus
"
@
clear
-
rate
-
limit
=
"
handleClearRateLimit
"
/>
<
SyncFromCrsModal
:
show
=
"
showSync
"
@
close
=
"
showSync = false
"
@
synced
=
"
reload
"
/>
<
SyncFromCrsModal
:
show
=
"
showSync
"
@
close
=
"
showSync = false
"
@
synced
=
"
reload
"
/>
<
ImportDataModal
:
show
=
"
showImportData
"
@
close
=
"
showImportData = false
"
@
imported
=
"
handleDataImported
"
/>
<
BulkEditAccountModal
:
show
=
"
showBulkEdit
"
:
account
-
ids
=
"
selIds
"
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
close
=
"
showBulkEdit = false
"
@
updated
=
"
handleBulkUpdated
"
/>
<
BulkEditAccountModal
:
show
=
"
showBulkEdit
"
:
account
-
ids
=
"
selIds
"
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
close
=
"
showBulkEdit = false
"
@
updated
=
"
handleBulkUpdated
"
/>
<
TempUnschedStatusModal
:
show
=
"
showTempUnsched
"
:
account
=
"
tempUnschedAcc
"
@
close
=
"
showTempUnsched = false
"
@
reset
=
"
handleTempUnschedReset
"
/>
<
TempUnschedStatusModal
:
show
=
"
showTempUnsched
"
:
account
=
"
tempUnschedAcc
"
@
close
=
"
showTempUnsched = false
"
@
reset
=
"
handleTempUnschedReset
"
/>
<
ConfirmDialog
:
show
=
"
showDeleteDialog
"
:
title
=
"
t('admin.accounts.deleteAccount')
"
:
message
=
"
t('admin.accounts.deleteConfirm', { name: deletingAcc?.name
}
)
"
:
confirm
-
text
=
"
t('common.delete')
"
:
cancel
-
text
=
"
t('common.cancel')
"
:
danger
=
"
true
"
@
confirm
=
"
confirmDelete
"
@
cancel
=
"
showDeleteDialog = false
"
/>
<
ConfirmDialog
:
show
=
"
showDeleteDialog
"
:
title
=
"
t('admin.accounts.deleteAccount')
"
:
message
=
"
t('admin.accounts.deleteConfirm', { name: deletingAcc?.name
}
)
"
:
confirm
-
text
=
"
t('common.delete')
"
:
cancel
-
text
=
"
t('common.cancel')
"
:
danger
=
"
true
"
@
confirm
=
"
confirmDelete
"
@
cancel
=
"
showDeleteDialog = false
"
/>
<
ConfirmDialog
:
show
=
"
showExportDataDialog
"
:
title
=
"
t('admin.accounts.dataExport')
"
:
message
=
"
t('admin.accounts.dataExportConfirmMessage')
"
:
confirm
-
text
=
"
t('admin.accounts.dataExportConfirm')
"
:
cancel
-
text
=
"
t('common.cancel')
"
@
confirm
=
"
handleExportData
"
@
cancel
=
"
showExportDataDialog = false
"
>
<
label
class
=
"
flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300
"
>
<
input
type
=
"
checkbox
"
class
=
"
h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
v
-
model
=
"
includeProxyOnExport
"
/>
<
span
>
{{
t
(
'
admin.accounts.dataExportIncludeProxies
'
)
}}
<
/span
>
<
/label
>
<
/ConfirmDialog
>
<
/AppLayout
>
<
/AppLayout
>
<
/template
>
<
/template
>
...
@@ -242,6 +257,7 @@ import AccountTableActions from '@/components/admin/account/AccountTableActions.
...
@@ -242,6 +257,7 @@ import AccountTableActions from '@/components/admin/account/AccountTableActions.
import
AccountTableFilters
from
'
@/components/admin/account/AccountTableFilters.vue
'
import
AccountTableFilters
from
'
@/components/admin/account/AccountTableFilters.vue
'
import
AccountBulkActionsBar
from
'
@/components/admin/account/AccountBulkActionsBar.vue
'
import
AccountBulkActionsBar
from
'
@/components/admin/account/AccountBulkActionsBar.vue
'
import
AccountActionMenu
from
'
@/components/admin/account/AccountActionMenu.vue
'
import
AccountActionMenu
from
'
@/components/admin/account/AccountActionMenu.vue
'
import
ImportDataModal
from
'
@/components/admin/account/ImportDataModal.vue
'
import
ReAuthAccountModal
from
'
@/components/admin/account/ReAuthAccountModal.vue
'
import
ReAuthAccountModal
from
'
@/components/admin/account/ReAuthAccountModal.vue
'
import
AccountTestModal
from
'
@/components/admin/account/AccountTestModal.vue
'
import
AccountTestModal
from
'
@/components/admin/account/AccountTestModal.vue
'
import
AccountStatsModal
from
'
@/components/admin/account/AccountStatsModal.vue
'
import
AccountStatsModal
from
'
@/components/admin/account/AccountStatsModal.vue
'
...
@@ -265,6 +281,9 @@ const selIds = ref<number[]>([])
...
@@ -265,6 +281,9 @@ const selIds = ref<number[]>([])
const
showCreate
=
ref
(
false
)
const
showCreate
=
ref
(
false
)
const
showEdit
=
ref
(
false
)
const
showEdit
=
ref
(
false
)
const
showSync
=
ref
(
false
)
const
showSync
=
ref
(
false
)
const
showImportData
=
ref
(
false
)
const
showExportDataDialog
=
ref
(
false
)
const
includeProxyOnExport
=
ref
(
true
)
const
showBulkEdit
=
ref
(
false
)
const
showBulkEdit
=
ref
(
false
)
const
showTempUnsched
=
ref
(
false
)
const
showTempUnsched
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
...
@@ -279,6 +298,7 @@ const testingAcc = ref<Account | null>(null)
...
@@ -279,6 +298,7 @@ const testingAcc = ref<Account | null>(null)
const
statsAcc
=
ref
<
Account
|
null
>
(
null
)
const
statsAcc
=
ref
<
Account
|
null
>
(
null
)
const
togglingSchedulable
=
ref
<
number
|
null
>
(
null
)
const
togglingSchedulable
=
ref
<
number
|
null
>
(
null
)
const
menu
=
reactive
<
{
show
:
boolean
,
acc
:
Account
|
null
,
pos
:{
top
:
number
,
left
:
number
}
|
null
}
>
({
show
:
false
,
acc
:
null
,
pos
:
null
}
)
const
menu
=
reactive
<
{
show
:
boolean
,
acc
:
Account
|
null
,
pos
:{
top
:
number
,
left
:
number
}
|
null
}
>
({
show
:
false
,
acc
:
null
,
pos
:
null
}
)
const
exportingData
=
ref
(
false
)
// Column settings
// Column settings
const
showColumnDropdown
=
ref
(
false
)
const
showColumnDropdown
=
ref
(
false
)
...
@@ -405,6 +425,8 @@ const isAnyModalOpen = computed(() => {
...
@@ -405,6 +425,8 @@ const isAnyModalOpen = computed(() => {
showCreate
.
value
||
showCreate
.
value
||
showEdit
.
value
||
showEdit
.
value
||
showSync
.
value
||
showSync
.
value
||
showImportData
.
value
||
showExportDataDialog
.
value
||
showBulkEdit
.
value
||
showBulkEdit
.
value
||
showTempUnsched
.
value
||
showTempUnsched
.
value
||
showDeleteDialog
.
value
||
showDeleteDialog
.
value
||
...
@@ -633,6 +655,50 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
...
@@ -633,6 +655,50 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
}
}
}
}
const
handleBulkUpdated
=
()
=>
{
showBulkEdit
.
value
=
false
;
selIds
.
value
=
[];
reload
()
}
const
handleBulkUpdated
=
()
=>
{
showBulkEdit
.
value
=
false
;
selIds
.
value
=
[];
reload
()
}
const
handleDataImported
=
()
=>
{
showImportData
.
value
=
false
;
reload
()
}
const
formatExportTimestamp
=
()
=>
{
const
now
=
new
Date
()
const
pad2
=
(
value
:
number
)
=>
String
(
value
).
padStart
(
2
,
'
0
'
)
return
`${now.getFullYear()
}
${pad2(now.getMonth() + 1)
}
${pad2(now.getDate())
}
${pad2(now.getHours())
}
${pad2(now.getMinutes())
}
${pad2(now.getSeconds())
}
`
}
const
openExportDataDialog
=
()
=>
{
includeProxyOnExport
.
value
=
true
showExportDataDialog
.
value
=
true
}
const
handleExportData
=
async
()
=>
{
if
(
exportingData
.
value
)
return
exportingData
.
value
=
true
try
{
const
dataPayload
=
await
adminAPI
.
accounts
.
exportData
(
selIds
.
value
.
length
>
0
?
{
ids
:
selIds
.
value
,
includeProxies
:
includeProxyOnExport
.
value
}
:
{
includeProxies
:
includeProxyOnExport
.
value
,
filters
:
{
platform
:
params
.
platform
,
type
:
params
.
type
,
status
:
params
.
status
,
search
:
params
.
search
}
}
)
const
timestamp
=
formatExportTimestamp
()
const
filename
=
`sub2api-account-${timestamp
}
.json`
const
blob
=
new
Blob
([
JSON
.
stringify
(
dataPayload
,
null
,
2
)],
{
type
:
'
application/json
'
}
)
const
url
=
URL
.
createObjectURL
(
blob
)
const
link
=
document
.
createElement
(
'
a
'
)
link
.
href
=
url
link
.
download
=
filename
link
.
click
()
URL
.
revokeObjectURL
(
url
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.dataExported
'
))
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
?.
message
||
t
(
'
admin.accounts.dataExportFailed
'
))
}
finally
{
exportingData
.
value
=
false
showExportDataDialog
.
value
=
false
}
}
const
closeTestModal
=
()
=>
{
showTest
.
value
=
false
;
testingAcc
.
value
=
null
}
const
closeTestModal
=
()
=>
{
showTest
.
value
=
false
;
testingAcc
.
value
=
null
}
const
closeStatsModal
=
()
=>
{
showStats
.
value
=
false
;
statsAcc
.
value
=
null
}
const
closeStatsModal
=
()
=>
{
showStats
.
value
=
false
;
statsAcc
.
value
=
null
}
const
closeReAuthModal
=
()
=>
{
showReAuth
.
value
=
false
;
reAuthAcc
.
value
=
null
}
const
closeReAuthModal
=
()
=>
{
showReAuth
.
value
=
false
;
reAuthAcc
.
value
=
null
}
...
...
frontend/src/views/admin/ProxiesView.vue
View file @
b4bd46d0
...
@@ -69,6 +69,9 @@
...
@@ -69,6 +69,9 @@
<Icon
name=
"trash"
size=
"md"
class=
"mr-2"
/>
<Icon
name=
"trash"
size=
"md"
class=
"mr-2"
/>
{{
t
(
'
admin.proxies.batchDeleteAction
'
)
}}
{{
t
(
'
admin.proxies.batchDeleteAction
'
)
}}
</button>
</button>
<button
@
click=
"showExportDataDialog = true"
class=
"btn btn-secondary"
>
{{
t
(
'
admin.proxies.dataExport
'
)
}}
</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
'
)
}}
...
@@ -606,6 +609,15 @@
...
@@ -606,6 +609,15 @@
@
confirm
=
"
confirmBatchDelete
"
@
confirm
=
"
confirmBatchDelete
"
@
cancel
=
"
showBatchDeleteDialog = false
"
@
cancel
=
"
showBatchDeleteDialog = false
"
/>
/>
<
ConfirmDialog
:
show
=
"
showExportDataDialog
"
:
title
=
"
t('admin.proxies.dataExport')
"
:
message
=
"
t('admin.proxies.dataExportConfirmMessage')
"
:
confirm
-
text
=
"
t('admin.proxies.dataExportConfirm')
"
:
cancel
-
text
=
"
t('common.cancel')
"
@
confirm
=
"
handleExportData
"
@
cancel
=
"
showExportDataDialog = false
"
/>
<!--
Proxy
Accounts
Dialog
-->
<!--
Proxy
Accounts
Dialog
-->
<
BaseDialog
<
BaseDialog
...
@@ -733,8 +745,10 @@ const showCreateModal = ref(false)
...
@@ -733,8 +745,10 @@ const showCreateModal = ref(false)
const
showEditModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showBatchDeleteDialog
=
ref
(
false
)
const
showBatchDeleteDialog
=
ref
(
false
)
const
showExportDataDialog
=
ref
(
false
)
const
showAccountsModal
=
ref
(
false
)
const
showAccountsModal
=
ref
(
false
)
const
submitting
=
ref
(
false
)
const
submitting
=
ref
(
false
)
const
exportingData
=
ref
(
false
)
const
testingProxyIds
=
ref
<
Set
<
number
>>
(
new
Set
())
const
testingProxyIds
=
ref
<
Set
<
number
>>
(
new
Set
())
const
batchTesting
=
ref
(
false
)
const
batchTesting
=
ref
(
false
)
const
selectedProxyIds
=
ref
<
Set
<
number
>>
(
new
Set
())
const
selectedProxyIds
=
ref
<
Set
<
number
>>
(
new
Set
())
...
@@ -1228,6 +1242,39 @@ const handleBatchTest = async () => {
...
@@ -1228,6 +1242,39 @@ const handleBatchTest = async () => {
}
}
}
}
const
formatExportTimestamp
=
()
=>
{
const
now
=
new
Date
()
const
pad2
=
(
value
:
number
)
=>
String
(
value
).
padStart
(
2
,
'
0
'
)
return
`${now.getFullYear()
}
${pad2(now.getMonth() + 1)
}
${pad2(now.getDate())
}
${pad2(now.getHours())
}
${pad2(now.getMinutes())
}
${pad2(now.getSeconds())
}
`
}
const
handleExportData
=
async
()
=>
{
if
(
exportingData
.
value
)
return
exportingData
.
value
=
true
try
{
const
dataPayload
=
await
adminAPI
.
proxies
.
exportData
({
protocol
:
filters
.
protocol
||
undefined
,
status
:
(
filters
.
status
||
undefined
)
as
'
active
'
|
'
inactive
'
|
undefined
,
search
:
searchQuery
.
value
||
undefined
}
)
const
timestamp
=
formatExportTimestamp
()
const
filename
=
`sub2api-proxy-${timestamp
}
.json`
const
blob
=
new
Blob
([
JSON
.
stringify
(
dataPayload
,
null
,
2
)],
{
type
:
'
application/json
'
}
)
const
url
=
URL
.
createObjectURL
(
blob
)
const
link
=
document
.
createElement
(
'
a
'
)
link
.
href
=
url
link
.
download
=
filename
link
.
click
()
URL
.
revokeObjectURL
(
url
)
appStore
.
showSuccess
(
t
(
'
admin.proxies.dataExported
'
))
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
?.
message
||
t
(
'
admin.proxies.dataExportFailed
'
))
}
finally
{
exportingData
.
value
=
false
showExportDataDialog
.
value
=
false
}
}
const
handleDelete
=
(
proxy
:
Proxy
)
=>
{
const
handleDelete
=
(
proxy
:
Proxy
)
=>
{
if
((
proxy
.
account_count
||
0
)
>
0
)
{
if
((
proxy
.
account_count
||
0
)
>
0
)
{
appStore
.
showError
(
t
(
'
admin.proxies.deleteBlockedInUse
'
))
appStore
.
showError
(
t
(
'
admin.proxies.deleteBlockedInUse
'
))
...
...
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