From 0d738d4ce4dc4318c983be9989866b5ff0059430 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:58:28 +0800 Subject: [PATCH] feat: add custom phone number support to card policies - Introduced a new field `custom_phone_number` in the CardPolicy model and database schema. - Updated the API to handle custom phone number input, including validation and normalization. - Modified the CardPolicyPanel component to allow users to set and save a custom phone number. - Enhanced the settings API to include the custom phone number in responses and updates. - Added tests to ensure the correct functionality of custom phone number handling. - Removed hardcoded environment variable for VOCAT_ADDR in service files. --- cmd/vocat/cli.go | 4 +- cmd/vocat/main.go | 19 +- cmd/vocat/menu.go | 307 +++++++++++++++--- cmd/vocat/menu_test.go | 71 ++++ deploy/vocat.service | 1 - internal/server/automatic_tasks.go | 14 +- internal/server/device_api.go | 14 +- internal/server/settings_api.go | 60 +++- internal/server/settings_api_test.go | 42 ++- internal/store/domain_test.go | 4 +- internal/store/migrations.go | 5 + internal/store/models.go | 19 +- internal/store/settings.go | 12 +- internal/store/store.go | 2 +- scripts/install.sh | 1 - .../components/devices/CardPolicyPanel.tsx | 84 ++++- .../components/devices/DeviceOverviewTab.tsx | 2 + .../components/devices/OverviewSimPanel.tsx | 6 +- web/src/components/devices/deviceActions.ts | 1 + web/src/lib/i18n-en.ts | 55 ++++ web/src/pages/DevicesPage.tsx | 1 + web/src/types.ts | 1 + 22 files changed, 609 insertions(+), 116 deletions(-) create mode 100644 cmd/vocat/menu_test.go diff --git a/cmd/vocat/cli.go b/cmd/vocat/cli.go index 6c67892..a9db695 100644 --- a/cmd/vocat/cli.go +++ b/cmd/vocat/cli.go @@ -32,8 +32,8 @@ Usage: GITHUB_TOKEN Optional bearer token for private repos or higher rate limits. vocat menu Interactive lifecycle menu (root on the host): - toggle language, change password, restart, update, - uninstall. + toggle language, change password, change the Web port, + restart, update, uninstall. vocat help Show this help message. When run without a subcommand on a non-TTY (e.g. systemd), vocat starts the diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index 3a1feaa..2d7eff7 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -1070,15 +1070,18 @@ func enforceCardRegion( } } if snapshot.ICCID != "" { - policy := store.CardPolicy{ - ICCID: snapshot.ICCID, - NetworkEnabled: false, - VoWiFiEnabled: false, - AirplaneEnabled: true, - IPVersion: "IPV4V6", - Source: cardPolicySourceRegionBlock, + policy, policyErr := database.CardPolicy(ctx, snapshot.ICCID) + if errors.Is(policyErr, store.ErrNotFound) { + policy = store.CardPolicy{ICCID: snapshot.ICCID, IPVersion: "IPV4V6"} + policyErr = nil } - if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil { + policy.NetworkEnabled = false + policy.VoWiFiEnabled = false + policy.AirplaneEnabled = true + policy.Source = cardPolicySourceRegionBlock + if policyErr != nil && ctx.Err() == nil { + logger.Warn("region block: failed to read card policy", "device_id", id, "iccid", snapshot.ICCID, "error", policyErr) + } else if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil { logger.Warn( "region block: failed to persist card policy", "device_id", id, "iccid", snapshot.ICCID, "error", err, diff --git a/cmd/vocat/menu.go b/cmd/vocat/menu.go index 8daaa14..0ee70f9 100644 --- a/cmd/vocat/menu.go +++ b/cmd/vocat/menu.go @@ -7,8 +7,10 @@ import ( "errors" "fmt" "log/slog" + "net" "os" "os/exec" + "strconv" "strings" "time" @@ -25,6 +27,11 @@ import ( // rewrite it or the next restart reverts the password. const envFilePath = "/etc/vocat/env" +// legacyEnvFilePath was used by the standalone deploy/vocat.service. Keep it +// discoverable so the menu works on installations made before the installer +// and service template converged on /etc/vocat/env. +const legacyEnvFilePath = "/etc/vocat/vocat.env" + const systemdUnitPath = "/etc/systemd/system/vocat.service" // defaultDatabasePath is the install-default SQLite location written into the @@ -50,7 +57,7 @@ func loadMenuEnv() { if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok { _ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath) } - if data, err := os.ReadFile(envFilePath); err == nil { + if data, err := os.ReadFile(menuEnvFilePath()); err == nil { for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { @@ -69,10 +76,20 @@ func loadMenuEnv() { } } +func menuEnvFilePath() string { + if _, err := os.Stat(envFilePath); err == nil { + return envFilePath + } + if _, err := os.Stat(legacyEnvFilePath); err == nil { + return legacyEnvFilePath + } + return envFilePath +} + // runMenu is the interactive lifecycle menu: toggle language, change password, -// restart the systemd unit, self-update, or fully uninstall vocat. It must run -// as root on the host (needs systemctl + the 0600 env file). Docker deployments -// do not use it. +// change the Web listener port, restart the systemd unit, self-update, or fully +// uninstall vocat. It must run as root on the host (needs systemctl + the 0600 +// env file). Docker deployments do not use it. func runMenu(logger *slog.Logger) error { if os.Geteuid() != 0 { return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)") @@ -113,10 +130,14 @@ func runMenu(logger *slog.Logger) error { fmt.Println(menu.errorPrefix(err)) } case "3": - if err := menuRestart(menu); err != nil { + if err := menuChangeWebPort(reader, menu); err != nil { fmt.Println(menu.errorPrefix(err)) } case "4": + if err := menuRestart(menu); err != nil { + fmt.Println(menu.errorPrefix(err)) + } + case "5": if err := menuUpdate(menu, logger); err != nil { fmt.Println(menu.errorPrefix(err)) } @@ -242,9 +263,18 @@ func readPasswordMasked() (string, error) { // the temp file lives in the same directory so os.Rename stays on one // filesystem. func rewriteEnvPassword(newPassword string) error { - const key = "VOCAT_ADMIN_PASSWORD=" + return rewriteEnvValue(menuEnvFilePath(), "VOCAT_ADMIN_PASSWORD", newPassword) +} + +// rewriteEnvValue replaces or appends one systemd EnvironmentFile value. The +// write is atomic and rejects line breaks so one setting cannot inject another. +func rewriteEnvValue(path, name, value string) error { + if name == "" || strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") { + return errors.New("invalid environment setting") + } + key := name + "=" var lines []string - if data, err := os.ReadFile(envFilePath); err == nil { + if data, err := os.ReadFile(path); err == nil { lines = strings.Split(string(data), "\n") } else if !errors.Is(err, os.ErrNotExist) { return err @@ -253,27 +283,34 @@ func rewriteEnvPassword(newPassword string) error { replaced := false for i, line := range lines { if strings.HasPrefix(line, key) { - lines[i] = key + newPassword + lines[i] = key + value replaced = true break } } if !replaced { - lines = append(lines, key+newPassword) + lines = append(lines, key+value) } content := strings.Join(lines, "\n") if !strings.HasSuffix(content, "\n") { content += "\n" } + return writeEnvFileAtomic(path, []byte(content)) +} - dir := envFilePath[:strings.LastIndex(envFilePath, "/")] +func writeEnvFileAtomic(path string, content []byte) error { + dirIndex := strings.LastIndexAny(path, "/\\") + if dirIndex < 0 { + return errors.New("environment file path has no directory") + } + dir := path[:dirIndex] tmp, err := os.CreateTemp(dir, ".vocat-env-*") if err != nil { return err } tmpName := tmp.Name() defer os.Remove(tmpName) - if _, err := tmp.WriteString(content); err != nil { + if _, err := tmp.Write(content); err != nil { _ = tmp.Close() return err } @@ -284,7 +321,125 @@ func rewriteEnvPassword(newPassword string) error { if err := tmp.Close(); err != nil { return err } - return os.Rename(tmpName, envFilePath) + return os.Rename(tmpName, path) +} + +func menuChangeWebPort(reader *bufio.Reader, m *menu) error { + if _, err := exec.LookPath("systemctl"); err != nil { + return errNoSystemctl + } + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("%w: %v", errMenuConfig, err) + } + _, currentPortText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address)) + if err != nil { + return fmt.Errorf("%w: %v", errMenuConfig, err) + } + fmt.Println(m.currentWebAddress(cfg.Address)) + fmt.Println(m.reverseProxyNotice()) + fmt.Print(m.newWebPort(currentPortText)) + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("read Web port: %w", err) + } + portText := strings.TrimSpace(line) + if portText == "" { + fmt.Println(m.webPortCancelled()) + return nil + } + newAddress, newPort, err := webAddressWithPort(cfg.Address, portText) + if err != nil { + return errInvalidWebPort + } + currentPort, _ := strconv.Atoi(currentPortText) + if newPort == currentPort { + fmt.Println(m.webPortUnchanged()) + return nil + } + + listener, err := net.Listen("tcp", newAddress) + if err != nil { + return fmt.Errorf("%w: %v", errWebPortUnavailable, err) + } + _ = listener.Close() + + environmentPath := menuEnvFilePath() + original, readErr := os.ReadFile(environmentPath) + originalExisted := readErr == nil + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return fmt.Errorf("%w: %v", errMenuPortWrite, readErr) + } + if err := rewriteEnvValue(environmentPath, "VOCAT_ADDR", newAddress); err != nil { + return fmt.Errorf("%w: %v", errMenuPortWrite, err) + } + if err := restartVocatService(); err != nil { + rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted) + _ = restartVocatService() + if rollbackErr != nil { + return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr) + } + return fmt.Errorf("%w: %v", errRestartFailed, err) + } + if err := waitForWebListener(newAddress, 5*time.Second); err != nil { + rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted) + _ = restartVocatService() + if rollbackErr != nil { + return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr) + } + return fmt.Errorf("%w: %v", errRestartFailed, err) + } + _ = os.Setenv("VOCAT_ADDR", newAddress) + fmt.Println(m.webPortChanged(newAddress)) + return nil +} + +func webAddressWithPort(address, portText string) (string, int, error) { + host, _, err := net.SplitHostPort(strings.TrimSpace(address)) + if err != nil { + return "", 0, err + } + port, err := strconv.Atoi(strings.TrimSpace(portText)) + if err != nil || port < 1 || port > 65535 { + return "", 0, errInvalidWebPort + } + return net.JoinHostPort(host, strconv.Itoa(port)), port, nil +} + +func waitForWebListener(address string, timeout time.Duration) error { + host, port, err := net.SplitHostPort(address) + if err != nil { + return err + } + switch host { + case "", "0.0.0.0": + host = "127.0.0.1" + case "::": + host = "::1" + } + target := net.JoinHostPort(host, port) + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + connection, dialErr := net.DialTimeout("tcp", target, 500*time.Millisecond) + if dialErr == nil { + _ = connection.Close() + return nil + } + lastErr = dialErr + time.Sleep(200 * time.Millisecond) + } + return fmt.Errorf("Web listener %s did not become reachable: %w", target, lastErr) +} + +func restoreMenuEnvFile(path string, content []byte, existed bool) error { + if existed { + return writeEnvFileAtomic(path, content) + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil } // menuToggleLanguage flips the persisted language preference between "zh" and @@ -327,6 +482,14 @@ func menuToggleLanguage(m *menu, logger *slog.Logger) error { } func menuRestart(m *menu) error { + if err := restartVocatService(); err != nil { + return err + } + fmt.Println(m.restarted()) + return nil +} + +func restartVocatService() error { if _, err := exec.LookPath("systemctl"); err != nil { return errNoSystemctl } @@ -334,7 +497,9 @@ func menuRestart(m *menu) error { if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out))) } - fmt.Println(m.restarted()) + if out, err := exec.Command("systemctl", "is-active", "--quiet", "vocat").CombinedOutput(); err != nil { + return fmt.Errorf("%w: service is not active: %s", errRestartFailed, strings.TrimSpace(string(out))) + } return nil } @@ -378,6 +543,7 @@ func menuUninstall(reader *bufio.Reader, m *menu) error { _ = os.Remove(systemdUnitPath) _ = os.RemoveAll("/opt/vocat") _ = os.Remove(envFilePath) + _ = os.Remove(legacyEnvFilePath) _ = os.Remove("/etc/vocat") // succeeds only when empty runIgnore("systemctl", "daemon-reload") runIgnore("userdel", "vocat") @@ -388,15 +554,18 @@ func menuUninstall(reader *bufio.Reader, m *menu) error { // menu-local sentinel errors so callers can map them to localized messages. var ( - errCurrentWrong = errors.New("menu: current password is incorrect") - errPasswordsDiffer = errors.New("menu: passwords do not match") - errNoSystemctl = errors.New("menu: systemctl not found") - errRestartFailed = errors.New("menu: restart failed") - errUpdateFailed = errors.New("menu: update failed") - errMenuConfig = errors.New("menu: load configuration") - errMenuStore = errors.New("menu: open database") - errMenuAuth = errors.New("menu: auth service") - errMenuEnvWrite = errors.New("menu: write env file") + errCurrentWrong = errors.New("menu: current password is incorrect") + errPasswordsDiffer = errors.New("menu: passwords do not match") + errNoSystemctl = errors.New("menu: systemctl not found") + errRestartFailed = errors.New("menu: restart failed") + errUpdateFailed = errors.New("menu: update failed") + errMenuConfig = errors.New("menu: load configuration") + errMenuStore = errors.New("menu: open database") + errMenuAuth = errors.New("menu: auth service") + errMenuEnvWrite = errors.New("menu: write env file") + errMenuPortWrite = errors.New("menu: write Web port") + errInvalidWebPort = errors.New("menu: invalid Web port") + errWebPortUnavailable = errors.New("menu: Web port unavailable") ) // ---- i18n ---- @@ -409,18 +578,28 @@ func newMenu(lang string) *menu { return &menu{lang: lang} } func (m *menu) msg(key string) string { const zh, en = 0, 1 table := map[string][2]string{ - "title": {"vocat 管理菜单", "vocat management menu"}, - "opt_lang": {"1) 切换中英文", "1) Toggle language"}, - "opt_change": {"2) 修改账号密码", "2) Change admin password"}, - "opt_restart": {"3) 重启软件", "3) Restart software"}, - "opt_update": {"4) 更新软件", "4) Update software"}, - "opt_uninstall": {"0) 卸载软件", "0) Uninstall software"}, - "prompt": {"请选择: ", "Select: "}, - "invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."}, - "cur_pw": {"当前密码: ", "Current password: "}, - "new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "}, - "confirm_pw": {"确认新密码: ", "Confirm new password: "}, - "pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."}, + "title": {"vocat 管理菜单", "vocat management menu"}, + "opt_lang": {"1) 切换中英文", "1) Toggle language"}, + "opt_change": {"2) 修改账号密码", "2) Change admin password"}, + "opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"}, + "opt_restart": {"4) 重启软件", "4) Restart software"}, + "opt_update": {"5) 更新软件", "5) Update software"}, + "opt_uninstall": {"0) 卸载软件", "0) Uninstall software"}, + "prompt": {"请选择: ", "Select: "}, + "invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."}, + "cur_pw": {"当前密码: ", "Current password: "}, + "new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "}, + "confirm_pw": {"确认新密码: ", "Confirm new password: "}, + "pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."}, + "current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"}, + "new_web_port": {"新端口 (1-65535,直接回车取消,当前 %s): ", "New port (1-65535, Enter to cancel, current %s): "}, + "web_port_cancelled": {"已取消修改端口。", "Web port change cancelled."}, + "web_port_unchanged": {"端口未改变。", "Web port is unchanged."}, + "web_port_changed": {"Web 监听地址已改为 %s,软件已重启。", "Web listening address changed to %s; software restarted."}, + "reverse_proxy_notice": { + "如使用 Nginx/Caddy 等反向代理,请同步修改其上游端口。", + "If you use Nginx, Caddy, or another reverse proxy, update its upstream port too.", + }, "lang_switched": { "语言已切换。Web 界面下次刷新后同步。", "Language switched. The web UI syncs on next refresh.", @@ -431,9 +610,9 @@ func (m *menu) msg(key string) string { "警告: 将删除程序、数据与配置,且不可恢复!", "WARNING: removes the program, data and config. Irreversible!", }, - "uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "}, + "uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "}, "uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."}, - "uninstalled": {"vocat 已卸载。", "vocat uninstalled."}, + "uninstalled": {"vocat 已卸载。", "vocat uninstalled."}, } entry, ok := table[key] if !ok { @@ -445,25 +624,36 @@ func (m *menu) msg(key string) string { return entry[zh] } -func (m *menu) title() string { return m.msg("title") } -func (m *menu) prompt() string { return m.msg("prompt") } -func (m *menu) invalid() string { return m.msg("invalid") } -func (m *menu) currentPassword() string { return m.msg("cur_pw") } -func (m *menu) newPassword() string { return m.msg("new_pw") } -func (m *menu) confirmPassword() string { return m.msg("confirm_pw") } -func (m *menu) passwordChanged() string { return m.msg("pw_changed") } -func (m *menu) languageSwitched() string { return m.msg("lang_switched") } -func (m *menu) updateChecking() string { return m.msg("upd_checking") } -func (m *menu) restarted() string { return m.msg("restarted") } -func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") } -func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") } +func (m *menu) title() string { return m.msg("title") } +func (m *menu) prompt() string { return m.msg("prompt") } +func (m *menu) invalid() string { return m.msg("invalid") } +func (m *menu) currentPassword() string { return m.msg("cur_pw") } +func (m *menu) newPassword() string { return m.msg("new_pw") } +func (m *menu) confirmPassword() string { return m.msg("confirm_pw") } +func (m *menu) passwordChanged() string { return m.msg("pw_changed") } +func (m *menu) currentWebAddress(address string) string { + return fmt.Sprintf(m.msg("current_web_address"), address) +} +func (m *menu) newWebPort(port string) string { return fmt.Sprintf(m.msg("new_web_port"), port) } +func (m *menu) webPortCancelled() string { return m.msg("web_port_cancelled") } +func (m *menu) webPortUnchanged() string { return m.msg("web_port_unchanged") } +func (m *menu) webPortChanged(address string) string { + return fmt.Sprintf(m.msg("web_port_changed"), address) +} +func (m *menu) reverseProxyNotice() string { return m.msg("reverse_proxy_notice") } +func (m *menu) languageSwitched() string { return m.msg("lang_switched") } +func (m *menu) updateChecking() string { return m.msg("upd_checking") } +func (m *menu) restarted() string { return m.msg("restarted") } +func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") } +func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") } func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled") } -func (m *menu) uninstalled() string { return m.msg("uninstalled") } +func (m *menu) uninstalled() string { return m.msg("uninstalled") } func (m *menu) options() []string { return []string{ m.msg("opt_lang"), m.msg("opt_change"), + m.msg("opt_port"), m.msg("opt_restart"), m.msg("opt_update"), m.msg("opt_uninstall"), @@ -514,9 +704,24 @@ func (m *menu) errorPrefix(err error) string { return "认证服务错误。" case errors.Is(err, errMenuEnvWrite): if m.lang == "en" { - return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + envFilePath + "." + return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + menuEnvFilePath() + "." } - return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + envFilePath + "。" + return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + menuEnvFilePath() + "。" + case errors.Is(err, errInvalidWebPort): + if m.lang == "en" { + return "Invalid port. Enter a number from 1 to 65535." + } + return "端口无效,请输入 1 到 65535。" + case errors.Is(err, errWebPortUnavailable): + if m.lang == "en" { + return "The new Web port is unavailable or already in use." + } + return "新的 Web 端口不可用或已被占用。" + case errors.Is(err, errMenuPortWrite): + if m.lang == "en" { + return "Failed to save the Web listening port to " + menuEnvFilePath() + "." + } + return "无法将 Web 监听端口保存到 " + menuEnvFilePath() + "。" default: if m.lang == "en" { return "Error: " + err.Error() diff --git a/cmd/vocat/menu_test.go b/cmd/vocat/menu_test.go new file mode 100644 index 0000000..4c4d741 --- /dev/null +++ b/cmd/vocat/menu_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWebAddressWithPort(t *testing.T) { + tests := []struct { + name string + address string + port string + want string + wantErr bool + }{ + {name: "IPv4", address: "0.0.0.0:7575", port: "8080", want: "0.0.0.0:8080"}, + {name: "IPv6", address: "[::]:7575", port: "8443", want: "[::]:8443"}, + {name: "minimum", address: "127.0.0.1:7575", port: "1", want: "127.0.0.1:1"}, + {name: "maximum", address: "127.0.0.1:7575", port: "65535", want: "127.0.0.1:65535"}, + {name: "zero", address: "0.0.0.0:7575", port: "0", wantErr: true}, + {name: "too large", address: "0.0.0.0:7575", port: "65536", wantErr: true}, + {name: "not numeric", address: "0.0.0.0:7575", port: "http", wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, _, err := webAddressWithPort(test.address, test.port) + if test.wantErr { + if !errors.Is(err, errInvalidWebPort) { + t.Fatalf("error = %v, want errInvalidWebPort", err) + } + return + } + if err != nil || got != test.want { + t.Fatalf("webAddressWithPort() = %q, %v; want %q", got, err, test.want) + } + }) + } +} + +func TestRewriteEnvValuePreservesOtherSettings(t *testing.T) { + path := filepath.Join(t.TempDir(), "env") + if err := os.WriteFile(path, []byte("VOCAT_ADMIN_PASSWORD=secret\nVOCAT_ADDR=0.0.0.0:7575\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:8080"); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := string(content) + if !strings.Contains(got, "VOCAT_ADMIN_PASSWORD=secret\n") || !strings.Contains(got, "VOCAT_ADDR=0.0.0.0:8080\n") || strings.Contains(got, ":7575") { + t.Fatalf("rewritten env = %q", got) + } + if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:9000\nVOCAT_ADMIN_PASSWORD=changed"); err == nil { + t.Fatal("environment line injection was accepted") + } +} + +func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) { + for _, lang := range []string{"zh", "en"} { + options := strings.Join(newMenu(lang).options(), "\n") + if !strings.Contains(options, "3)") || !strings.Contains(strings.ToLower(options), "web") { + t.Fatalf("%s menu options do not contain Web port entry: %q", lang, options) + } + } +} diff --git a/deploy/vocat.service b/deploy/vocat.service index 8019e92..85a7244 100644 --- a/deploy/vocat.service +++ b/deploy/vocat.service @@ -13,7 +13,6 @@ Restart=on-failure RestartSec=3s TimeoutStartSec=30s TimeoutStopSec=20s -Environment=VOCAT_ADDR=0.0.0.0:7575 Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db EnvironmentFile=/etc/vocat/vocat.env diff --git a/internal/server/automatic_tasks.go b/internal/server/automatic_tasks.go index 5a51ae0..6654c0a 100644 --- a/internal/server/automatic_tasks.go +++ b/internal/server/automatic_tasks.go @@ -274,7 +274,19 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st if err := s.store.UpsertDevice(ctx, *config); err != nil { return err } - if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil { + policy, policyErr := s.store.CardPolicy(ctx, iccid) + if errors.Is(policyErr, store.ErrNotFound) { + policy = defaultCardPolicy(iccid) + policyErr = nil + } + if policyErr != nil { + return policyErr + } + policy.NetworkEnabled = false + policy.VoWiFiEnabled = true + policy.AirplaneEnabled = true + policy.Source = "automatic_task" + if err := s.store.UpsertCardPolicy(ctx, policy); err != nil { return err } if s.vowifi == nil { diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 0154ae0..1af05b8 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -263,11 +263,15 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool { if selected.Snapshot != nil { iccid := strings.TrimSpace(selected.Snapshot.ICCID) if iccid != "" { - if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{ - ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, - IPVersion: "IPV4V6", Source: "default", - }); err != nil { - s.writeStoreError(w, err) + _, policyErr := s.store.CardPolicy(r.Context(), iccid) + if errors.Is(policyErr, store.ErrNotFound) { + policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{ + ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, + IPVersion: "IPV4V6", Source: "default", + }) + } + if policyErr != nil { + s.writeStoreError(w, policyErr) return true } } diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index d28afcd..6b559e9 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -1227,17 +1227,18 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)}) case http.MethodPut: var request struct { - VoWiFiEnabled *bool `json:"vowifi_enabled"` - AirplaneEnabled *bool `json:"airplane_enabled"` - APN *string `json:"apn"` - IPVersion *string `json:"ip_version"` + VoWiFiEnabled *bool `json:"vowifi_enabled"` + AirplaneEnabled *bool `json:"airplane_enabled"` + APN *string `json:"apn"` + IPVersion *string `json:"ip_version"` + CustomPhoneNumber *string `json:"custom_phone_number"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil && - request.APN == nil && request.IPVersion == nil { + request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil { writeError( w, http.StatusBadRequest, @@ -1277,6 +1278,14 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid } policy.IPVersion = ipVersion } + if request.CustomPhoneNumber != nil { + phoneNumber, phoneErr := normalizeCustomPhoneNumber(*request.CustomPhoneNumber) + if phoneErr != nil { + writeError(w, http.StatusBadRequest, "invalid_card_policy", phoneErr.Error()) + return + } + policy.CustomPhoneNumber = phoneNumber + } if request.VoWiFiEnabled != nil { policy.VoWiFiEnabled = *request.VoWiFiEnabled } @@ -1575,15 +1584,42 @@ func validICCID(value string) bool { return true } +func normalizeCustomPhoneNumber(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + var normalized strings.Builder + digitCount := 0 + for index, character := range value { + switch { + case character >= '0' && character <= '9': + normalized.WriteRune(character) + digitCount++ + case character == '+' && index == 0: + normalized.WriteRune(character) + case character == ' ' || character == '-' || character == '(' || character == ')': + // Common visual separators are accepted but not persisted. + default: + return "", errors.New("custom phone number may contain only digits, a leading plus sign, spaces, parentheses, or hyphens") + } + } + if digitCount < 3 || digitCount > 20 { + return "", errors.New("custom phone number must contain between 3 and 20 digits") + } + return normalized.String(), nil +} + func cardPolicyResponse(policy store.CardPolicy) map[string]any { response := map[string]any{ - "iccid": policy.ICCID, - "network_enabled": false, - "vowifi_enabled": policy.VoWiFiEnabled, - "airplane_enabled": policy.AirplaneEnabled, - "apn": policy.APN, - "ip_version": policy.IPVersion, - "source": policy.Source, + "iccid": policy.ICCID, + "network_enabled": false, + "vowifi_enabled": policy.VoWiFiEnabled, + "airplane_enabled": policy.AirplaneEnabled, + "apn": policy.APN, + "ip_version": policy.IPVersion, + "custom_phone_number": policy.CustomPhoneNumber, + "source": policy.Source, } if !policy.CreatedAt.IsZero() { response["created_at"] = policy.CreatedAt diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index 0903e1e..6ddc350 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -426,10 +426,35 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) { policy := response["data"].(map[string]any) if policy["iccid"] != iccid || policy["source"] != "default" || policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true || - policy["airplane_enabled"] != true { + policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" { t.Fatalf("default policy = %#v", policy) } + recorder = test.request( + t, + http.MethodPut, + "/api/cards/"+iccid+"/policy", + `{"custom_phone_number":"+86 (138) 0013-8000"}`, + ) + if recorder.Code != http.StatusOK { + t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body) + } + response = decodeSettingsResponse(t, recorder) + policy = response["data"].(map[string]any) + if policy["custom_phone_number"] != "+8613800138000" { + t.Fatalf("normalized custom phone number = %#v", policy) + } + + recorder = test.request( + t, + http.MethodPut, + "/api/cards/"+iccid+"/policy", + `{"custom_phone_number":"+86-CALL-ME"}`, + ) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid custom phone status = %d, body = %s", recorder.Code, recorder.Body) + } + recorder = test.request( t, http.MethodPut, @@ -456,7 +481,7 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) { t.Fatalf("saved policy = %#v", policy) } stored, err := test.database.CardPolicy(context.Background(), iccid) - if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" { + if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" || stored.CustomPhoneNumber != "+8613800138000" { t.Fatalf("stored policy = %+v, %v", stored, err) } @@ -471,10 +496,21 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) { t.Fatalf("partial policy status = %d, body = %s", recorder.Code, recorder.Body) } stored, err = test.database.CardPolicy(context.Background(), iccid) - if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "ims" { + if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "ims" || stored.CustomPhoneNumber != "+8613800138000" { t.Fatalf("partially updated policy = %+v, %v", stored, err) } + // Clearing the override restores system-number display without affecting the + // rest of this ICCID's policy. + recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"custom_phone_number":""}`) + if recorder.Code != http.StatusOK { + t.Fatalf("clear custom phone status = %d, body = %s", recorder.Code, recorder.Body) + } + stored, err = test.database.CardPolicy(context.Background(), iccid) + if err != nil || stored.CustomPhoneNumber != "" || stored.APN != "ims" { + t.Fatalf("cleared custom phone policy = %+v, %v", stored, err) + } + // APN-only updates are accepted without changing either switch. recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"mobile.example","ip_version":"ip"}`) if recorder.Code != http.StatusOK { diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index 11393be..652f123 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -784,7 +784,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) { if err := database.UpsertCardPolicy(ctx, CardPolicy{ ICCID: "89860001", NetworkEnabled: true, VoWiFiEnabled: true, - APN: "ims", IPVersion: "ipv4v6", + APN: "ims", IPVersion: "ipv4v6", CustomPhoneNumber: "+8613800138000", }); err != nil { t.Fatal(err) } @@ -794,7 +794,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) { t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err) } policy, err := database.CardPolicy(ctx, "89860001") - if err != nil || !policy.VoWiFiEnabled { + if err != nil || !policy.VoWiFiEnabled || policy.CustomPhoneNumber != "+8613800138000" { t.Fatalf("CardPolicy() = %+v, %v", policy, err) } safePolicy, err := database.CardPolicy(ctx, "89860002") diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 12fcf7f..005a622 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -255,6 +255,11 @@ func migrationStatements(version int) []string { `ALTER TABLE card_apn_profiles ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'NONE' CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP'))`, } + case 15: + return []string{ + `ALTER TABLE card_policies + ADD COLUMN custom_phone_number TEXT NOT NULL DEFAULT ''`, + } default: return nil } diff --git a/internal/store/models.go b/internal/store/models.go index 4e9a772..7ef3b64 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -477,15 +477,16 @@ type LogFilter struct { } type CardPolicy struct { - ICCID string - NetworkEnabled bool - VoWiFiEnabled bool - AirplaneEnabled bool - APN string - IPVersion string - Source string - CreatedAt time.Time - UpdatedAt time.Time + ICCID string + NetworkEnabled bool + VoWiFiEnabled bool + AirplaneEnabled bool + APN string + IPVersion string + CustomPhoneNumber string + Source string + CreatedAt time.Time + UpdatedAt time.Time } type CardAPNProfile struct { diff --git a/internal/store/settings.go b/internal/store/settings.go index c569121..0c69a45 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -360,6 +360,7 @@ func maskedJSONValue(value json.RawMessage) bool { func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error { value.ICCID = strings.TrimSpace(value.ICCID) + value.CustomPhoneNumber = strings.TrimSpace(value.CustomPhoneNumber) if value.ICCID == "" { return errors.New("card policy ICCID is required") } @@ -381,20 +382,21 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error { _, err := s.db.ExecContext(ctx, ` INSERT INTO card_policies ( iccid, network_enabled, vowifi_enabled, airplane_enabled, - apn, ip_version, source, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + apn, ip_version, custom_phone_number, source, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(iccid) DO UPDATE SET network_enabled = excluded.network_enabled, vowifi_enabled = excluded.vowifi_enabled, airplane_enabled = excluded.airplane_enabled, apn = excluded.apn, ip_version = excluded.ip_version, + custom_phone_number = excluded.custom_phone_number, source = excluded.source, updated_at = excluded.updated_at `, value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled), boolInt(value.AirplaneEnabled), value.APN, value.IPVersion, - value.Source, createdAt.Unix(), updatedAt.Unix(), + value.CustomPhoneNumber, value.Source, createdAt.Unix(), updatedAt.Unix(), ) if err != nil { return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err) @@ -440,7 +442,7 @@ func (s *Store) DeleteCardPolicy(ctx context.Context, iccid string) error { const cardPolicySelect = ` SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled, - apn, ip_version, source, created_at, updated_at + apn, ip_version, custom_phone_number, source, created_at, updated_at FROM card_policies` func cardPolicy(row rowScanner) (CardPolicy, error) { @@ -449,7 +451,7 @@ func cardPolicy(row rowScanner) (CardPolicy, error) { var createdAt, updatedAt int64 err := row.Scan( &value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled, - &value.APN, &value.IPVersion, &value.Source, &createdAt, &updatedAt, + &value.APN, &value.IPVersion, &value.CustomPhoneNumber, &value.Source, &createdAt, &updatedAt, ) if errors.Is(err, sql.ErrNoRows) { return CardPolicy{}, ErrNotFound diff --git a/internal/store/store.go b/internal/store/store.go index 320689a..ec863ad 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -13,7 +13,7 @@ import ( _ "modernc.org/sqlite" ) -const schemaVersion = 14 +const schemaVersion = 15 var ErrNotFound = errors.New("store: not found") diff --git a/scripts/install.sh b/scripts/install.sh index 25d6cc4..dc49d26 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -210,7 +210,6 @@ User=root Group=root WorkingDirectory=/opt/vocat EnvironmentFile=${ENV_FILE} -Environment=VOCAT_ADDR=0.0.0.0:7575 Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db ExecStart=${BINARY_PATH} Restart=on-failure diff --git a/web/src/components/devices/CardPolicyPanel.tsx b/web/src/components/devices/CardPolicyPanel.tsx index c22008c..cd4a450 100644 --- a/web/src/components/devices/CardPolicyPanel.tsx +++ b/web/src/components/devices/CardPolicyPanel.tsx @@ -1,25 +1,30 @@ +import { useEffect, useState } from "react"; import { CardUiRegular } from "@fluentui/react-icons"; -import { Tag } from "../ui"; +import { Button, Input, Tag, message } from "../ui"; import { PolicySwitchCard } from "./PolicySwitchCard"; import { CardPolicyAPN } from "./CardPolicyAPN"; import { useCardPolicyToggles } from "./useCardPolicyToggles"; -import { enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions"; +import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions"; import type { CardPolicy } from "../../types"; import { useI18n } from "../../lib/i18n"; +import { apiMessage } from "../../api"; export interface CardPolicyPanelProps { deviceId: string; iccid?: string; policy: CardPolicy | null; deviceOnline: boolean; - onPolicyChanged: () => void; + onPolicyChanged: () => void | Promise; } export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) { const { t } = useI18n(); const operable = deviceOnline && !!iccid; - const flags = policy - ? { vowifiEnabled: policy.vowifiEnabled, airplaneEnabled: policy.airplaneEnabled } + const currentPolicy = policy?.iccid === iccid ? policy : null; + const [customPhoneNumber, setCustomPhoneNumber] = useState(currentPolicy?.customPhoneNumber || ""); + const [phoneSaving, setPhoneSaving] = useState(false); + const flags = currentPolicy + ? { vowifiEnabled: currentPolicy.vowifiEnabled, airplaneEnabled: currentPolicy.airplaneEnabled } : null; const toggles = useCardPolicyToggles(flags, { @@ -28,9 +33,30 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic onChanged: onPolicyChanged, }); - const isManual = policy?.source === "user" || policy?.source === "manual"; - const sourceLabel = policy ? (isManual ? t("手动设置") : t("自动默认")) : ""; + const isManual = currentPolicy?.source === "user" || currentPolicy?.source === "manual"; + const sourceLabel = currentPolicy ? (isManual ? t("手动设置") : t("自动默认")) : ""; const { local } = toggles; + const savedPhoneNumber = currentPolicy?.customPhoneNumber || ""; + const phoneChanged = customPhoneNumber.trim() !== savedPhoneNumber; + + useEffect(() => { + setCustomPhoneNumber(currentPolicy?.customPhoneNumber || ""); + }, [iccid, currentPolicy?.customPhoneNumber]); + + const saveCustomPhoneNumber = async () => { + if (!iccid || phoneSaving || !phoneChanged) return; + setPhoneSaving(true); + try { + const saved = await updateCardPolicy(iccid, { customPhoneNumber: customPhoneNumber.trim() }); + setCustomPhoneNumber(saved.customPhoneNumber || ""); + message.success(saved.customPhoneNumber ? t("自定义手机号已保存") : t("已恢复显示系统读取的号码")); + await onPolicyChanged(); + } catch (error) { + message.error(apiMessage(error) || t("保存自定义手机号失败")); + } finally { + setPhoneSaving(false); + } + }; return (
@@ -53,12 +79,44 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic ) : null} {iccid ? (
-
-
-
{t("当前卡 ICCID")}
-
{iccid}
+
+
+
+
{t("当前卡 ICCID")}
+
{iccid}
+
+ {sourceLabel ? {sourceLabel} : null} +
+
+
{t("自定义手机号")}
+
+ setCustomPhoneNumber(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void saveCustomPhoneNumber(); + }} + placeholder={t("请输入手机号(可留空)")} + inputMode="tel" + maxLength={32} + disabled={phoneSaving} + aria-label={t("自定义手机号")} + /> + +
+
+ {t("支持开头的 + 和 3-20 位数字;留空时显示系统从 SIM/网络读取的号码")} +
- {sourceLabel ? {sourceLabel} : null}
diff --git a/web/src/components/devices/DeviceOverviewTab.tsx b/web/src/components/devices/DeviceOverviewTab.tsx index 9ef1c0d..440ea68 100644 --- a/web/src/components/devices/DeviceOverviewTab.tsx +++ b/web/src/components/devices/DeviceOverviewTab.tsx @@ -12,6 +12,7 @@ import { isVoWiFiInUse } from "./shared"; export interface DeviceOverviewTabProps { device: DeviceDetail; simOperatorDisplay: string; + customPhoneNumber?: string; trafficSpeedRx: string; trafficSpeedTx: string; trafficMinuteRx: string; @@ -39,6 +40,7 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) { diff --git a/web/src/components/devices/OverviewSimPanel.tsx b/web/src/components/devices/OverviewSimPanel.tsx index 2b03657..996ccf1 100644 --- a/web/src/components/devices/OverviewSimPanel.tsx +++ b/web/src/components/devices/OverviewSimPanel.tsx @@ -10,11 +10,12 @@ import { CountryFlag } from "../CountryFlag"; export interface OverviewSimPanelProps { device: DeviceDetail; simOperatorDisplay: string; + customPhoneNumber?: string; e911Starting: boolean; onSetupE911: () => void; } -export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onSetupE911 }: OverviewSimPanelProps) { +export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber, e911Starting, onSetupE911 }: OverviewSimPanelProps) { const { t } = useI18n(); const [showSensitive, toggleSensitive] = useShowSensitive(); const modem = device.modem; @@ -22,6 +23,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS const activeEsim = (device.activeEsimProfileName || "").trim(); const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4; const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi); + const displayedPhoneNumber = customPhoneNumber?.trim() || device.localPhone || "--"; const backendLabel = device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto"; @@ -40,7 +42,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS - + {device?.e911SetupAvailable ? (
{t("E911地址")} diff --git a/web/src/components/devices/deviceActions.ts b/web/src/components/devices/deviceActions.ts index d804e96..8a16812 100644 --- a/web/src/components/devices/deviceActions.ts +++ b/web/src/components/devices/deviceActions.ts @@ -27,6 +27,7 @@ export interface CardPolicyUpdate { airplaneEnabled?: boolean; apn?: string; ipVersion?: "IP" | "IPV6" | "IPV4V6"; + customPhoneNumber?: string; } export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) { return api(`/cards/${iccid}/policy`, { method: "PUT", body }); diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 3d593bd..4ded28b 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -5,6 +5,52 @@ * 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。 */ export const EN_DICT: Record = { + // Cellular APN profiles. + "蜂窝 APN": "Cellular APN", + "APN 列表和启用状态跟随当前 ICCID/Profile 保存": "APN profiles and the active selection are saved per ICCID/Profile", + "新增 APN": "Add APN", + "正在读取 APN 列表...": "Loading APN profiles...", + "账号 / 认证": "Account / Authentication", + "协议 / 漫游": "Protocol / Roaming", + 来源: "Source", + "运营商自动配置": "Carrier Automatic", + 默认: "Default", + "模组已有": "Modem", + 自定义: "Custom", + 漫游: "Roaming", + "已设密码": "Password set", + 使用中: "Active", + "APN 已启用": "APN enabled", + "已使用运营商自动 APN 配置": "Carrier automatic APN configuration enabled", + "启用 APN 失败": "Failed to enable APN", + "设备离线:自定义列表仍可管理,模组已有 APN 将在上线后读取": + "Device offline: custom profiles can still be managed; modem APNs will load when the device comes online", + "新增 APN 配置": "Add APN Profile", + "修改 APN 配置": "Edit APN Profile", + "保存修改": "Save Changes", + "添加到列表": "Add to List", + 留空: "Leave blank", + "留空表示保持原密码": "Leave blank to keep the current password", + "APN 协议": "APN Protocol", + "APN 漫游协议": "APN Roaming Protocol", + "认证类型": "Authentication Type", + "清除已保存密码": "Clear Saved Password", + "关闭时,密码输入框留空会保持原密码": "When disabled, leaving the password blank keeps the current password", + "APN 只能包含字母、数字、点、下划线或连字符,且最长 100 个字符": + "APN may contain only letters, numbers, dots, underscores, or hyphens, with a maximum of 100 characters", + "MCC 必须是 3 位数字": "MCC must be exactly 3 digits", + "MNC 必须是 2 或 3 位数字": "MNC must be 2 or 3 digits", + "自定义 APN 已添加,请点击启用后使用": "Custom APN added. Click Enable to use it", + "自定义 APN 已修改": "Custom APN updated", + "添加 APN 失败": "Failed to add APN", + "修改 APN 失败": "Failed to update APN", + "确定删除这个自定义 APN 配置吗?": "Delete this custom APN profile?", + "删除 APN": "Delete APN", + "APN 已删除,并恢复运营商自动配置": "APN deleted; carrier automatic configuration restored", + "自定义 APN 已删除": "Custom APN deleted", + "删除 APN 失败": "Failed to delete APN", + 修改: "Edit", + // External extensions. "插件": "Plugins", "通过 URL 或本地插件包扩展 VoCat 功能": "Extend VoCat with a URL or a local plugin package", @@ -939,6 +985,15 @@ export const EN_DICT: Record = { "大疆 4G 模块(移远芯片)": "DJI 4G Module (Quectel)", "PCIe EC20/EC25(移远芯片)": "PCIe EC20/EC25 (Quectel)", + // ---- Per-Profile phone display override ---- + "自定义手机号": "Custom Phone Number", + "请输入手机号(可留空)": "Enter a phone number (optional)", + "自定义手机号已保存": "Custom phone number saved", + "已恢复显示系统读取的号码": "Restored the system-detected number", + "保存自定义手机号失败": "Failed to save custom phone number", + "支持开头的 + 和 3-20 位数字;留空时显示系统从 SIM/网络读取的号码": + "Supports a leading + and 3–20 digits. Leave blank to show the number read from the SIM/network.", + // ---- AT 快捷指令(按 group · item 分组翻译) ---- 基础: "Basics", 网络控制: "Network Control", diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index 8cbe0df..ed1e245 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -589,6 +589,7 @@ export default function DevicesPage() {