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