diff --git a/web/package.json b/web/package.json index f0f22fc..2eb7b17 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite --host 127.0.0.1", "build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build", + "test": "node --test test/*.test.mjs", "preview": "vite preview --host 127.0.0.1" }, "dependencies": { diff --git a/web/src/lib/automaticTaskProfiles.ts b/web/src/lib/automaticTaskProfiles.ts new file mode 100644 index 0000000..68cac5b --- /dev/null +++ b/web/src/lib/automaticTaskProfiles.ts @@ -0,0 +1,57 @@ +export interface AutomaticTaskProfileGroup { + aidHex?: string; + profiles?: Array<{ + iccid: string; + name?: string; + serviceProviderName?: string; + }>; +} + +export interface AutomaticTaskProfileOption { + iccid: string; + aidHex: string; + label: string; +} + +export interface AutomaticTaskProfileRequestGuard { + begin: () => number; + invalidate: () => void; + isCurrent: (requestID: number) => boolean; +} + +export function createAutomaticTaskProfileRequestGuard(): AutomaticTaskProfileRequestGuard { + let latestRequestID = 0; + return { + begin: () => ++latestRequestID, + invalidate: () => { latestRequestID += 1; }, + isCurrent: (requestID) => requestID === latestRequestID, + }; +} + +export function buildAutomaticTaskProfileOptions( + groups: AutomaticTaskProfileGroup[], + currentICCID: string, + currentSIMLabel: string, +): AutomaticTaskProfileOption[] { + const options = groups.flatMap((group, groupIndex) => + (group.profiles || []).map((profile) => ({ + iccid: profile.iccid, + aidHex: group.aidHex || "", + label: `${profile.name || profile.serviceProviderName || `Profile ${groupIndex + 1}`} · ${profile.iccid}`, + })), + ); + const iccid = currentICCID.trim(); + if (iccid && !options.some((option) => option.iccid.trim() === iccid)) { + options.push({ iccid, aidHex: "", label: `${currentSIMLabel} · ${iccid}` }); + } + return options; +} + +export function selectAutomaticTaskProfileOption( + options: AutomaticTaskProfileOption[], + requestedICCID: string, +): AutomaticTaskProfileOption | undefined { + const iccid = requestedICCID.trim(); + if (iccid) return options.find((option) => option.iccid.trim() === iccid); + return options[0]; +} diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index dee8afa..2362f60 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -178,8 +178,11 @@ export const EN_DICT: Record = { 自动任务: "Automatic Tasks", "按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务": "Switch to a selected eSIM profile on schedule, then run SMS, call, or roaming public-IP jobs in a per-device queue", "按周期切换指定 eSIM Profile,并在设备串行队列中执行短信或通话任务": "Switch to a selected eSIM profile on schedule, then run SMS or call jobs in a per-device queue", + "按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务": "Use the selected SIM or switch to the selected eSIM profile on schedule, then run SMS, call, or roaming public-IP jobs in a per-device queue", + "按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信或通话任务": "Use the selected SIM or switch to the selected eSIM profile on schedule, then run SMS or call jobs in a per-device queue", 添加任务: "Add Task", "设备 / Profile": "Device / Profile", + "设备 / SIM / Profile": "Device / SIM / Profile", 执行环境: "Environment", 周期: "Schedule", 下次执行: "Next Run", @@ -194,6 +197,7 @@ export const EN_DICT: Record = { 立即执行: "Run Now", 暂无自动任务: "No automatic tasks", "添加任务后,系统会按设备排队并在执行前校验目标 Profile": "Tasks are queued per device and the target profile is verified before execution", + "添加任务后,系统会按设备排队并在执行前校验目标 SIM / Profile": "Tasks are queued per device and the target SIM or profile is verified before execution", 最近执行记录: "Recent Runs", 排队时间: "Queued At", 尝试次数: "Attempts", @@ -242,6 +246,8 @@ export const EN_DICT: Record = { 请输入号码: "Enter a number", "请选择 eSIM Profile": "Select an eSIM profile", "请选择 Profile": "Select a profile", + "请选择 SIM 卡或 eSIM Profile": "Select a SIM card or eSIM profile", + "请选择 SIM / Profile": "Select a SIM or profile", 请选择设备: "Select a device", "确定删除这个自动任务吗?": "Delete this automatic task?", 任务: "Task", diff --git a/web/src/pages/AutomaticTasksPage.tsx b/web/src/pages/AutomaticTasksPage.tsx index 91144d5..7f9c96b 100644 --- a/web/src/pages/AutomaticTasksPage.tsx +++ b/web/src/pages/AutomaticTasksPage.tsx @@ -23,6 +23,11 @@ import { message, } from "../components/ui"; import { useI18n } from "../lib/i18n"; +import { + buildAutomaticTaskProfileOptions, + createAutomaticTaskProfileRequestGuard, + selectAutomaticTaskProfileOption, +} from "../lib/automaticTaskProfiles"; type TaskType = "sms" | "call" | "public_ip"; type TaskEnvironment = "vowifi" | "cellular"; @@ -130,6 +135,10 @@ function formatDateTime(value?: string) { return Number.isNaN(date.getTime()) ? "--" : date.toLocaleString(); } +function currentDeviceICCID(device?: DeviceListItem) { + return String(device?.modem?.iccid || device?.vowifiRuntime?.iccid || "").trim(); +} + const fieldLabel = "mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-200"; export default function AutomaticTasksPage() { @@ -152,6 +161,7 @@ export default function AutomaticTasksPage() { // is actually looking at instead of snapping back to page 1 on every tick. const runsPageRef = useRef(1); const runsPageSizeRef = useRef(20); + const profileRequestGuardRef = useRef(createAutomaticTaskProfileRequestGuard()); const load = useCallback(async (initial = false) => { if (initial) setLoading(true); @@ -209,6 +219,8 @@ export default function AutomaticTasksPage() { return () => window.clearInterval(timer); }, [load, reloadRuns]); + useEffect(() => () => profileRequestGuardRef.current.invalidate(), []); + function changeRunsPage(page: number) { runsPageRef.current = page; setRunsPage(page); @@ -223,37 +235,53 @@ export default function AutomaticTasksPage() { void fetchRuns(1, pageSize); } - const loadProfiles = useCallback(async (deviceId: string, keepICCID = "") => { + const loadProfiles = useCallback(async (deviceId: string, keepICCID = "", currentICCID = "") => { + if (!deviceId) { + profileRequestGuardRef.current.invalidate(); + setProfiles([]); + setProfileLoading(false); + return; + } + const requestID = profileRequestGuardRef.current.begin(); setProfiles([]); - if (!deviceId) return; setProfileLoading(true); + let groups: EsimProfileGroup[] = []; + let inventoryError: unknown; try { const data = await api<{ profiles?: EsimProfileGroup[] }>(`/devices/${encodeURIComponent(deviceId)}/esim`); - const options = (data.profiles || []).flatMap((group, groupIndex) => - (group.profiles || []).map((profile) => ({ - iccid: profile.iccid, - aidHex: group.aidHex || "", - label: `${profile.name || profile.serviceProviderName || `Profile ${groupIndex + 1}`} · ${profile.iccid}`, - })), - ); - setProfiles(options); - setForm((current) => { - if (current.deviceId !== deviceId) return current; - const selected = options.find((item) => item.iccid === (keepICCID || current.profileIccid)) || options[0]; - return selected ? { ...current, profileIccid: selected.iccid, profileAid: selected.aidHex } : current; - }); + groups = data.profiles || []; } catch (error) { - message.error(apiMessage(error)); - } finally { - setProfileLoading(false); + inventoryError = error; } - }, []); + if (!profileRequestGuardRef.current.isCurrent(requestID)) return; + const options = buildAutomaticTaskProfileOptions(groups, currentICCID, t("当前 SIM 卡")); + const requestedICCID = keepICCID.trim(); + const requestedUnavailable = requestedICCID !== "" && + !options.some((option) => option.iccid.trim() === requestedICCID); + if (inventoryError && (options.length === 0 || requestedUnavailable)) { + message.error(apiMessage(inventoryError)); + } + setProfiles(options); + setForm((current) => { + if (current.deviceId !== deviceId) return current; + const selected = selectAutomaticTaskProfileOption(options, requestedICCID); + return selected ? { ...current, profileIccid: selected.iccid, profileAid: selected.aidHex } : current; + }); + setProfileLoading(false); + }, [t]); + + function closeEditor() { + profileRequestGuardRef.current.invalidate(); + setProfileLoading(false); + setOpen(false); + } const deviceByID = useMemo(() => new Map(devices.map((device) => [device.id, device])), [devices]); const taskByID = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); function edit(task?: AutomaticTask) { const deviceId = task?.deviceId || devices[0]?.id || ""; + const selectedDevice = devices.find((device) => device.id === deviceId); let next = task ? { id: task.id, name: task.name, @@ -272,7 +300,7 @@ export default function AutomaticTasksPage() { message: task.payload?.message || "", durationSeconds: task.payload?.durationSeconds || 30, } : emptyForm(deviceId); - if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") { + if (selectedDevice?.deviceType === "usb_sim_reader") { next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" }; } if (!advancedTasksAvailable && (next.taskType === "public_ip" || next.environment === "cellular")) { @@ -280,17 +308,18 @@ export default function AutomaticTasksPage() { } setForm(next); setOpen(true); - void loadProfiles(deviceId, next.profileIccid); + void loadProfiles(deviceId, next.profileIccid, currentDeviceICCID(selectedDevice)); } function chooseDevice(deviceId: string) { - const reader = devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader"; + const selectedDevice = devices.find((device) => device.id === deviceId); + const reader = selectedDevice?.deviceType === "usb_sim_reader"; setForm((current) => ({ ...current, deviceId, profileIccid: "", profileAid: "", taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType, environment: reader || !advancedTasksAvailable ? "vowifi" : current.environment, })); - void loadProfiles(deviceId); + void loadProfiles(deviceId, "", currentDeviceICCID(selectedDevice)); } function chooseProfile(iccid: string) { @@ -310,7 +339,7 @@ export default function AutomaticTasksPage() { async function save() { if (!form.name.trim()) return message.warning(t("请输入任务名称")); if (!form.deviceId) return message.warning(t("请选择设备")); - if (!form.profileIccid) return message.warning(t("请选择 eSIM Profile")); + if (!form.profileIccid) return message.warning(t("请选择 SIM 卡或 eSIM Profile")); if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) { return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务")); } @@ -344,7 +373,7 @@ export default function AutomaticTasksPage() { body, }); message.success(t(form.id ? "自动任务已更新" : "自动任务已创建")); - setOpen(false); + closeEditor(); await load(); } catch (error) { message.error(apiMessage(error)); @@ -411,8 +440,8 @@ export default function AutomaticTasksPage() { } onClick={() => edit()} disabled={!devices.length}>{t("添加任务")}} /> @@ -422,7 +451,7 @@ export default function AutomaticTasksPage() { {t("任务")} - {t("设备 / Profile")} + {t("设备 / SIM / Profile")} {t("类型")} {t("执行环境")} {t("周期")} @@ -466,7 +495,7 @@ export default function AutomaticTasksPage() {
{t("暂无自动任务")}
-
{t("添加任务后,系统会按设备排队并在执行前校验目标 Profile")}
+
{t("添加任务后,系统会按设备排队并在执行前校验目标 SIM / Profile")}
) : null} {loading ?
{t("加载中...")}
: null} @@ -498,11 +527,11 @@ export default function AutomaticTasksPage() { ) : null} - setOpen(false)} title={form.id ? t("编辑自动任务") : t("添加自动任务")} width="max-w-3xl"> +
setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} />
({ value: profile.iccid, label: profile.label }))} />
+
chooseTaskType(value as TaskType)} options={taskTypeOptions} />