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 30afe090d3
commit e4434bbfbf
22 changed files with 609 additions and 116 deletions
+71 -13
View File
@@ -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<void>;
}
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 (
<div>
@@ -53,12 +79,44 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
) : null}
{iccid ? (
<div className="space-y-3">
<div className="ui-panel-muted flex items-center justify-between p-3">
<div>
<div className="mb-0.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("当前卡 ICCID")}</div>
<div className="font-mono text-sm text-gray-800 dark:text-gray-100">{iccid}</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div className="ui-panel-muted flex min-w-0 items-center justify-between gap-3 p-3">
<div className="min-w-0">
<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>
{sourceLabel ? <Tag type={isManual ? "primary" : "info"}>{sourceLabel}</Tag> : null}
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard
@@ -85,7 +143,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
<CardPolicyAPN
deviceId={deviceId}
iccid={iccid}
policy={policy}
policy={currentPolicy}
deviceOnline={deviceOnline}
onSaved={onPolicyChanged}
/>
@@ -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) {
<OverviewSimPanel
device={device}
simOperatorDisplay={props.simOperatorDisplay}
customPhoneNumber={props.customPhoneNumber}
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
@@ -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
<FieldRow label="IMEI" value={modem?.imei} 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={t("本机号码")} value={device.localPhone || "--"} sensitive={sensitive} monospace copyable />
<FieldRow label={t("本机号码")} value={displayedPhoneNumber} sensitive={sensitive} monospace copyable />
{device?.e911SetupAvailable ? (
<div className="flex justify-between gap-3">
<span className="text-gray-500">{t("E911地址")}</span>
@@ -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<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body });