mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-22 15:53:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee43ce8d1 | ||
|
|
353e93673d | ||
|
|
cb8152ccd5 | ||
|
|
4a39ed3d31 |
@@ -35,6 +35,10 @@ func (mapper nativeQMIControllerMapper) ReadSIMMetadata(ctx context.Context, id
|
|||||||
return mapper.Mapper.ReadSIMMetadata(ctx, id)
|
return mapper.Mapper.ReadSIMMetadata(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mapper nativeQMIControllerMapper) ReadSMSCenter(ctx context.Context, id string) (string, error) {
|
||||||
|
return mapper.Mapper.ReadSMSCenter(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) {
|
func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) {
|
||||||
physical, err := mapper.physical(id)
|
physical, err := mapper.physical(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -130,10 +130,22 @@ func openNativeQMIChannelWithRecovery(
|
|||||||
if powerErr := session.PowerOffSIM(ctx, slot); powerErr != nil {
|
if powerErr := session.PowerOffSIM(ctx, slot); powerErr != nil {
|
||||||
return 0, errors.Join(err, fmt.Errorf("power off QMI UIM slot %d: %w", slot, powerErr))
|
return 0, errors.Join(err, fmt.Errorf("power off QMI UIM slot %d: %w", slot, powerErr))
|
||||||
}
|
}
|
||||||
|
restorePower := func() error {
|
||||||
|
cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return session.PowerOnSIM(cleanupContext, slot)
|
||||||
|
}
|
||||||
if waitErr := waitNativeQMIRecovery(ctx, 3*time.Second); waitErr != nil {
|
if waitErr := waitNativeQMIRecovery(ctx, 3*time.Second); waitErr != nil {
|
||||||
|
// Power-off succeeded, so cancellation of the caller must not strand the
|
||||||
|
// physical SIM in power-down. Restore power with a bounded cleanup context
|
||||||
|
// that survives an HTTP/UI request being canceled.
|
||||||
|
powerErr := restorePower()
|
||||||
|
if powerErr != nil {
|
||||||
|
return 0, errors.Join(err, waitErr, fmt.Errorf("restore power to QMI UIM slot %d: %w", slot, powerErr))
|
||||||
|
}
|
||||||
return 0, errors.Join(err, waitErr)
|
return 0, errors.Join(err, waitErr)
|
||||||
}
|
}
|
||||||
if powerErr := session.PowerOnSIM(ctx, slot); powerErr != nil {
|
if powerErr := restorePower(); powerErr != nil {
|
||||||
return 0, errors.Join(err, fmt.Errorf("power on QMI UIM slot %d: %w", slot, powerErr))
|
return 0, errors.Join(err, fmt.Errorf("power on QMI UIM slot %d: %w", slot, powerErr))
|
||||||
}
|
}
|
||||||
if waitErr := waitNativeQMIRecovery(ctx, 5*time.Second); waitErr != nil {
|
if waitErr := waitNativeQMIRecovery(ctx, 5*time.Second); waitErr != nil {
|
||||||
|
|||||||
@@ -242,10 +242,6 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.writeStoreError(w, err)
|
s.writeStoreError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if store.NormalizeDeviceType(config.DeviceType) == store.DeviceTypeWiFi410 {
|
|
||||||
writeError(w, http.StatusNotImplemented, "device_feature_unsupported", "SMS is not supported by the native OpenStick 410 backend")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entry, physicalID, present := s.physicalForConfig(config)
|
entry, physicalID, present := s.physicalForConfig(config)
|
||||||
if !s.requirePhysicalDevice(w, present) {
|
if !s.requirePhysicalDevice(w, present) {
|
||||||
return
|
return
|
||||||
@@ -291,6 +287,13 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Native OpenStick 410 supports SMS only through an established VoWiFi IMS
|
||||||
|
// session. Keep cellular AT+CMGS disabled until that modem path is separately
|
||||||
|
// implemented and validated; never silently fall back from IMS to cellular.
|
||||||
|
if store.NormalizeDeviceType(config.DeviceType) == store.DeviceTypeWiFi410 {
|
||||||
|
writeError(w, http.StatusConflict, "ims_sms_not_ready", "OpenStick 410 requires a ready VoWiFi IMS SMS session")
|
||||||
|
return
|
||||||
|
}
|
||||||
result, sendErr := s.devices.SendSMS(
|
result, sendErr := s.devices.SendSMS(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
physicalID,
|
physicalID,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package integration
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
@@ -74,6 +75,43 @@ func (mapper ATMapper) ExecuteSensitiveAT(
|
|||||||
return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command)
|
return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadSMSCenter reads the SIM-provisioned service-centre address through the
|
||||||
|
// modem's read-only AT interface. Native QMI devices still expose a companion
|
||||||
|
// AT port, and SMS-over-IMS needs this value to address RP-DATA submissions.
|
||||||
|
func (mapper ATMapper) ReadSMSCenter(ctx context.Context, configuredID string) (string, error) {
|
||||||
|
response, err := mapper.ExecuteAT(ctx, configuredID, "AT+CSCA?")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read SMS service centre: %w", err)
|
||||||
|
}
|
||||||
|
for _, line := range response.Lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, "+CSCA:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(strings.TrimPrefix(line, "+CSCA:"))
|
||||||
|
if comma := strings.IndexByte(value, ','); comma >= 0 {
|
||||||
|
value = value[:comma]
|
||||||
|
}
|
||||||
|
value = strings.Trim(strings.TrimSpace(value), `"`)
|
||||||
|
digits := strings.TrimPrefix(value, "+")
|
||||||
|
if len(digits) < 3 || len(digits) > 20 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
valid := true
|
||||||
|
for _, digit := range digits {
|
||||||
|
if digit < '0' || digit > '9' {
|
||||||
|
valid = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if valid {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return "", errors.New("modem returned no valid SMS service-centre address")
|
||||||
|
}
|
||||||
|
|
||||||
// ReadSIMMetadata reuses the device manager's per-ICCID EF cache. VoWiFi
|
// ReadSIMMetadata reuses the device manager's per-ICCID EF cache. VoWiFi
|
||||||
// identity discovery therefore gains Android-style SPN/GID MVNO selectors
|
// identity discovery therefore gains Android-style SPN/GID MVNO selectors
|
||||||
// without issuing duplicate APDUs on every reconnect.
|
// without issuing duplicate APDUs on every reconnect.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type nativeQMIBinding struct {
|
|||||||
|
|
||||||
var _ SIMIdentityReader = (*NativeQMIAdapter)(nil)
|
var _ SIMIdentityReader = (*NativeQMIAdapter)(nil)
|
||||||
var _ PreferredAKAProvider = (*NativeQMIAdapter)(nil)
|
var _ PreferredAKAProvider = (*NativeQMIAdapter)(nil)
|
||||||
|
var _ SMSCenterReader = (*NativeQMIAdapter)(nil)
|
||||||
var _ RadioController = (*NativeQMIAdapter)(nil)
|
var _ RadioController = (*NativeQMIAdapter)(nil)
|
||||||
|
|
||||||
func NewNativeQMIAdapter(controller NativeQMIController, purePolicy func(string) bool) (*NativeQMIAdapter, error) {
|
func NewNativeQMIAdapter(controller NativeQMIController, purePolicy func(string) bool) (*NativeQMIAdapter, error) {
|
||||||
@@ -73,6 +74,14 @@ func (adapter *NativeQMIAdapter) ReadIdentity(ctx context.Context, deviceID stri
|
|||||||
return identity, nil
|
return identity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (adapter *NativeQMIAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
|
||||||
|
reader, ok := adapter.controller.(SMSCenterReader)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("vocat: native QMI controller does not expose an SMS service-centre reader")
|
||||||
|
}
|
||||||
|
return reader.ReadSMSCenter(ctx, deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
func (adapter *NativeQMIAdapter) binding(identity SIMIdentity) (nativeQMIBinding, error) {
|
func (adapter *NativeQMIAdapter) binding(identity SIMIdentity) (nativeQMIBinding, error) {
|
||||||
adapter.mu.Lock()
|
adapter.mu.Lock()
|
||||||
binding, ok := adapter.bindings[strings.TrimSpace(identity.ICCID)]
|
binding, ok := adapter.bindings[strings.TrimSpace(identity.ICCID)]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface DeviceOverviewTabProps {
|
|||||||
trafficMinuteTx: string;
|
trafficMinuteTx: string;
|
||||||
e911Starting: boolean;
|
e911Starting: boolean;
|
||||||
onSetupE911: () => void;
|
onSetupE911: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
||||||
@@ -45,6 +45,7 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
|||||||
customPhoneNumber={props.customPhoneNumber}
|
customPhoneNumber={props.customPhoneNumber}
|
||||||
e911Starting={props.e911Starting}
|
e911Starting={props.e911Starting}
|
||||||
onSetupE911={props.onSetupE911}
|
onSetupE911={props.onSetupE911}
|
||||||
|
onRefreshOverview={props.onRefresh}
|
||||||
/>
|
/>
|
||||||
{showNetworkDetails ? <OverviewNetworkPanel
|
{showNetworkDetails ? <OverviewNetworkPanel
|
||||||
device={device}
|
device={device}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { DeviceDetail } from "./types";
|
|||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
import { carrierIso } from "../../lib/carrier";
|
import { carrierIso } from "../../lib/carrier";
|
||||||
import { CountryFlag } from "../CountryFlag";
|
import { CountryFlag } from "../CountryFlag";
|
||||||
|
import { SMSChannelStatusRow } from "./SMSChannelStatusRow";
|
||||||
|
|
||||||
export interface OverviewSimPanelProps {
|
export interface OverviewSimPanelProps {
|
||||||
device: DeviceDetail;
|
device: DeviceDetail;
|
||||||
@@ -13,9 +14,10 @@ export interface OverviewSimPanelProps {
|
|||||||
customPhoneNumber?: string;
|
customPhoneNumber?: string;
|
||||||
e911Starting: boolean;
|
e911Starting: boolean;
|
||||||
onSetupE911: () => void;
|
onSetupE911: () => void;
|
||||||
|
onRefreshOverview: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber, e911Starting, onSetupE911 }: OverviewSimPanelProps) {
|
export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber, e911Starting, onSetupE911, onRefreshOverview }: OverviewSimPanelProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [showSensitive, toggleSensitive] = useShowSensitive();
|
const [showSensitive, toggleSensitive] = useShowSensitive();
|
||||||
const modem = device.modem;
|
const modem = device.modem;
|
||||||
@@ -43,6 +45,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber
|
|||||||
<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={displayedPhoneNumber} sensitive={sensitive} monospace copyable />
|
<FieldRow label={t("本机号码")} value={displayedPhoneNumber} sensitive={sensitive} monospace copyable />
|
||||||
|
<SMSChannelStatusRow device={device} onRefreshOverview={onRefreshOverview} />
|
||||||
{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>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowSyncRegular } from "@fluentui/react-icons";
|
||||||
|
import { apiMessage } from "../../api";
|
||||||
|
import { Tag, type TagType } from "../ui/Tag";
|
||||||
|
import { message } from "../ui";
|
||||||
|
import { cx } from "../../lib/utils";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
import { getCellularIMS, type CellularIMSStatus } from "./deviceActions";
|
||||||
|
import { isDeviceOnline, isVoWiFiInUse } from "./shared";
|
||||||
|
import type { DeviceDetail } from "./types";
|
||||||
|
|
||||||
|
interface SMSChannelStatusRowProps {
|
||||||
|
device: DeviceDetail;
|
||||||
|
onRefreshOverview: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DisplayStatus {
|
||||||
|
label: string;
|
||||||
|
tone: TagType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SMSChannelStatusRow({ device, onRefreshOverview }: SMSChannelStatusRowProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cellular, setCellular] = useState<CellularIMSStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const requestSequence = useRef(0);
|
||||||
|
|
||||||
|
const iccid = (device.modem?.iccid || "").trim();
|
||||||
|
const usesVoWiFi = isVoWiFiInUse(device) && !(device.modem?.imei && device.modem?.simInserted === false);
|
||||||
|
const canProbeCellular = !usesVoWiFi && isDeviceOnline(device) && !!iccid && device.modem?.simInserted !== false;
|
||||||
|
|
||||||
|
const probeCellular = useCallback(async () => {
|
||||||
|
if (!canProbeCellular) return;
|
||||||
|
const sequence = ++requestSequence.current;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const next = await getCellularIMS(device.id);
|
||||||
|
if (sequence === requestSequence.current) setCellular(next);
|
||||||
|
} catch {
|
||||||
|
if (sequence === requestSequence.current) setCellular(null);
|
||||||
|
} finally {
|
||||||
|
if (sequence === requestSequence.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [canProbeCellular, device.id]);
|
||||||
|
|
||||||
|
// The overview tab is conditionally mounted, so this runs once when users
|
||||||
|
// return to it and again when the active device/SIM/profile changes.
|
||||||
|
useEffect(() => {
|
||||||
|
requestSequence.current += 1;
|
||||||
|
setCellular(null);
|
||||||
|
setLoading(false);
|
||||||
|
if (canProbeCellular) void probeCellular();
|
||||||
|
return () => {
|
||||||
|
requestSequence.current += 1;
|
||||||
|
};
|
||||||
|
}, [canProbeCellular, device.id, iccid, device.activeEsimProfileName, usesVoWiFi, probeCellular]);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
if (usesVoWiFi) {
|
||||||
|
const sequence = ++requestSequence.current;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onRefreshOverview();
|
||||||
|
} catch (error) {
|
||||||
|
if (sequence === requestSequence.current) {
|
||||||
|
message.error(apiMessage(error) || t("刷新短信通道状态失败"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (sequence === requestSequence.current) setLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await probeCellular();
|
||||||
|
};
|
||||||
|
|
||||||
|
let display: DisplayStatus;
|
||||||
|
if (usesVoWiFi) {
|
||||||
|
if (device.vowifiRuntime?.smsReady) display = { label: t("已就绪(VoWiFi)"), tone: "success" };
|
||||||
|
else if (device.vowifiRuntime?.imsReady) display = { label: t("已注册(IMS)"), tone: "success" };
|
||||||
|
else if (device.vowifiRuntime?.enabled) display = { label: t("未注册短信域"), tone: "warning" };
|
||||||
|
else display = { label: t("状态未知"), tone: "info" };
|
||||||
|
} else if (!canProbeCellular || !cellular) {
|
||||||
|
display = { label: t("状态未知"), tone: "info" };
|
||||||
|
} else if (cellular.registered && cellular.csRegistered) {
|
||||||
|
display = { label: t("已注册(CS, IMS)"), tone: "success" };
|
||||||
|
} else if (cellular.registered) {
|
||||||
|
display = { label: t("已注册(IMS)"), tone: "success" };
|
||||||
|
} else if (cellular.csRegistered) {
|
||||||
|
display = { label: t("已注册(CS)"), tone: "success" };
|
||||||
|
} else if (cellular.csKnown && cellular.supported) {
|
||||||
|
display = { label: t("未注册短信域"), tone: "warning" };
|
||||||
|
} else {
|
||||||
|
display = { label: t("状态未知"), tone: "info" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full min-w-0 items-center justify-between gap-3">
|
||||||
|
<span className="shrink-0 whitespace-nowrap text-gray-500">{t("短信通道")}</span>
|
||||||
|
<div className="flex min-w-0 items-center justify-end gap-1.5">
|
||||||
|
<Tag type={display.tone}>{display.label}</Tag>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refresh()}
|
||||||
|
disabled={loading}
|
||||||
|
title={t("刷新短信通道状态")}
|
||||||
|
aria-label={t("刷新短信通道状态")}
|
||||||
|
className="rounded p-1 text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-white/10 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<ArrowSyncRegular className={cx("h-4 w-4", loading && "animate-spin")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1208,6 +1208,15 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
// Additional missing system strings
|
// Additional missing system strings
|
||||||
"端口": "Port",
|
"端口": "Port",
|
||||||
"错误详情": "Error Details",
|
"错误详情": "Error Details",
|
||||||
|
"短信通道": "SMS channel",
|
||||||
|
"刷新短信通道状态": "Refresh SMS channel status",
|
||||||
|
"刷新短信通道状态失败": "Failed to refresh SMS channel status",
|
||||||
|
"已注册(CS)": "Registered (CS)",
|
||||||
|
"已注册(IMS)": "Registered (IMS)",
|
||||||
|
"已注册(CS, IMS)": "Registered (CS, IMS)",
|
||||||
|
"已就绪(VoWiFi)": "Ready (VoWiFi)",
|
||||||
|
"未注册短信域": "No SMS domain registered",
|
||||||
|
"状态未知": "Unknown",
|
||||||
"正在搜索网络": "Searching network",
|
"正在搜索网络": "Searching network",
|
||||||
"SM-DP+ 的公开 Profile 库存已耗尽,请稍后重试或更换服务。":
|
"SM-DP+ 的公开 Profile 库存已耗尽,请稍后重试或更换服务。":
|
||||||
"The public profile inventory on the SM-DP+ is exhausted. Please try again later or use a different service.",
|
"The public profile inventory on the SM-DP+ is exhausted. Please try again later or use a different service.",
|
||||||
@@ -1218,4 +1227,3 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"已发现该模组,但未找到 AT 串口:通常是 option 驱动未认该 PID 或模组处于 MBIM/RNDIS 组态。可 ":
|
"已发现该模组,但未找到 AT 串口:通常是 option 驱动未认该 PID 或模组处于 MBIM/RNDIS 组态。可 ":
|
||||||
"Modem detected, but no AT serial port found: option driver may not recognize this PID or modem is in MBIM/RNDIS mode. You can ",
|
"Modem detected, but no AT serial port found: option driver may not recognize this PID or modem is in MBIM/RNDIS mode. You can ",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,10 @@ function showSmsSendOutcome(result: SmsSendResult) {
|
|||||||
message.success("短信已确认送达");
|
message.success("短信已确认送达");
|
||||||
return;
|
return;
|
||||||
case "accepted_unconfirmed":
|
case "accepted_unconfirmed":
|
||||||
message.info(
|
// Keep the user-facing result identical across cellular AT (EC20) and
|
||||||
result.transport === "ims"
|
// VoWiFi IMS (410). The transport remains available in the API response
|
||||||
? "IMS 已接受短信提交,但尚未收到收件人送达确认"
|
// for diagnostics, but it must not change the meaning shown to users.
|
||||||
: "模块已接受短信提交,但尚未收到运营商送达确认",
|
message.info("模块已接受短信提交,但尚未收到运营商送达确认", 5000);
|
||||||
5000,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
case "partial":
|
case "partial":
|
||||||
message.warning(`短信仅有 ${accepted}/${total} 段被接受,不能判定为发送成功`, 5000);
|
message.warning(`短信仅有 ${accepted}/${total} 段被接受,不能判定为发送成功`, 5000);
|
||||||
|
|||||||
Reference in New Issue
Block a user