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.
This commit is contained in:
MengMengCode
2026-08-11 21:58:28 +08:00
parent 609a591045
commit 0d738d4ce4
22 changed files with 609 additions and 116 deletions
+2 -2
View File
@@ -32,8 +32,8 @@ Usage:
GITHUB_TOKEN Optional bearer token for private repos GITHUB_TOKEN Optional bearer token for private repos
or higher rate limits. or higher rate limits.
vocat menu Interactive lifecycle menu (root on the host): vocat menu Interactive lifecycle menu (root on the host):
toggle language, change password, restart, update, toggle language, change password, change the Web port,
uninstall. restart, update, uninstall.
vocat help Show this help message. vocat help Show this help message.
When run without a subcommand on a non-TTY (e.g. systemd), vocat starts the When run without a subcommand on a non-TTY (e.g. systemd), vocat starts the
+11 -8
View File
@@ -1070,15 +1070,18 @@ func enforceCardRegion(
} }
} }
if snapshot.ICCID != "" { if snapshot.ICCID != "" {
policy := store.CardPolicy{ policy, policyErr := database.CardPolicy(ctx, snapshot.ICCID)
ICCID: snapshot.ICCID, if errors.Is(policyErr, store.ErrNotFound) {
NetworkEnabled: false, policy = store.CardPolicy{ICCID: snapshot.ICCID, IPVersion: "IPV4V6"}
VoWiFiEnabled: false, policyErr = nil
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: cardPolicySourceRegionBlock,
} }
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( logger.Warn(
"region block: failed to persist card policy", "region block: failed to persist card policy",
"device_id", id, "iccid", snapshot.ICCID, "error", err, "device_id", id, "iccid", snapshot.ICCID, "error", err,
+256 -51
View File
@@ -7,8 +7,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"os" "os"
"os/exec" "os/exec"
"strconv"
"strings" "strings"
"time" "time"
@@ -25,6 +27,11 @@ import (
// rewrite it or the next restart reverts the password. // rewrite it or the next restart reverts the password.
const envFilePath = "/etc/vocat/env" 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" const systemdUnitPath = "/etc/systemd/system/vocat.service"
// defaultDatabasePath is the install-default SQLite location written into the // defaultDatabasePath is the install-default SQLite location written into the
@@ -50,7 +57,7 @@ func loadMenuEnv() {
if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok { if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok {
_ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath) _ = 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") { for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(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, // runMenu is the interactive lifecycle menu: toggle language, change password,
// restart the systemd unit, self-update, or fully uninstall vocat. It must run // change the Web listener port, restart the systemd unit, self-update, or fully
// as root on the host (needs systemctl + the 0600 env file). Docker deployments // uninstall vocat. It must run as root on the host (needs systemctl + the 0600
// do not use it. // env file). Docker deployments do not use it.
func runMenu(logger *slog.Logger) error { func runMenu(logger *slog.Logger) error {
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)") 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)) fmt.Println(menu.errorPrefix(err))
} }
case "3": case "3":
if err := menuRestart(menu); err != nil { if err := menuChangeWebPort(reader, menu); err != nil {
fmt.Println(menu.errorPrefix(err)) fmt.Println(menu.errorPrefix(err))
} }
case "4": case "4":
if err := menuRestart(menu); err != nil {
fmt.Println(menu.errorPrefix(err))
}
case "5":
if err := menuUpdate(menu, logger); err != nil { if err := menuUpdate(menu, logger); err != nil {
fmt.Println(menu.errorPrefix(err)) 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 // the temp file lives in the same directory so os.Rename stays on one
// filesystem. // filesystem.
func rewriteEnvPassword(newPassword string) error { 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 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") lines = strings.Split(string(data), "\n")
} else if !errors.Is(err, os.ErrNotExist) { } else if !errors.Is(err, os.ErrNotExist) {
return err return err
@@ -253,27 +283,34 @@ func rewriteEnvPassword(newPassword string) error {
replaced := false replaced := false
for i, line := range lines { for i, line := range lines {
if strings.HasPrefix(line, key) { if strings.HasPrefix(line, key) {
lines[i] = key + newPassword lines[i] = key + value
replaced = true replaced = true
break break
} }
} }
if !replaced { if !replaced {
lines = append(lines, key+newPassword) lines = append(lines, key+value)
} }
content := strings.Join(lines, "\n") content := strings.Join(lines, "\n")
if !strings.HasSuffix(content, "\n") { if !strings.HasSuffix(content, "\n") {
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-*") tmp, err := os.CreateTemp(dir, ".vocat-env-*")
if err != nil { if err != nil {
return err return err
} }
tmpName := tmp.Name() tmpName := tmp.Name()
defer os.Remove(tmpName) defer os.Remove(tmpName)
if _, err := tmp.WriteString(content); err != nil { if _, err := tmp.Write(content); err != nil {
_ = tmp.Close() _ = tmp.Close()
return err return err
} }
@@ -284,7 +321,125 @@ func rewriteEnvPassword(newPassword string) error {
if err := tmp.Close(); err != nil { if err := tmp.Close(); err != nil {
return err 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 // 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 { 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 { if _, err := exec.LookPath("systemctl"); err != nil {
return errNoSystemctl return errNoSystemctl
} }
@@ -334,7 +497,9 @@ func menuRestart(m *menu) error {
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out))) 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 return nil
} }
@@ -378,6 +543,7 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
_ = os.Remove(systemdUnitPath) _ = os.Remove(systemdUnitPath)
_ = os.RemoveAll("/opt/vocat") _ = os.RemoveAll("/opt/vocat")
_ = os.Remove(envFilePath) _ = os.Remove(envFilePath)
_ = os.Remove(legacyEnvFilePath)
_ = os.Remove("/etc/vocat") // succeeds only when empty _ = os.Remove("/etc/vocat") // succeeds only when empty
runIgnore("systemctl", "daemon-reload") runIgnore("systemctl", "daemon-reload")
runIgnore("userdel", "vocat") 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. // menu-local sentinel errors so callers can map them to localized messages.
var ( var (
errCurrentWrong = errors.New("menu: current password is incorrect") errCurrentWrong = errors.New("menu: current password is incorrect")
errPasswordsDiffer = errors.New("menu: passwords do not match") errPasswordsDiffer = errors.New("menu: passwords do not match")
errNoSystemctl = errors.New("menu: systemctl not found") errNoSystemctl = errors.New("menu: systemctl not found")
errRestartFailed = errors.New("menu: restart failed") errRestartFailed = errors.New("menu: restart failed")
errUpdateFailed = errors.New("menu: update failed") errUpdateFailed = errors.New("menu: update failed")
errMenuConfig = errors.New("menu: load configuration") errMenuConfig = errors.New("menu: load configuration")
errMenuStore = errors.New("menu: open database") errMenuStore = errors.New("menu: open database")
errMenuAuth = errors.New("menu: auth service") errMenuAuth = errors.New("menu: auth service")
errMenuEnvWrite = errors.New("menu: write env file") 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 ---- // ---- i18n ----
@@ -409,18 +578,28 @@ func newMenu(lang string) *menu { return &menu{lang: lang} }
func (m *menu) msg(key string) string { func (m *menu) msg(key string) string {
const zh, en = 0, 1 const zh, en = 0, 1
table := map[string][2]string{ table := map[string][2]string{
"title": {"vocat 管理菜单", "vocat management menu"}, "title": {"vocat 管理菜单", "vocat management menu"},
"opt_lang": {"1) 切换中英文", "1) Toggle language"}, "opt_lang": {"1) 切换中英文", "1) Toggle language"},
"opt_change": {"2) 修改账号密码", "2) Change admin password"}, "opt_change": {"2) 修改账号密码", "2) Change admin password"},
"opt_restart": {"3) 重启软件", "3) Restart software"}, "opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"},
"opt_update": {"4) 更新软件", "4) Update software"}, "opt_restart": {"4) 重启软件", "4) Restart software"},
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"}, "opt_update": {"5) 更新软件", "5) Update software"},
"prompt": {"请选择: ", "Select: "}, "opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."}, "prompt": {"请选择: ", "Select: "},
"cur_pw": {"当前密码: ", "Current password: "}, "invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "}, "cur_pw": {"当前密码: ", "Current password: "},
"confirm_pw": {"确认新密码: ", "Confirm new password: "}, "new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."}, "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": { "lang_switched": {
"语言已切换。Web 界面下次刷新后同步。", "语言已切换。Web 界面下次刷新后同步。",
"Language switched. The web UI syncs on next refresh.", "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!", "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."}, "uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."},
"uninstalled": {"vocat 已卸载。", "vocat uninstalled."}, "uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
} }
entry, ok := table[key] entry, ok := table[key]
if !ok { if !ok {
@@ -445,25 +624,36 @@ func (m *menu) msg(key string) string {
return entry[zh] return entry[zh]
} }
func (m *menu) title() string { return m.msg("title") } func (m *menu) title() string { return m.msg("title") }
func (m *menu) prompt() string { return m.msg("prompt") } func (m *menu) prompt() string { return m.msg("prompt") }
func (m *menu) invalid() string { return m.msg("invalid") } func (m *menu) invalid() string { return m.msg("invalid") }
func (m *menu) currentPassword() string { return m.msg("cur_pw") } func (m *menu) currentPassword() string { return m.msg("cur_pw") }
func (m *menu) newPassword() string { return m.msg("new_pw") } func (m *menu) newPassword() string { return m.msg("new_pw") }
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") } func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
func (m *menu) passwordChanged() string { return m.msg("pw_changed") } func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
func (m *menu) languageSwitched() string { return m.msg("lang_switched") } func (m *menu) currentWebAddress(address string) string {
func (m *menu) updateChecking() string { return m.msg("upd_checking") } return fmt.Sprintf(m.msg("current_web_address"), address)
func (m *menu) restarted() string { return m.msg("restarted") } }
func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") } func (m *menu) newWebPort(port string) string { return fmt.Sprintf(m.msg("new_web_port"), port) }
func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") } 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) 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 { func (m *menu) options() []string {
return []string{ return []string{
m.msg("opt_lang"), m.msg("opt_lang"),
m.msg("opt_change"), m.msg("opt_change"),
m.msg("opt_port"),
m.msg("opt_restart"), m.msg("opt_restart"),
m.msg("opt_update"), m.msg("opt_update"),
m.msg("opt_uninstall"), m.msg("opt_uninstall"),
@@ -514,9 +704,24 @@ func (m *menu) errorPrefix(err error) string {
return "认证服务错误。" return "认证服务错误。"
case errors.Is(err, errMenuEnvWrite): case errors.Is(err, errMenuEnvWrite):
if m.lang == "en" { 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: default:
if m.lang == "en" { if m.lang == "en" {
return "Error: " + err.Error() return "Error: " + err.Error()
+71
View File
@@ -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)
}
}
}
-1
View File
@@ -13,7 +13,6 @@ Restart=on-failure
RestartSec=3s RestartSec=3s
TimeoutStartSec=30s TimeoutStartSec=30s
TimeoutStopSec=20s TimeoutStopSec=20s
Environment=VOCAT_ADDR=0.0.0.0:7575
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
EnvironmentFile=/etc/vocat/vocat.env EnvironmentFile=/etc/vocat/vocat.env
+13 -1
View File
@@ -274,7 +274,19 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
if err := s.store.UpsertDevice(ctx, *config); err != nil { if err := s.store.UpsertDevice(ctx, *config); err != nil {
return err 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 return err
} }
if s.vowifi == nil { if s.vowifi == nil {
+9 -5
View File
@@ -263,11 +263,15 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
if selected.Snapshot != nil { if selected.Snapshot != nil {
iccid := strings.TrimSpace(selected.Snapshot.ICCID) iccid := strings.TrimSpace(selected.Snapshot.ICCID)
if iccid != "" { if iccid != "" {
if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{ _, policyErr := s.store.CardPolicy(r.Context(), iccid)
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, if errors.Is(policyErr, store.ErrNotFound) {
IPVersion: "IPV4V6", Source: "default", policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
}); err != nil { ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
s.writeStoreError(w, err) IPVersion: "IPV4V6", Source: "default",
})
}
if policyErr != nil {
s.writeStoreError(w, policyErr)
return true return true
} }
} }
+48 -12
View File
@@ -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)}) writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
case http.MethodPut: case http.MethodPut:
var request struct { var request struct {
VoWiFiEnabled *bool `json:"vowifi_enabled"` VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"` AirplaneEnabled *bool `json:"airplane_enabled"`
APN *string `json:"apn"` APN *string `json:"apn"`
IPVersion *string `json:"ip_version"` IPVersion *string `json:"ip_version"`
CustomPhoneNumber *string `json:"custom_phone_number"`
} }
if err := s.decodeJSON(w, r, &request); err != nil { if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return return
} }
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil && if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil &&
request.APN == nil && request.IPVersion == nil { request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil {
writeError( writeError(
w, w,
http.StatusBadRequest, http.StatusBadRequest,
@@ -1277,6 +1278,14 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
} }
policy.IPVersion = ipVersion 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 { if request.VoWiFiEnabled != nil {
policy.VoWiFiEnabled = *request.VoWiFiEnabled policy.VoWiFiEnabled = *request.VoWiFiEnabled
} }
@@ -1575,15 +1584,42 @@ func validICCID(value string) bool {
return true 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 { func cardPolicyResponse(policy store.CardPolicy) map[string]any {
response := map[string]any{ response := map[string]any{
"iccid": policy.ICCID, "iccid": policy.ICCID,
"network_enabled": false, "network_enabled": false,
"vowifi_enabled": policy.VoWiFiEnabled, "vowifi_enabled": policy.VoWiFiEnabled,
"airplane_enabled": policy.AirplaneEnabled, "airplane_enabled": policy.AirplaneEnabled,
"apn": policy.APN, "apn": policy.APN,
"ip_version": policy.IPVersion, "ip_version": policy.IPVersion,
"source": policy.Source, "custom_phone_number": policy.CustomPhoneNumber,
"source": policy.Source,
} }
if !policy.CreatedAt.IsZero() { if !policy.CreatedAt.IsZero() {
response["created_at"] = policy.CreatedAt response["created_at"] = policy.CreatedAt
+39 -3
View File
@@ -426,10 +426,35 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
policy := response["data"].(map[string]any) policy := response["data"].(map[string]any)
if policy["iccid"] != iccid || policy["source"] != "default" || if policy["iccid"] != iccid || policy["source"] != "default" ||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true || 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) 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( recorder = test.request(
t, t,
http.MethodPut, http.MethodPut,
@@ -456,7 +481,7 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
t.Fatalf("saved policy = %#v", policy) t.Fatalf("saved policy = %#v", policy)
} }
stored, err := test.database.CardPolicy(context.Background(), iccid) 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) 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) t.Fatalf("partial policy status = %d, body = %s", recorder.Code, recorder.Body)
} }
stored, err = test.database.CardPolicy(context.Background(), iccid) 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) 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. // 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"}`) recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"mobile.example","ip_version":"ip"}`)
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
+2 -2
View File
@@ -784,7 +784,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
if err := database.UpsertCardPolicy(ctx, CardPolicy{ if err := database.UpsertCardPolicy(ctx, CardPolicy{
ICCID: "89860001", NetworkEnabled: true, VoWiFiEnabled: true, ICCID: "89860001", NetworkEnabled: true, VoWiFiEnabled: true,
APN: "ims", IPVersion: "ipv4v6", APN: "ims", IPVersion: "ipv4v6", CustomPhoneNumber: "+8613800138000",
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -794,7 +794,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err) t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err)
} }
policy, err := database.CardPolicy(ctx, "89860001") 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) t.Fatalf("CardPolicy() = %+v, %v", policy, err)
} }
safePolicy, err := database.CardPolicy(ctx, "89860002") safePolicy, err := database.CardPolicy(ctx, "89860002")
+5
View File
@@ -255,6 +255,11 @@ func migrationStatements(version int) []string {
`ALTER TABLE card_apn_profiles ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'NONE' `ALTER TABLE card_apn_profiles ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'NONE'
CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP'))`, 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: default:
return nil return nil
} }
+10 -9
View File
@@ -477,15 +477,16 @@ type LogFilter struct {
} }
type CardPolicy struct { type CardPolicy struct {
ICCID string ICCID string
NetworkEnabled bool NetworkEnabled bool
VoWiFiEnabled bool VoWiFiEnabled bool
AirplaneEnabled bool AirplaneEnabled bool
APN string APN string
IPVersion string IPVersion string
Source string CustomPhoneNumber string
CreatedAt time.Time Source string
UpdatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time
} }
type CardAPNProfile struct { type CardAPNProfile struct {
+7 -5
View File
@@ -360,6 +360,7 @@ func maskedJSONValue(value json.RawMessage) bool {
func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error { func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
value.ICCID = strings.TrimSpace(value.ICCID) value.ICCID = strings.TrimSpace(value.ICCID)
value.CustomPhoneNumber = strings.TrimSpace(value.CustomPhoneNumber)
if value.ICCID == "" { if value.ICCID == "" {
return errors.New("card policy ICCID is required") 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, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO card_policies ( INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled, 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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid) DO UPDATE SET ON CONFLICT(iccid) DO UPDATE SET
network_enabled = excluded.network_enabled, network_enabled = excluded.network_enabled,
vowifi_enabled = excluded.vowifi_enabled, vowifi_enabled = excluded.vowifi_enabled,
airplane_enabled = excluded.airplane_enabled, airplane_enabled = excluded.airplane_enabled,
apn = excluded.apn, apn = excluded.apn,
ip_version = excluded.ip_version, ip_version = excluded.ip_version,
custom_phone_number = excluded.custom_phone_number,
source = excluded.source, source = excluded.source,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`, `,
value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled), value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled),
boolInt(value.AirplaneEnabled), value.APN, value.IPVersion, boolInt(value.AirplaneEnabled), value.APN, value.IPVersion,
value.Source, createdAt.Unix(), updatedAt.Unix(), value.CustomPhoneNumber, value.Source, createdAt.Unix(), updatedAt.Unix(),
) )
if err != nil { if err != nil {
return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err) 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 = ` const cardPolicySelect = `
SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled, 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` FROM card_policies`
func cardPolicy(row rowScanner) (CardPolicy, error) { func cardPolicy(row rowScanner) (CardPolicy, error) {
@@ -449,7 +451,7 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
var createdAt, updatedAt int64 var createdAt, updatedAt int64
err := row.Scan( err := row.Scan(
&value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled, &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) { if errors.Is(err, sql.ErrNoRows) {
return CardPolicy{}, ErrNotFound return CardPolicy{}, ErrNotFound
+1 -1
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
const schemaVersion = 14 const schemaVersion = 15
var ErrNotFound = errors.New("store: not found") var ErrNotFound = errors.New("store: not found")
-1
View File
@@ -210,7 +210,6 @@ User=root
Group=root Group=root
WorkingDirectory=/opt/vocat WorkingDirectory=/opt/vocat
EnvironmentFile=${ENV_FILE} EnvironmentFile=${ENV_FILE}
Environment=VOCAT_ADDR=0.0.0.0:7575
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
ExecStart=${BINARY_PATH} ExecStart=${BINARY_PATH}
Restart=on-failure Restart=on-failure
+71 -13
View File
@@ -1,25 +1,30 @@
import { useEffect, useState } from "react";
import { CardUiRegular } from "@fluentui/react-icons"; import { CardUiRegular } from "@fluentui/react-icons";
import { Tag } from "../ui"; import { Button, Input, Tag, message } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard"; import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN"; import { CardPolicyAPN } from "./CardPolicyAPN";
import { useCardPolicyToggles } from "./useCardPolicyToggles"; import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions"; import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions";
import type { CardPolicy } from "../../types"; import type { CardPolicy } from "../../types";
import { useI18n } from "../../lib/i18n"; import { useI18n } from "../../lib/i18n";
import { apiMessage } from "../../api";
export interface CardPolicyPanelProps { export interface CardPolicyPanelProps {
deviceId: string; deviceId: string;
iccid?: string; iccid?: string;
policy: CardPolicy | null; policy: CardPolicy | null;
deviceOnline: boolean; deviceOnline: boolean;
onPolicyChanged: () => void; onPolicyChanged: () => void | Promise<void>;
} }
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) { export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) {
const { t } = useI18n(); const { t } = useI18n();
const operable = deviceOnline && !!iccid; const operable = deviceOnline && !!iccid;
const flags = policy const currentPolicy = policy?.iccid === iccid ? policy : null;
? { vowifiEnabled: policy.vowifiEnabled, airplaneEnabled: policy.airplaneEnabled } const [customPhoneNumber, setCustomPhoneNumber] = useState(currentPolicy?.customPhoneNumber || "");
const [phoneSaving, setPhoneSaving] = useState(false);
const flags = currentPolicy
? { vowifiEnabled: currentPolicy.vowifiEnabled, airplaneEnabled: currentPolicy.airplaneEnabled }
: null; : null;
const toggles = useCardPolicyToggles(flags, { const toggles = useCardPolicyToggles(flags, {
@@ -28,9 +33,30 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
onChanged: onPolicyChanged, onChanged: onPolicyChanged,
}); });
const isManual = policy?.source === "user" || policy?.source === "manual"; const isManual = currentPolicy?.source === "user" || currentPolicy?.source === "manual";
const sourceLabel = policy ? (isManual ? t("手动设置") : t("自动默认")) : ""; const sourceLabel = currentPolicy ? (isManual ? t("手动设置") : t("自动默认")) : "";
const { local } = toggles; 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 ( return (
<div> <div>
@@ -53,12 +79,44 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
) : null} ) : null}
{iccid ? ( {iccid ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="ui-panel-muted flex items-center justify-between p-3"> <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div> <div className="ui-panel-muted flex min-w-0 items-center justify-between gap-3 p-3">
<div className="mb-0.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("当前卡 ICCID")}</div> <div className="min-w-0">
<div className="font-mono text-sm text-gray-800 dark:text-gray-100">{iccid}</div> <div className="mb-0.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("当前卡 ICCID")}</div>
<div className="truncate font-mono text-sm text-gray-800 dark:text-gray-100" title={iccid}>{iccid}</div>
</div>
{sourceLabel ? <Tag type={isManual ? "primary" : "info"}>{sourceLabel}</Tag> : null}
</div>
<div className="ui-panel-muted p-3">
<div className="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("自定义手机号")}</div>
<div className="flex items-center gap-2">
<Input
value={customPhoneNumber}
onChange={(event) => setCustomPhoneNumber(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void saveCustomPhoneNumber();
}}
placeholder={t("请输入手机号(可留空)")}
inputMode="tel"
maxLength={32}
disabled={phoneSaving}
aria-label={t("自定义手机号")}
/>
<Button
variant="primary"
size="small"
className="shrink-0 !border-0"
loading={phoneSaving}
disabled={!phoneChanged}
onClick={() => void saveCustomPhoneNumber()}
>
{t("保存")}
</Button>
</div>
<div className="mt-1.5 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{t("支持开头的 + 和 3-20 位数字;留空时显示系统从 SIM/网络读取的号码")}
</div>
</div> </div>
{sourceLabel ? <Tag type={isManual ? "primary" : "info"}>{sourceLabel}</Tag> : null}
</div> </div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard <PolicySwitchCard
@@ -85,7 +143,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
<CardPolicyAPN <CardPolicyAPN
deviceId={deviceId} deviceId={deviceId}
iccid={iccid} iccid={iccid}
policy={policy} policy={currentPolicy}
deviceOnline={deviceOnline} deviceOnline={deviceOnline}
onSaved={onPolicyChanged} onSaved={onPolicyChanged}
/> />
@@ -12,6 +12,7 @@ import { isVoWiFiInUse } from "./shared";
export interface DeviceOverviewTabProps { export interface DeviceOverviewTabProps {
device: DeviceDetail; device: DeviceDetail;
simOperatorDisplay: string; simOperatorDisplay: string;
customPhoneNumber?: string;
trafficSpeedRx: string; trafficSpeedRx: string;
trafficSpeedTx: string; trafficSpeedTx: string;
trafficMinuteRx: string; trafficMinuteRx: string;
@@ -39,6 +40,7 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
<OverviewSimPanel <OverviewSimPanel
device={device} device={device}
simOperatorDisplay={props.simOperatorDisplay} simOperatorDisplay={props.simOperatorDisplay}
customPhoneNumber={props.customPhoneNumber}
e911Starting={props.e911Starting} e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911} onSetupE911={props.onSetupE911}
/> />
@@ -10,11 +10,12 @@ import { CountryFlag } from "../CountryFlag";
export interface OverviewSimPanelProps { export interface OverviewSimPanelProps {
device: DeviceDetail; device: DeviceDetail;
simOperatorDisplay: string; simOperatorDisplay: string;
customPhoneNumber?: string;
e911Starting: boolean; e911Starting: boolean;
onSetupE911: () => void; onSetupE911: () => void;
} }
export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onSetupE911 }: OverviewSimPanelProps) { export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber, e911Starting, onSetupE911 }: OverviewSimPanelProps) {
const { t } = useI18n(); const { t } = useI18n();
const [showSensitive, toggleSensitive] = useShowSensitive(); const [showSensitive, toggleSensitive] = useShowSensitive();
const modem = device.modem; const modem = device.modem;
@@ -22,6 +23,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
const activeEsim = (device.activeEsimProfileName || "").trim(); const activeEsim = (device.activeEsimProfileName || "").trim();
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4; const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi); const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi);
const displayedPhoneNumber = customPhoneNumber?.trim() || device.localPhone || "--";
const backendLabel = const backendLabel =
device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto"; device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto";
@@ -40,7 +42,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
<FieldRow label="IMEI" value={modem?.imei} sensitive={sensitive} monospace copyable /> <FieldRow label="IMEI" value={modem?.imei} sensitive={sensitive} monospace copyable />
<FieldRow label="ICCID" value={modem?.iccid} sensitive={sensitive} monospace copyable /> <FieldRow label="ICCID" value={modem?.iccid} sensitive={sensitive} monospace copyable />
<FieldRow label="IMSI" value={modem?.imsi} sensitive={sensitive} monospace copyable /> <FieldRow label="IMSI" value={modem?.imsi} sensitive={sensitive} monospace copyable />
<FieldRow label={t("本机号码")} value={device.localPhone || "--"} sensitive={sensitive} monospace copyable /> <FieldRow label={t("本机号码")} value={displayedPhoneNumber} sensitive={sensitive} monospace copyable />
{device?.e911SetupAvailable ? ( {device?.e911SetupAvailable ? (
<div className="flex justify-between gap-3"> <div className="flex justify-between gap-3">
<span className="text-gray-500">{t("E911地址")}</span> <span className="text-gray-500">{t("E911地址")}</span>
@@ -27,6 +27,7 @@ export interface CardPolicyUpdate {
airplaneEnabled?: boolean; airplaneEnabled?: boolean;
apn?: string; apn?: string;
ipVersion?: "IP" | "IPV6" | "IPV4V6"; ipVersion?: "IP" | "IPV6" | "IPV4V6";
customPhoneNumber?: string;
} }
export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) { export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) {
return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body }); return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body });
+55
View File
@@ -5,6 +5,52 @@
* / * /
*/ */
export const EN_DICT: Record<string, string> = { export const EN_DICT: Record<string, string> = {
// 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. // External extensions.
"插件": "Plugins", "插件": "Plugins",
"通过 URL 或本地插件包扩展 VoCat 功能": "Extend VoCat with a URL or a local plugin package", "通过 URL 或本地插件包扩展 VoCat 功能": "Extend VoCat with a URL or a local plugin package",
@@ -939,6 +985,15 @@ export const EN_DICT: Record<string, string> = {
"大疆 4G 模块(移远芯片)": "DJI 4G Module (Quectel)", "大疆 4G 模块(移远芯片)": "DJI 4G Module (Quectel)",
"PCIe EC20/EC25(移远芯片)": "PCIe EC20/EC25 (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 320 digits. Leave blank to show the number read from the SIM/network.",
// ---- AT 快捷指令(按 group · item 分组翻译) ---- // ---- AT 快捷指令(按 group · item 分组翻译) ----
: "Basics", : "Basics",
: "Network Control", : "Network Control",
+1
View File
@@ -589,6 +589,7 @@ export default function DevicesPage() {
<DeviceOverviewTab <DeviceOverviewTab
device={detail} device={detail}
simOperatorDisplay={simOperator} simOperatorDisplay={simOperator}
customPhoneNumber={cardPolicy?.iccid === detail.modem?.iccid ? cardPolicy.customPhoneNumber : ""}
trafficSpeedRx={''} trafficSpeedRx={''}
trafficSpeedTx={''} trafficSpeedTx={''}
trafficMinuteRx={''} trafficMinuteRx={''}
+1
View File
@@ -230,6 +230,7 @@ export interface CardPolicy {
airplaneEnabled: boolean; airplaneEnabled: boolean;
apn?: string; apn?: string;
ipVersion?: string; ipVersion?: string;
customPhoneNumber?: string;
source?: string; source?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;