From b4bd46d067ee7d731b56d043eab96403e137b682 Mon Sep 17 00:00:00 2001
From: LLLLLLiulei <1065070665@qq.com>
Date: Thu, 5 Feb 2026 17:46:08 +0800
Subject: [PATCH 1/6] feat: add data import/export bundle
---
.../internal/handler/admin/account_data.go | 510 ++++++++++++++++++
.../admin/account_data_handler_test.go | 230 ++++++++
.../internal/handler/admin/account_handler.go | 58 +-
.../handler/admin/admin_service_stub_test.go | 39 +-
backend/internal/handler/admin/proxy_data.go | 73 +++
.../handler/admin/proxy_data_handler_test.go | 68 +++
backend/internal/server/routes/admin.go | 3 +
backend/internal/service/admin_service.go | 4 +-
.../__tests__/integration/data-import.spec.ts | 70 +++
frontend/src/api/admin/accounts.ts | 46 +-
frontend/src/api/admin/proxies.ts | 17 +-
.../admin/account/AccountTableActions.vue | 1 +
.../admin/account/ImportDataModal.vue | 168 ++++++
.../src/components/common/ConfirmDialog.vue | 1 +
frontend/src/i18n/locales/en.ts | 27 +
frontend/src/i18n/locales/zh.ts | 27 +
frontend/src/types/index.ts | 50 ++
frontend/src/views/admin/AccountsView.vue | 66 +++
frontend/src/views/admin/ProxiesView.vue | 47 ++
19 files changed, 1488 insertions(+), 17 deletions(-)
create mode 100644 backend/internal/handler/admin/account_data.go
create mode 100644 backend/internal/handler/admin/account_data_handler_test.go
create mode 100644 backend/internal/handler/admin/proxy_data.go
create mode 100644 backend/internal/handler/admin/proxy_data_handler_test.go
create mode 100644 frontend/src/__tests__/integration/data-import.spec.ts
create mode 100644 frontend/src/components/admin/account/ImportDataModal.vue
diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go
new file mode 100644
index 00000000..65ef62ac
--- /dev/null
+++ b/backend/internal/handler/admin/account_data.go
@@ -0,0 +1,510 @@
+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
+}
diff --git a/backend/internal/handler/admin/account_data_handler_test.go b/backend/internal/handler/admin/account_data_handler_test.go
new file mode 100644
index 00000000..9a6870f4
--- /dev/null
+++ b/backend/internal/handler/admin/account_data_handler_test.go
@@ -0,0 +1,230 @@
+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)
+}
diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index 6d42f726..2673e614 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -696,11 +696,61 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
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{
- "success": len(req.Accounts),
- "failed": 0,
- "results": []gin.H{},
+ "success": success,
+ "failed": failed,
+ "results": results,
})
}
diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go
index ea2ea963..b355b5ad 100644
--- a/backend/internal/handler/admin/admin_service_stub_test.go
+++ b/backend/internal/handler/admin/admin_service_stub_test.go
@@ -2,19 +2,22 @@ package admin
import (
"context"
+ "strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type stubAdminService struct {
- users []service.User
- apiKeys []service.APIKey
- groups []service.Group
- accounts []service.Account
- proxies []service.Proxy
- proxyCounts []service.ProxyWithAccountCount
- redeems []service.RedeemCode
+ users []service.User
+ apiKeys []service.APIKey
+ groups []service.Group
+ accounts []service.Account
+ proxies []service.Proxy
+ proxyCounts []service.ProxyWithAccountCount
+ redeems []service.RedeemCode
+ createdAccounts []*service.CreateAccountInput
+ createdProxies []*service.CreateProxyInput
}
func newStubAdminService() *stubAdminService {
@@ -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) {
+ s.createdAccounts = append(s.createdAccounts, input)
account := service.Account{ID: 300, Name: input.Name, Status: service.StatusActive}
return &account, nil
}
@@ -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) {
- 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) {
@@ -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) {
+ s.createdProxies = append(s.createdProxies, input)
proxy := service.Proxy{ID: 400, Name: input.Name, Status: service.StatusActive}
return &proxy, nil
}
diff --git a/backend/internal/handler/admin/proxy_data.go b/backend/internal/handler/admin/proxy_data.go
new file mode 100644
index 00000000..5ede58d6
--- /dev/null
+++ b/backend/internal/handler/admin/proxy_data.go
@@ -0,0 +1,73 @@
+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
+}
diff --git a/backend/internal/handler/admin/proxy_data_handler_test.go b/backend/internal/handler/admin/proxy_data_handler_test.go
new file mode 100644
index 00000000..21c5dcf6
--- /dev/null
+++ b/backend/internal/handler/admin/proxy_data_handler_test.go
@@ -0,0 +1,68 @@
+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)
+}
diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go
index ca9d627e..accd12a3 100644
--- a/backend/internal/server/routes/admin.go
+++ b/backend/internal/server/routes/admin.go
@@ -219,6 +219,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
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-refresh-tier", h.Admin.Account.BatchRefreshTier)
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
@@ -278,6 +280,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.GET("/:id", h.Admin.Proxy.GetByID)
proxies.POST("", h.Admin.Proxy.Create)
proxies.PUT("/:id", h.Admin.Proxy.Update)
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index c512f235..3f7624ac 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -166,6 +166,8 @@ type CreateAccountInput struct {
GroupIDs []int64
ExpiresAt *int64
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.
// This should only be set when the caller has explicitly confirmed the risk.
SkipMixedChannelCheck bool
@@ -1004,7 +1006,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
// 绑定分组
groupIDs := input.GroupIDs
// 如果没有指定分组,自动绑定对应平台的默认分组
- if len(groupIDs) == 0 {
+ if len(groupIDs) == 0 && !input.SkipDefaultGroupBind {
defaultGroupName := input.Platform + "-default"
groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform)
if err == nil {
diff --git a/frontend/src/__tests__/integration/data-import.spec.ts b/frontend/src/__tests__/integration/data-import.spec.ts
new file mode 100644
index 00000000..1fe870ab
--- /dev/null
+++ b/frontend/src/__tests__/integration/data-import.spec.ts
@@ -0,0 +1,70 @@
+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: '
' }
+ }
+ }
+ })
+
+ 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: '
' }
+ }
+ }
+ })
+
+ 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')
+ })
+})
diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts
index 54d0ad94..97776a06 100644
--- a/frontend/src/api/admin/accounts.ts
+++ b/frontend/src/api/admin/accounts.ts
@@ -13,7 +13,9 @@ import type {
WindowStats,
ClaudeModel,
AccountUsageStatsResponse,
- TempUnschedulableStatus
+ TempUnschedulableStatus,
+ AdminDataPayload,
+ AdminDataImportResult
} from '@/types'
/**
@@ -347,6 +349,44 @@ export async function syncFromCrs(params: {
return data
}
+export async function exportData(options?: {
+ ids?: number[]
+ filters?: {
+ platform?: string
+ type?: string
+ status?: string
+ search?: string
+ }
+ includeProxies?: boolean
+}): Promise {
+ const params: Record = {}
+ 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('/admin/accounts/data', { params })
+ return data
+}
+
+export async function importData(payload: {
+ data: AdminDataPayload
+ skip_default_group_bind?: boolean
+}): Promise {
+ const { data } = await apiClient.post('/admin/accounts/data', {
+ data: payload.data,
+ skip_default_group_bind: payload.skip_default_group_bind
+ })
+ return data
+}
+
export const accountsAPI = {
list,
getById,
@@ -370,7 +410,9 @@ export const accountsAPI = {
batchCreate,
batchUpdateCredentials,
bulkUpdate,
- syncFromCrs
+ syncFromCrs,
+ exportData,
+ importData
}
export default accountsAPI
diff --git a/frontend/src/api/admin/proxies.ts b/frontend/src/api/admin/proxies.ts
index 1af2ea39..b2545a7e 100644
--- a/frontend/src/api/admin/proxies.ts
+++ b/frontend/src/api/admin/proxies.ts
@@ -9,7 +9,8 @@ import type {
ProxyAccountSummary,
CreateProxyRequest,
UpdateProxyRequest,
- PaginatedResponse
+ PaginatedResponse,
+ AdminDataPayload
} from '@/types'
/**
@@ -208,6 +209,17 @@ export async function batchDelete(ids: number[]): Promise<{
return data
}
+export async function exportData(filters?: {
+ protocol?: string
+ status?: 'active' | 'inactive'
+ search?: string
+}): Promise {
+ const { data } = await apiClient.get('/admin/proxies/data', {
+ params: filters
+ })
+ return data
+}
+
export const proxiesAPI = {
list,
getAll,
@@ -221,7 +233,8 @@ export const proxiesAPI = {
getStats,
getProxyAccounts,
batchCreate,
- batchDelete
+ batchDelete,
+ exportData
}
export default proxiesAPI
diff --git a/frontend/src/components/admin/account/AccountTableActions.vue b/frontend/src/components/admin/account/AccountTableActions.vue
index 8dffd6d1..a449f866 100644
--- a/frontend/src/components/admin/account/AccountTableActions.vue
+++ b/frontend/src/components/admin/account/AccountTableActions.vue
@@ -7,6 +7,7 @@
+
diff --git a/frontend/src/components/admin/account/ImportDataModal.vue b/frontend/src/components/admin/account/ImportDataModal.vue
new file mode 100644
index 00000000..5b42fe17
--- /dev/null
+++ b/frontend/src/components/admin/account/ImportDataModal.vue
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/ConfirmDialog.vue b/frontend/src/components/common/ConfirmDialog.vue
index abccc416..6ffd9b77 100644
--- a/frontend/src/components/common/ConfirmDialog.vue
+++ b/frontend/src/components/common/ConfirmDialog.vue
@@ -2,6 +2,7 @@
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index fb255c1a..8a7fb48f 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -1188,6 +1188,28 @@ export default {
refreshInterval30s: '30 seconds',
autoRefreshCountdown: 'Auto refresh: {seconds}s',
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',
syncFromCrsDesc:
'Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).',
@@ -1879,6 +1901,11 @@ export default {
createProxy: 'Create Proxy',
editProxy: 'Edit 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...',
allProtocols: 'All Protocols',
allStatus: 'All Status',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index e964aae2..006a7bd2 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -1273,6 +1273,28 @@ export default {
refreshInterval30s: '30 秒',
autoRefreshCountdown: '自动刷新:{seconds}s',
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 同步账号',
syncFromCrsDesc:
'将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。',
@@ -1988,6 +2010,11 @@ export default {
deleteProxy: '删除代理',
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
testProxy: '测试代理',
+ dataExport: '导出',
+ dataExportConfirmMessage: '导出的数据包含代理的敏感信息,请妥善保存。',
+ dataExportConfirm: '确认导出',
+ dataExported: '数据导出成功',
+ dataExportFailed: '数据导出失败',
columns: {
name: '名称',
protocol: '协议',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index eb53de44..bf4a1fbc 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -727,6 +727,56 @@ export interface UpdateProxyRequest {
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
+ extra?: Record
+ 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 ====================
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue
index 222f248d..d8ecd372 100644
--- a/frontend/src/views/admin/AccountsView.vue
+++ b/frontend/src/views/admin/AccountsView.vue
@@ -96,6 +96,14 @@
+
+
+
+
@@ -218,9 +226,16 @@
+
+
+
+
@@ -242,6 +257,7 @@ import AccountTableActions from '@/components/admin/account/AccountTableActions.
import AccountTableFilters from '@/components/admin/account/AccountTableFilters.vue'
import AccountBulkActionsBar from '@/components/admin/account/AccountBulkActionsBar.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 AccountTestModal from '@/components/admin/account/AccountTestModal.vue'
import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
@@ -265,6 +281,9 @@ const selIds = ref([])
const showCreate = ref(false)
const showEdit = ref(false)
const showSync = ref(false)
+const showImportData = ref(false)
+const showExportDataDialog = ref(false)
+const includeProxyOnExport = ref(true)
const showBulkEdit = ref(false)
const showTempUnsched = ref(false)
const showDeleteDialog = ref(false)
@@ -279,6 +298,7 @@ const testingAcc = ref(null)
const statsAcc = ref(null)
const togglingSchedulable = ref(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
const showColumnDropdown = ref(false)
@@ -405,6 +425,8 @@ const isAnyModalOpen = computed(() => {
showCreate.value ||
showEdit.value ||
showSync.value ||
+ showImportData.value ||
+ showExportDataDialog.value ||
showBulkEdit.value ||
showTempUnsched.value ||
showDeleteDialog.value ||
@@ -633,6 +655,50 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
}
}
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 closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = null }
diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue
index 3bd766b6..f6cec7ac 100644
--- a/frontend/src/views/admin/ProxiesView.vue
+++ b/frontend/src/views/admin/ProxiesView.vue
@@ -69,6 +69,9 @@
{{ t('admin.proxies.batchDeleteAction') }}
+