mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-16 21:03:44 +08:00
FIX #29
This commit is contained in:
@@ -3,6 +3,7 @@ package device
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,25 @@ func CountryForMCC(mcc string) (string, bool) {
|
||||
return country, len(country) == 2
|
||||
}
|
||||
|
||||
// MCCsByCountry returns the complete MCC grouping from the embedded carrier
|
||||
// database, keyed by ISO alpha-2 country/territory code. The returned map and
|
||||
// slices are new values and may be safely modified by callers.
|
||||
func MCCsByCountry() map[string][]string {
|
||||
result := make(map[string][]string)
|
||||
for mcc, rawCountry := range globalCarrierDatabase.Countries {
|
||||
country := strings.ToUpper(strings.TrimSpace(rawCountry))
|
||||
mcc = strings.TrimSpace(mcc)
|
||||
if len(country) != 2 || len(mcc) != 3 {
|
||||
continue
|
||||
}
|
||||
result[country] = append(result[country], mcc)
|
||||
}
|
||||
for country := range result {
|
||||
sort.Strings(result[country])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var globalCarrierDatabase = func() carrierDatabase {
|
||||
var database carrierDatabase
|
||||
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
|
||||
|
||||
@@ -74,6 +74,17 @@ func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCCsByCountryReturnsCompleteIndependentGrouping(t *testing.T) {
|
||||
grouped := MCCsByCountry()
|
||||
if got := grouped["GB"]; len(got) != 2 || got[0] != "234" || got[1] != "235" {
|
||||
t.Fatalf("GB MCCs = %#v", got)
|
||||
}
|
||||
grouped["GB"][0] = "999"
|
||||
if country, ok := CountryForMCC("234"); !ok || country != "GB" {
|
||||
t.Fatalf("mutating returned grouping changed embedded index: (%q, %v)", country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
tests := []struct {
|
||||
imsi string
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/i18n"
|
||||
localproxy "vocat/internal/proxy"
|
||||
"vocat/internal/store"
|
||||
@@ -100,6 +102,48 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
payload.ID = id
|
||||
s.saveAndProbeUpstream(w, r, payload)
|
||||
case http.MethodPatch:
|
||||
var request struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
value, err := s.store.UpstreamProxy(r.Context(), id)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
value.Enabled = request.Enabled
|
||||
value.UpdatedAt = time.Now().UTC()
|
||||
if err := s.store.UpsertUpstreamProxy(r.Context(), value); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
bindings, err := s.store.ListDeviceProxyBindings(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
reconnectRequested := false
|
||||
var reconnectErrors []string
|
||||
for _, binding := range bindings {
|
||||
if binding.UpstreamProxyID != id {
|
||||
continue
|
||||
}
|
||||
requested, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
|
||||
reconnectRequested = reconnectRequested || requested
|
||||
if reconnectErr != nil {
|
||||
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
|
||||
}
|
||||
}
|
||||
response := upstreamProxyResponse(value.Redacted())
|
||||
response["reconnect_requested"] = reconnectRequested
|
||||
if len(reconnectErrors) > 0 {
|
||||
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": response})
|
||||
case http.MethodDelete:
|
||||
bindings, listErr := s.store.ListDeviceProxyBindings(r.Context())
|
||||
if listErr != nil {
|
||||
@@ -117,7 +161,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
|
||||
default:
|
||||
w.Header().Set("Allow", "PUT, DELETE")
|
||||
w.Header().Set("Allow", "PUT, PATCH, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
@@ -589,7 +633,7 @@ func countryNameForMCC(mcc string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var proxyCountries = []proxyCountry{
|
||||
var namedProxyCountries = []proxyCountry{
|
||||
{Code: "CN", Name: "中国", MCCs: []string{"460", "461"}},
|
||||
{Code: "HK", Name: "中国香港", MCCs: []string{"454"}},
|
||||
{Code: "MO", Name: "中国澳门", MCCs: []string{"455"}},
|
||||
@@ -644,3 +688,26 @@ var proxyCountries = []proxyCountry{
|
||||
{Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}},
|
||||
{Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}},
|
||||
}
|
||||
|
||||
var proxyCountries = buildProxyCountries()
|
||||
|
||||
func buildProxyCountries() []proxyCountry {
|
||||
byCode := make(map[string]proxyCountry)
|
||||
for _, country := range namedProxyCountries {
|
||||
byCode[country.Code] = country
|
||||
}
|
||||
for code, mccs := range device.MCCsByCountry() {
|
||||
country, found := byCode[code]
|
||||
if !found {
|
||||
country = proxyCountry{Code: code, Name: code}
|
||||
}
|
||||
country.MCCs = append([]string(nil), mccs...)
|
||||
byCode[code] = country
|
||||
}
|
||||
result := make([]proxyCountry, 0, len(byCode))
|
||||
for _, country := range byCode {
|
||||
result = append(result, country)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Code < result[j].Code })
|
||||
return result
|
||||
}
|
||||
|
||||
+46
-17
@@ -357,23 +357,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
|
||||
}
|
||||
|
||||
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
|
||||
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
||||
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
||||
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
||||
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
||||
value, err := normalizeDeviceProxyBinding(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
createdAt := value.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = now
|
||||
}
|
||||
updatedAt := value.UpdatedAt
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = now
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO device_proxy_bindings (
|
||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
@@ -382,13 +370,54 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
|
||||
profile_name = excluded.profile_name,
|
||||
upstream_proxy_id = excluded.upstream_proxy_id,
|
||||
updated_at = excluded.updated_at
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertDeviceProxyBindingIfAbsent materializes a default route without ever
|
||||
// replacing an explicit (or concurrently-created) ICCID binding.
|
||||
func (s *Store) InsertDeviceProxyBindingIfAbsent(ctx context.Context, value DeviceProxyBinding) (bool, error) {
|
||||
value, err := normalizeDeviceProxyBinding(value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO device_proxy_bindings (
|
||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(iccid) DO NOTHING
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("insert proxy binding for ICCID %q if absent: %w", value.ICCID, err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read inserted proxy binding result for ICCID %q: %w", value.ICCID, err)
|
||||
}
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
func normalizeDeviceProxyBinding(value DeviceProxyBinding) (DeviceProxyBinding, error) {
|
||||
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
||||
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
||||
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
||||
return DeviceProxyBinding{}, errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if value.CreatedAt.IsZero() {
|
||||
value.CreatedAt = now
|
||||
}
|
||||
if value.UpdatedAt.IsZero() {
|
||||
value.UpdatedAt = now
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
|
||||
return deviceProxyBinding(s.db.QueryRowContext(
|
||||
ctx,
|
||||
|
||||
@@ -32,6 +32,7 @@ func (resolver ProxyResolver) Resolve(
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
var upstreamID string
|
||||
matchedCountryRule := false
|
||||
if iccid != "" {
|
||||
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if err == nil {
|
||||
@@ -43,7 +44,10 @@ func (resolver ProxyResolver) Resolve(
|
||||
if upstreamID == "" {
|
||||
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
|
||||
if !found {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
country = strings.ToUpper(strings.TrimSpace(request.CountryCode))
|
||||
if len(country) != 2 {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
}
|
||||
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
|
||||
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
|
||||
@@ -53,6 +57,7 @@ func (resolver ProxyResolver) Resolve(
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
|
||||
}
|
||||
upstreamID = rule.UpstreamProxyID
|
||||
matchedCountryRule = true
|
||||
}
|
||||
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
|
||||
if err != nil {
|
||||
@@ -64,12 +69,43 @@ func (resolver ProxyResolver) Resolve(
|
||||
)
|
||||
}
|
||||
if !upstream.Enabled {
|
||||
if matchedCountryRule {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf(
|
||||
"upstream proxy %q for device %s is disabled",
|
||||
upstream.ID,
|
||||
deviceID,
|
||||
)
|
||||
}
|
||||
if matchedCountryRule && iccid != "" {
|
||||
created, bindErr := resolver.Store.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: deviceID,
|
||||
ICCID: iccid,
|
||||
ProfileName: iccid,
|
||||
UpstreamProxyID: upstream.ID,
|
||||
})
|
||||
if bindErr != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("materialize MCC proxy route for ICCID %s: %w", iccid, bindErr)
|
||||
}
|
||||
if !created {
|
||||
// Another request or an administrator may have created an explicit
|
||||
// binding after our first lookup. The persisted ICCID route wins.
|
||||
binding, bindingErr := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if bindingErr != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("reload proxy binding for ICCID %s: %w", iccid, bindingErr)
|
||||
}
|
||||
if binding.UpstreamProxyID != upstream.ID {
|
||||
upstream, err = resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
|
||||
if err != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("load materialized upstream proxy %q for device %s: %w", binding.UpstreamProxyID, deviceID, err)
|
||||
}
|
||||
if !upstream.Enabled {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("upstream proxy %q for device %s is disabled", upstream.ID, deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return vowifi.ProxyRoute{
|
||||
Mode: vowifi.ProxyModeSOCKS5,
|
||||
ID: upstream.ID,
|
||||
|
||||
@@ -103,6 +103,141 @@ func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverCountryRuleWithDisabledProxyFallsBackDirect(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "disabled", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.Mode != vowifi.ProxyModeDirect {
|
||||
t.Fatalf("route = %#v, want direct for a disabled country default", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverICCIDBindingWithDisabledProxyFailsClosed(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Manual", UpstreamProxyID: "disabled",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverMaterializesCountryRuleAsICCIDBinding(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, proxy := range []store.UpstreamProxy{
|
||||
{ID: "first", Name: "First", Addr: "127.0.0.1:1080", Enabled: true},
|
||||
{ID: "later", Name: "Later", Addr: "127.0.0.1:1081", Enabled: true},
|
||||
} {
|
||||
if err := database.UpsertUpstreamProxy(ctx, proxy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "first", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := vowifi.ProxyRequest{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
||||
}
|
||||
resolver := ProxyResolver{Store: database}
|
||||
route, err := resolver.Resolve(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.ID != "first" {
|
||||
t.Fatalf("first route = %#v, want MCC default", route)
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(ctx, request.ICCID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding.DeviceID != request.DeviceID || binding.UpstreamProxyID != "first" {
|
||||
t.Fatalf("materialized binding = %#v", binding)
|
||||
}
|
||||
|
||||
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "later", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route, err = resolver.Resolve(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.ID != "first" {
|
||||
t.Fatalf("route after country rule edit = %#v, want durable ICCID binding", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertDeviceProxyBindingIfAbsentDoesNotReplaceExplicitBinding(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, proxyID := range []string{"explicit", "default"} {
|
||||
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||
ID: proxyID, Name: proxyID, Addr: "127.0.0.1:1080", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
iccid := "89441000400128014257"
|
||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := database.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: iccid, ProfileName: "Automatic", UpstreamProxyID: "default",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
t.Fatal("default binding unexpectedly replaced an explicit binding")
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(ctx, iccid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding.UpstreamProxyID != "explicit" || binding.ProfileName != "Manual" {
|
||||
t.Fatalf("binding = %#v, want explicit binding unchanged", binding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
|
||||
database := testStore(t)
|
||||
for _, proxy := range []store.UpstreamProxy{
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmbeddedDistributionContainsIndex(t *testing.T) {
|
||||
index, err := fs.ReadFile(Dist, "index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded index.html: %v", err)
|
||||
}
|
||||
if len(index) == 0 {
|
||||
t.Fatal("embedded index.html is empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { SearchRegular } from "@fluentui/react-icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Country, CountryRule, UpstreamProxy } from "../../types";
|
||||
import { Button, EmptyState, Input, Modal, Select } from "../ui";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export interface CountryRulesDialogProps {
|
||||
open: boolean;
|
||||
proxies: UpstreamProxy[];
|
||||
countries: Country[];
|
||||
rules: CountryRule[];
|
||||
busy: boolean;
|
||||
onSave: (assignments: Record<string, string>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CountryRulesDialog(props: CountryRulesDialogProps) {
|
||||
const { t, lang } = useI18n();
|
||||
const { open, proxies, countries, rules, busy, onSave, onClose } = props;
|
||||
const [query, setQuery] = useState("");
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
const regionNames = useMemo(() => {
|
||||
try {
|
||||
return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [lang]);
|
||||
const countryLabel = (country: Country) => regionNames?.of(country.countryCode) || country.countryName || country.countryCode;
|
||||
const proxyOptions = useMemo(() => [
|
||||
{ value: "", label: t("直连") },
|
||||
...proxies.map((proxy) => ({
|
||||
value: proxy.id,
|
||||
label: proxy.enabled ? (proxy.name || proxy.id) : `${proxy.name || proxy.id}(${t("已禁用")})`,
|
||||
disabled: !proxy.enabled,
|
||||
})),
|
||||
], [proxies, t, lang]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setAssignments({});
|
||||
return;
|
||||
}
|
||||
setAssignments(Object.fromEntries(rules.filter((rule) => rule.enabled).map((rule) => [rule.countryCode, rule.upstreamProxyId])));
|
||||
// Sample rules only when opening. Polling must not discard in-progress edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
return [...countries]
|
||||
.sort((a, b) => countryLabel(a).localeCompare(countryLabel(b), lang === "zh" ? "zh-CN" : "en"))
|
||||
.filter((country) => {
|
||||
if (!needle) return true;
|
||||
return [country.countryCode, country.countryName, countryLabel(country), ...country.mccs]
|
||||
.some((value) => String(value || "").toLocaleLowerCase().includes(needle));
|
||||
});
|
||||
}, [countries, query, lang, regionNames]);
|
||||
|
||||
const configuredCount = Object.values(assignments).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("MCC 国家规则")}
|
||||
width="max-w-5xl"
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>{t("取消")}</Button>
|
||||
<Button variant="primary" loading={busy} onClick={() => onSave(assignments)}>{t("保存规则")}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4 pb-1">
|
||||
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs leading-5 text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
|
||||
{t("为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。")}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-xs text-gray-500">{configuredCount} {t("个国家规则")}</div>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("搜索国家、地区代码或 MCC")}
|
||||
prefix={<SearchRegular />}
|
||||
className="w-full sm:w-72"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-gray-100 dark:border-white/10">
|
||||
<div className="max-h-[55vh] overflow-auto">
|
||||
<table className="w-full min-w-[680px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-[#202027]">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t("国家 / 地区")}</th>
|
||||
<th className="px-4 py-3">MCC</th>
|
||||
<th className="w-72 px-4 py-3">{t("规则")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
|
||||
{filtered.map((country) => (
|
||||
<tr key={country.countryCode} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium">{countryLabel(country)}</span>
|
||||
<span className="ml-2 font-mono text-xs text-gray-400">{country.countryCode}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-300">{country.mccs.join(", ")}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Select
|
||||
value={assignments[country.countryCode] || ""}
|
||||
options={proxyOptions}
|
||||
disabled={busy}
|
||||
onChange={(value) => setAssignments((current) => ({ ...current, [country.countryCode]: value }))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filtered.length === 0 ? <EmptyState title={t("没有匹配的国家或 MCC")} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
|
||||
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular, PauseRegular, PlayRegular } from "@fluentui/react-icons";
|
||||
import type { UpstreamProxy } from "../../types";
|
||||
import { Button, Tag } from "../ui";
|
||||
import type { LoadError, UpstreamRow } from "./shared";
|
||||
@@ -12,9 +12,11 @@ export interface UpstreamSectionProps {
|
||||
onEdit: (proxy: UpstreamProxy) => void;
|
||||
onDelete: (proxy: UpstreamProxy) => void;
|
||||
onOpenBindings: (proxy: UpstreamProxy) => void;
|
||||
onToggle: (proxy: UpstreamProxy) => void;
|
||||
toggleBusyId?: string;
|
||||
}
|
||||
|
||||
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) {
|
||||
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings, onToggle, toggleBusyId }: UpstreamSectionProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="ui-card overflow-hidden">
|
||||
@@ -30,15 +32,14 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<table className="w-full min-w-[760px] text-left text-sm">
|
||||
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t("名称")}</th>
|
||||
<th className="px-4 py-3">{t("协议")}</th>
|
||||
<th className="px-4 py-3">{t("地址")}</th>
|
||||
<th className="px-4 py-3">{t("鉴权")}</th>
|
||||
<th className="px-4 py-3">{t("状态")}</th>
|
||||
<th className="px-4 py-3">{t("SIM / Profile 绑定")}</th>
|
||||
<th className="px-4 py-3">{t("国家规则")}</th>
|
||||
<th className="px-4 py-3 text-right">{t("操作")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -46,18 +47,28 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
|
||||
<td className="px-4 py-3 font-semibold">{row.name || row.id}</td>
|
||||
<td className="px-4 py-3"><Tag type="primary">SOCKS5</Tag></td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{row.addr}</td>
|
||||
<td className="px-4 py-3">{row.username || t("无")}</td>
|
||||
<td className="px-4 py-3"><Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
|
||||
<DesktopRegular className="text-[14px]" />
|
||||
<span>{row.bindingCount} {t("个 SIM / Profile")}</span>
|
||||
</div>
|
||||
{row.bindingCount}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{row.countryNames.length ? (
|
||||
<div className="flex max-w-sm flex-wrap gap-1">
|
||||
{row.countryNames.map((countryName) => <Tag key={countryName} type="primary">{countryName}</Tag>)}
|
||||
</div>
|
||||
) : <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant={row.enabled ? "warning" : "success"}
|
||||
plain
|
||||
icon={row.enabled ? <PauseRegular /> : <PlayRegular />}
|
||||
loading={toggleBusyId === row.id}
|
||||
onClick={() => onToggle(row)}
|
||||
>{row.enabled ? t("禁用") : t("启用")}</Button>
|
||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("SIM / Profile 绑定")}</Button>
|
||||
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
|
||||
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
|
||||
@@ -72,7 +83,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
|
||||
<GlobeRegular className="mb-3 text-4xl" />
|
||||
<div className="text-sm">{t("暂无上游代理")}</div>
|
||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。")}</div>
|
||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface UpstreamProbeResult {
|
||||
|
||||
export interface UpstreamRow extends UpstreamProxy {
|
||||
bindingCount: number;
|
||||
countryNames: string[];
|
||||
}
|
||||
|
||||
export function ipv6Hint(): string {
|
||||
|
||||
@@ -1005,6 +1005,13 @@ export const EN_DICT: Record<string, string> = {
|
||||
"绑定:": "Bound:",
|
||||
"鉴权:": "Auth:",
|
||||
国家规则: "Country Rules",
|
||||
"MCC 国家规则": "MCC Country Rules",
|
||||
规则: "Rule",
|
||||
代理已启用: "Proxy enabled",
|
||||
代理已禁用: "Proxy disabled",
|
||||
切换代理状态失败: "Failed to change proxy status",
|
||||
"代理已禁用;显式 ICCID 绑定将停止使用该线路且不会转为直连,尚未固化的 MCC 默认规则会回退直连":
|
||||
"Proxy disabled. Explicit ICCID bindings stop using this route without falling back to direct; MCC defaults not yet materialized fall back to direct.",
|
||||
新增代理: "Add Proxy",
|
||||
新增实例: "Add Instance",
|
||||
删除规则: "Delete Rule",
|
||||
@@ -1013,6 +1020,22 @@ export const EN_DICT: Record<string, string> = {
|
||||
"UDP 中继地址:": "UDP Relay Address: ",
|
||||
"规则按 SIM 归属 MCC 解析国家。例如 US 会覆盖 MCC 310/311/312/313/314/315/316 等表内分组;没有配置规则的国家默认直连。需要重启 VoWiFi 生效。":
|
||||
"Country is resolved from the SIM home MCC. For example, US covers the listed MCC 310/311/312/313/314/315/316 groups; countries without a rule use direct connection. Restart VoWiFi to take effect.",
|
||||
"未绑定 ICCID 的卡会按 SIM 归属 MCC 匹配国家规则;首次命中后会生成独立的 ICCID 绑定。ICCID 绑定优先,未命中任何规则时直连。":
|
||||
"A SIM without an ICCID binding uses the country rule matching its home MCC. The first match creates an independent ICCID binding. ICCID bindings take priority; otherwise unmatched SIMs connect directly.",
|
||||
"为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。":
|
||||
"Choose a proxy for each country's MCC. Unconfigured MCCs connect directly. Existing ICCID bindings always take priority, and the first country-rule match creates an independent ICCID binding.",
|
||||
"同一国家只能属于一个代理;选择已分配的国家会将它迁移到当前代理。":
|
||||
"Each country can belong to only one proxy. Selecting a country assigned elsewhere moves it to this proxy.",
|
||||
"搜索国家、地区代码或 MCC": "Search country, region code, or MCC",
|
||||
"国家 / 地区": "Country / Region",
|
||||
当前规则: "Current Rule",
|
||||
当前代理: "This Proxy",
|
||||
直连: "Direct",
|
||||
"没有匹配的国家或 MCC": "No matching country or MCC",
|
||||
"管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定":
|
||||
"Manage VoWiFi upstream proxies, MCC country rules, and physical SIM / eSIM profile bindings",
|
||||
"点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。":
|
||||
"Create a SOCKS5 upstream proxy, then configure country rules or ICCID bindings. Unmatched SIMs connect directly by default.",
|
||||
"VoWiFi 通过此 Socks5 代理连接运营商,实现跨区域本地 VoWiFi。":
|
||||
"VoWiFi connects to the carrier through this Socks5 proxy, enabling cross-region local VoWiFi. ",
|
||||
|
||||
|
||||
+98
-11
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AddRegular } from "@fluentui/react-icons";
|
||||
import { AddRegular, GlobeRegular } from "@fluentui/react-icons";
|
||||
import { api, ApiError, apiMessage } from "../api";
|
||||
import type { DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types";
|
||||
import type { Country, CountryRule, DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types";
|
||||
import { usePolling } from "../lib/usePolling";
|
||||
import { Button, PageHeader, confirmDialog, message } from "../components/ui";
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../components/proxy/shared";
|
||||
import { UpstreamDialog } from "../components/proxy/UpstreamDialog";
|
||||
import { DeviceBindingsDialog } from "../components/proxy/DeviceBindingsDialog";
|
||||
import { CountryRulesDialog } from "../components/proxy/CountryRulesDialog";
|
||||
import { UpstreamSection } from "../components/proxy/UpstreamSection";
|
||||
import { tf, useI18n } from "../lib/i18n";
|
||||
import { listPlugins, pluginAssetURL, type InstalledPlugin } from "../extensions";
|
||||
@@ -24,11 +25,13 @@ interface BindingMutationResult {
|
||||
}
|
||||
|
||||
export default function ProxyPage() {
|
||||
const { t } = useI18n();
|
||||
const { t, lang } = useI18n();
|
||||
|
||||
const [proxies, setProxies] = useState<UpstreamProxy[]>([]);
|
||||
const [devices, setDevices] = useState<DeviceListItem[]>([]);
|
||||
const [bindings, setBindings] = useState<DeviceProxyBinding[]>([]);
|
||||
const [countries, setCountries] = useState<Country[]>([]);
|
||||
const [countryRules, setCountryRules] = useState<CountryRule[]>([]);
|
||||
const [upstreamLoading, setUpstreamLoading] = useState(true);
|
||||
const [upstreamError, setUpstreamError] = useState<LoadError | null>(null);
|
||||
const [upstreamDialogOpen, setUpstreamDialogOpen] = useState(false);
|
||||
@@ -39,28 +42,46 @@ export default function ProxyPage() {
|
||||
const [bindingsDialogOpen, setBindingsDialogOpen] = useState(false);
|
||||
const [bindingsProxy, setBindingsProxy] = useState<UpstreamProxy | null>(null);
|
||||
const [bindingBusy, setBindingBusy] = useState(false);
|
||||
const [countryDialogOpen, setCountryDialogOpen] = useState(false);
|
||||
const [countryBusy, setCountryBusy] = useState(false);
|
||||
const [toggleBusyId, setToggleBusyId] = useState("");
|
||||
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
|
||||
|
||||
const proxyRows = useMemo<UpstreamRow[]>(
|
||||
() => proxies.map((proxy) => ({
|
||||
const regionNames = useMemo(() => {
|
||||
try {
|
||||
return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [lang]);
|
||||
|
||||
const proxyRows = useMemo<UpstreamRow[]>(() => proxies.map((proxy) => {
|
||||
const countryNames = countryRules
|
||||
.filter((rule) => rule.enabled && rule.upstreamProxyId === proxy.id)
|
||||
.map((rule) => regionNames?.of(rule.countryCode) || rule.countryName || rule.countryCode);
|
||||
return {
|
||||
...proxy,
|
||||
bindingCount: bindings.filter((binding) => binding.upstreamProxyId === proxy.id).length,
|
||||
})),
|
||||
[proxies, bindings],
|
||||
);
|
||||
countryNames,
|
||||
};
|
||||
}), [proxies, bindings, countryRules, regionNames]);
|
||||
|
||||
const loadUpstream = useCallback(async (initial = false) => {
|
||||
if (initial) setUpstreamLoading(true);
|
||||
setUpstreamError(null);
|
||||
try {
|
||||
const [proxyList, bindingList, deviceList] = await Promise.all([
|
||||
const [proxyList, bindingList, deviceList, countryList, ruleList] = await Promise.all([
|
||||
api<UpstreamProxy[]>("/upstream-proxies"),
|
||||
api<DeviceProxyBinding[]>("/upstream-proxy-profile-bindings"),
|
||||
api<DevicesResponse>("/devices"),
|
||||
api<Country[]>("/upstream-proxy-countries"),
|
||||
api<CountryRule[]>("/upstream-proxy-country-rules"),
|
||||
]);
|
||||
setProxies(proxyList || []);
|
||||
setBindings(bindingList || []);
|
||||
setDevices(deviceList?.devices || []);
|
||||
setCountries(countryList || []);
|
||||
setCountryRules(ruleList || []);
|
||||
} catch (error) {
|
||||
setUpstreamError({ message: apiMessage(error), status: error instanceof ApiError ? error.status : undefined });
|
||||
} finally {
|
||||
@@ -165,6 +186,8 @@ export default function ProxyPage() {
|
||||
{tf("确定删除上游代理“{name}”?", { name: proxy.name || proxy.id })}
|
||||
<br />
|
||||
{t("绑定到该代理的 Profile 将自动解绑并恢复直连。")}
|
||||
<br />
|
||||
{t("绑定到该代理的国家规则将自动删除,相关国家会恢复直连。")}
|
||||
</>,
|
||||
t("确认删除"),
|
||||
{ confirmText: t("删除"), cancelText: t("取消"), type: "warning" },
|
||||
@@ -174,6 +197,7 @@ export default function ProxyPage() {
|
||||
await api(`/upstream-proxies/${proxy.id}`, { method: "DELETE" });
|
||||
message.success(t("上游代理已删除"));
|
||||
if (bindingsProxy?.id === proxy.id) setBindingsDialogOpen(false);
|
||||
setCountryDialogOpen(false);
|
||||
await loadUpstream(false);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error) || t("删除失败"));
|
||||
@@ -185,6 +209,53 @@ export default function ProxyPage() {
|
||||
setBindingsDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const toggleUpstream = useCallback(async (proxy: UpstreamProxy) => {
|
||||
const enabled = !proxy.enabled;
|
||||
setToggleBusyId(proxy.id);
|
||||
try {
|
||||
const result = await api<BindingMutationResult>(`/upstream-proxies/${encodeURIComponent(proxy.id)}`, {
|
||||
method: "PATCH",
|
||||
body: { enabled },
|
||||
});
|
||||
if (result.reconnectError) {
|
||||
message.warning(`${enabled ? t("代理已启用") : t("代理已禁用")};${t("线路已保存,将在下次启动 VoWiFi 时应用")}`);
|
||||
} else if (enabled) {
|
||||
message.success(t("代理已启用"));
|
||||
} else {
|
||||
message.success(t("代理已禁用;显式 ICCID 绑定将停止使用该线路且不会转为直连,尚未固化的 MCC 默认规则会回退直连"));
|
||||
}
|
||||
await loadUpstream(false);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error) || t("切换代理状态失败"));
|
||||
} finally {
|
||||
setToggleBusyId("");
|
||||
}
|
||||
}, [loadUpstream, t]);
|
||||
|
||||
const saveCountryRules = useCallback(async (assignments: Record<string, string>) => {
|
||||
setCountryBusy(true);
|
||||
const current = new Map(countryRules.map((rule) => [rule.countryCode, rule.upstreamProxyId]));
|
||||
const changed = Object.entries(assignments).filter(([code, proxyID]) => proxyID && current.get(code) !== proxyID);
|
||||
const removed = countryRules.filter((rule) => !assignments[rule.countryCode]);
|
||||
try {
|
||||
await Promise.all(changed.map(([code, proxyID]) => api(`/upstream-proxy-country-rules/${encodeURIComponent(code)}`, {
|
||||
method: "PUT",
|
||||
body: { upstreamProxyId: proxyID, enabled: true },
|
||||
})));
|
||||
await Promise.all(removed.map((rule) => api(`/upstream-proxy-country-rules/${encodeURIComponent(rule.countryCode)}`, {
|
||||
method: "DELETE",
|
||||
})));
|
||||
message.success(t("国家规则已保存"));
|
||||
await loadUpstream(false);
|
||||
setCountryDialogOpen(false);
|
||||
} catch (error) {
|
||||
await loadUpstream(false);
|
||||
message.error(apiMessage(error) || t("保存规则失败"));
|
||||
} finally {
|
||||
setCountryBusy(false);
|
||||
}
|
||||
}, [countryRules, loadUpstream, t]);
|
||||
|
||||
const showRouteChangeResult = useCallback((result: BindingMutationResult, successText: string) => {
|
||||
if (result.reconnectError) {
|
||||
message.warning(`${successText};${t("线路已保存,将在下次启动 VoWiFi 时应用")}`);
|
||||
@@ -241,8 +312,13 @@ export default function ProxyPage() {
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader
|
||||
title={t("代理管理")}
|
||||
subtitle={t("管理 VoWiFi 上游代理以及实体 SIM / eSIM Profile 绑定")}
|
||||
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
|
||||
subtitle={t("管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定")}
|
||||
actions={(
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<GlobeRegular />} onClick={() => setCountryDialogOpen(true)}>{t("MCC 国家规则")}</Button>
|
||||
<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<UpstreamSection
|
||||
rows={proxyRows}
|
||||
@@ -252,6 +328,8 @@ export default function ProxyPage() {
|
||||
onEdit={openUpstreamDialog}
|
||||
onDelete={removeUpstream}
|
||||
onOpenBindings={openBindingsDialog}
|
||||
onToggle={(proxy) => void toggleUpstream(proxy)}
|
||||
toggleBusyId={toggleBusyId}
|
||||
/>
|
||||
{plugins.filter((plugin) => plugin.enabled).flatMap((plugin) =>
|
||||
plugin.contributions.filter((contribution) => contribution.location === "proxy").map((contribution) => (
|
||||
@@ -293,6 +371,15 @@ export default function ProxyPage() {
|
||||
onDelete={(iccids) => void deleteProfileBindings(iccids)}
|
||||
onClose={() => setBindingsDialogOpen(false)}
|
||||
/>
|
||||
<CountryRulesDialog
|
||||
open={countryDialogOpen}
|
||||
proxies={proxies}
|
||||
countries={countries}
|
||||
rules={countryRules}
|
||||
busy={countryBusy}
|
||||
onSave={(assignments) => void saveCountryRules(assignments)}
|
||||
onClose={() => setCountryDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user