mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-18 13:53:42 +08:00
Implement automatic task management with CRUD operations and UI integration
- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks. - Created `automatic_tasks_test.go` to validate task claiming and deletion behavior. - Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution. - Integrated device and eSIM profile selection for task configuration. - Implemented automatic task scheduling and retry logic in the backend.
This commit is contained in:
@@ -14,6 +14,7 @@ import DevicesPage from "./pages/DevicesPage";
|
||||
import ProxyPage from "./pages/ProxyPage";
|
||||
import ExportProxyPage from "./pages/ExportProxyPage";
|
||||
import SmsPage from "./pages/SmsPage";
|
||||
import AutomaticTasksPage from "./pages/AutomaticTasksPage";
|
||||
import LogsPage from "./pages/LogsPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import ExtensionPage from "./pages/ExtensionPage";
|
||||
@@ -113,6 +114,7 @@ function AppRoot() {
|
||||
<Route path="proxy" element={<ProxyPage />} />
|
||||
<Route path="export-proxy" element={<ExportProxyPage />} />
|
||||
<Route path="sms" element={<SmsPage />} />
|
||||
<Route path="automatic-tasks" element={<AutomaticTasksPage />} />
|
||||
<Route path="extensions/:pluginId/:contributionId" element={<ExtensionPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
|
||||
@@ -61,7 +61,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<PolicySwitchCard
|
||||
title="VoWiFi"
|
||||
subtitle={t("启用后进飞行模式,不支持国内运营商")}
|
||||
subtitle={t("启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式")}
|
||||
tone="orange"
|
||||
checked={local.vowifiEnabled}
|
||||
disabled={!operable || toggles.vowifiPending}
|
||||
@@ -71,7 +71,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
|
||||
/>
|
||||
<PolicySwitchCard
|
||||
title={t("飞行模式")}
|
||||
subtitle={t("射频关闭,断网;VoWiFi 开启时由其接管")}
|
||||
subtitle={t("只有手动关闭此开关才允许设备连接基站")}
|
||||
tone="indigo"
|
||||
checked={local.airplaneEnabled}
|
||||
disabled={!operable || local.vowifiEnabled || toggles.airplanePending}
|
||||
|
||||
@@ -111,7 +111,11 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
|
||||
<div>
|
||||
<div className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("设备运行模式")}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{isQmi ? t("此类设备固定 QMI,AT 口仅用于终端") : isMbim ? t("此类设备固定 MBIM,AT 口仅用于终端") : t("AT=传统串口 / QMI=纯 QMI")}
|
||||
{isQmi
|
||||
? t("QMI 负责驻网状态与数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令")
|
||||
: isMbim
|
||||
? t("MBIM 负责数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令")
|
||||
: t("AT 模式通过串口管理驻网与 PDP 数据会话")}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Switch } from "../ui";
|
||||
import type { DeviceDetail } from "./types";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { deviceTypeImage } from "../../lib/deviceTypes";
|
||||
import { isVoWiFiInUse } from "./shared";
|
||||
|
||||
export interface DeviceDetailHeaderProps {
|
||||
device: DeviceDetail;
|
||||
@@ -19,7 +20,7 @@ export interface DeviceDetailHeaderProps {
|
||||
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
|
||||
const { t } = useI18n();
|
||||
const { device } = props;
|
||||
const vowifiInUse = !!device.vowifiEnabled;
|
||||
const vowifiInUse = isVoWiFiInUse(device);
|
||||
return (
|
||||
<div className="ui-card p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DeviceListItem } from "../../types";
|
||||
import { Input, Select, Tag, ListSkeleton, EmptyState } from "../ui";
|
||||
import { isDeviceOnline, isRegistered, lifecycleLabel } from "./shared";
|
||||
import { isDeviceOnline, isRegistered, isVoWiFiInUse, lifecycleLabel } from "./shared";
|
||||
import { DeviceListItemCard } from "./DeviceListItemCard";
|
||||
import { tl, useI18n } from "../../lib/i18n";
|
||||
|
||||
@@ -40,7 +40,7 @@ function primaryLine(d: DeviceListItem): string {
|
||||
}
|
||||
|
||||
function statusLine(d: DeviceListItem): string {
|
||||
if (d?.vowifiEnabled) return "WiFi-Calling";
|
||||
if (isVoWiFiInUse(d)) return "WiFi-Calling";
|
||||
return primaryLine(d);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { OverviewTrafficChart } from "./OverviewTrafficChart";
|
||||
import { OperatorSelectionDialog } from "./OperatorSelectionDialog";
|
||||
import type { DeviceDetail } from "./types";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { isVoWiFiInUse } from "./shared";
|
||||
|
||||
export interface DeviceOverviewTabProps {
|
||||
device: DeviceDetail;
|
||||
@@ -29,7 +30,7 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="ui-panel-muted p-4">
|
||||
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
|
||||
{device?.vowifiEnabled ? (
|
||||
{isVoWiFiInUse(device) ? (
|
||||
<OverviewVowifiCard device={device} />
|
||||
) : (
|
||||
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FieldRow } from "./FieldRow";
|
||||
import { useShowSensitive } from "./shared";
|
||||
import type { DeviceDetail } from "./types";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { carrierIso, flagEmoji } from "../../lib/carrier";
|
||||
import { carrierBrandIso, flagEmoji } from "../../lib/carrier";
|
||||
|
||||
export interface OverviewSimPanelProps {
|
||||
device: DeviceDetail;
|
||||
@@ -20,7 +20,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
|
||||
const sensitive = !showSensitive;
|
||||
const activeEsim = (device.activeEsimProfileName || "").trim();
|
||||
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
|
||||
const carrierFlag = flagEmoji(carrierIso(modem?.imsi));
|
||||
const carrierFlag = flagEmoji(carrierBrandIso(modem?.nativeSpn, modem?.imsi));
|
||||
const operatorValue =
|
||||
carrierFlag && simOperatorDisplay !== "--" ? `${carrierFlag} ${simOperatorDisplay}` : simOperatorDisplay;
|
||||
const backendLabel =
|
||||
|
||||
@@ -59,6 +59,17 @@ export function isRegistered(device?: { modem?: { regStatus?: number } } | null)
|
||||
return s === 1 || s === 5;
|
||||
}
|
||||
|
||||
// The stored device flag is the desired policy, while runtime.enabled is the
|
||||
// live owner of RF/IKE/IMS. A stale desired flag must not replace a healthy
|
||||
// cellular overview with an all-red "disabled" VoWiFi pipeline.
|
||||
export function isVoWiFiInUse(device?: {
|
||||
vowifiEnabled?: boolean;
|
||||
vowifiRuntime?: { enabled?: boolean };
|
||||
} | null): boolean {
|
||||
if (!device?.vowifiEnabled) return false;
|
||||
return device.vowifiRuntime?.enabled !== false;
|
||||
}
|
||||
|
||||
export interface StatusMeta {
|
||||
label: string;
|
||||
tag: "success" | "warning" | "danger";
|
||||
@@ -248,7 +259,15 @@ export function simOperatorDisplay(device?: DeviceDetail | null): string {
|
||||
const spn = String(modem?.nativeSpn ?? "").trim();
|
||||
const name = oplPnnName(modem) || firstPnnName(modem?.pnn);
|
||||
const plmn = plmnOf(modem);
|
||||
if (spn) return withPlmn(spn, plmn);
|
||||
// EF_SPN is the SIM's customer-facing brand. Do not append the currently
|
||||
// visited PLMN: a roaming Lebara UK SIM on a Chinese network would otherwise
|
||||
// be mislabeled as "Lebara (460xx)". Append the home/authentication PLMN
|
||||
// resolved from IMSI instead, so GigSky on 222-01 renders as
|
||||
// "GigSky (22201)" even while roaming.
|
||||
if (spn) {
|
||||
const home = lookupCarrier(modem?.imsi);
|
||||
return withPlmn(spn, home ? home.mcc + home.mnc : cardPlmnOf(modem));
|
||||
}
|
||||
if (name) return withPlmn(name, plmn);
|
||||
// Home ("original") carrier resolved from the SIM's IMSI via the MCC/MNC table.
|
||||
// Readable even when the modem isn't camped (VoWiFi RF-off / flight mode).
|
||||
|
||||
@@ -13,10 +13,13 @@ export interface PolicyToggleImpl {
|
||||
|
||||
type Field = "vowifi" | "airplane";
|
||||
|
||||
// mutual-exclusion merge: vowifi on clears airplane; airplane on clears vowifi.
|
||||
// RF-safe merge: VoWiFi always implies airplane mode. Turning VoWiFi off keeps
|
||||
// airplane mode on; only the separate airplane switch can explicitly restore RF.
|
||||
function mergePolicy(current: PolicyFlags, field: Field, value: boolean): PolicyFlags {
|
||||
if (field === "vowifi") {
|
||||
return value ? { vowifiEnabled: true, airplaneEnabled: false } : { ...current, vowifiEnabled: false };
|
||||
return value
|
||||
? { vowifiEnabled: true, airplaneEnabled: true }
|
||||
: { vowifiEnabled: false, airplaneEnabled: true };
|
||||
}
|
||||
return value ? { vowifiEnabled: false, airplaneEnabled: true } : { ...current, airplaneEnabled: false };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AddRegular, DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
|
||||
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
|
||||
import type { UpstreamProxy } from "../../types";
|
||||
import { Button, EmptyState, ErrorState, ListSkeleton, Tag } from "../ui";
|
||||
import { Button, Tag } from "../ui";
|
||||
import type { LoadError, UpstreamRow } from "./shared";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
@@ -9,90 +9,73 @@ export interface UpstreamSectionProps {
|
||||
loading: boolean;
|
||||
error: LoadError | null;
|
||||
onRetry: () => void;
|
||||
onNew: () => void;
|
||||
onEdit: (proxy: UpstreamProxy) => void;
|
||||
onDelete: (proxy: UpstreamProxy) => void;
|
||||
onOpenBindings: (proxy: UpstreamProxy) => void;
|
||||
}
|
||||
|
||||
function UpstreamRowCard({
|
||||
row,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onOpenBindings,
|
||||
}: {
|
||||
row: UpstreamRow;
|
||||
onEdit: (proxy: UpstreamProxy) => void;
|
||||
onDelete: (proxy: UpstreamProxy) => void;
|
||||
onOpenBindings: (proxy: UpstreamProxy) => void;
|
||||
}) {
|
||||
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="ui-panel-muted flex flex-col gap-3 p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.enabled ? "bg-green-500" : "bg-gray-300"}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-bold text-gray-900 dark:text-white">{row.name || row.id}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-gray-500">
|
||||
SOCKS5 · <span className="font-mono">{row.addr}</span>
|
||||
{row.username ? <span> · {t("鉴权")}: {row.username}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag>
|
||||
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
|
||||
<DesktopRegular className="text-[14px]" />
|
||||
<span>{row.bindingCount} {t("台设备")}</span>
|
||||
</div>
|
||||
<div className="mx-0.5 hidden h-3.5 w-px bg-gray-200 dark:bg-gray-700 sm:block" />
|
||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>
|
||||
<span className="hidden sm:inline">{t("设备绑定")}</span>
|
||||
</Button>
|
||||
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)} />
|
||||
<Button size="small" variant="danger" icon={<DeleteRegular />} onClick={() => onDelete(row)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpstreamSection({ rows, loading, error, onRetry, onNew, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div>
|
||||
<div className="ui-card overflow-hidden">
|
||||
{error ? (
|
||||
<ErrorState className="mb-6" title={t("加载上游代理失败")} message={error.message} statusCode={error.status} retryText={t("重试")} onRetry={onRetry} />
|
||||
) : null}
|
||||
<div className="ui-card p-6">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-[#0ea5e9] to-[#0284c7] text-white shadow-lg shadow-indigo-500/25">
|
||||
<GlobeRegular className="text-[20px]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{t("VoWiFi 上游代理")}</div>
|
||||
<div className="text-xs text-gray-500">{t("将设备的 VoWiFi 建链、IMS 和短信通信通过支持 UDP Associate 的 SOCKS5 代理传输。")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" className="!border-0" icon={<AddRegular />} onClick={onNew}>
|
||||
{t("新增代理")}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-red-200 bg-red-50 p-3 text-sm text-red-600 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
|
||||
<span className="min-w-0 truncate">
|
||||
{t("加载上游代理失败")}:{error.message}
|
||||
{error.status ? `(${error.status})` : ""}
|
||||
</span>
|
||||
<button type="button" className="shrink-0 font-medium underline underline-offset-2" onClick={onRetry}>
|
||||
{t("重试")}
|
||||
</button>
|
||||
</div>
|
||||
{loading && rows.length === 0 ? (
|
||||
<ListSkeleton rows={2} />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("暂无上游代理")}
|
||||
subtitle={t("点击“新增代理”创建 SOCKS5 上游代理,然后将需要使用它的设备直接绑定;未绑定设备默认直连。")}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
) : null}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t("名称")}</th>
|
||||
<th className="px-4 py-3">{t("协议")}</th>
|
||||
<th className="px-4 py-3">{t("地址")}</th>
|
||||
<th className="px-4 py-3">{t("鉴权")}</th>
|
||||
<th className="px-4 py-3">{t("状态")}</th>
|
||||
<th className="px-4 py-3">{t("设备绑定")}</th>
|
||||
<th className="px-4 py-3 text-right">{t("操作")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
|
||||
{rows.map((row) => (
|
||||
<UpstreamRowCard key={row.id} row={row} onEdit={onEdit} onDelete={onDelete} onOpenBindings={onOpenBindings} />
|
||||
<tr key={row.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
|
||||
<td className="px-4 py-3 font-semibold">{row.name || row.id}</td>
|
||||
<td className="px-4 py-3"><Tag type="primary">SOCKS5</Tag></td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{row.addr}</td>
|
||||
<td className="px-4 py-3">{row.username || t("无")}</td>
|
||||
<td className="px-4 py-3"><Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
|
||||
<DesktopRegular className="text-[14px]" />
|
||||
<span>{row.bindingCount} {t("台设备")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("设备绑定")}</Button>
|
||||
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
|
||||
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!loading && !rows.length ? (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
|
||||
<GlobeRegular className="mb-3 text-4xl" />
|
||||
<div className="text-sm">{t("暂无上游代理")}</div>
|
||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,然后将需要使用它的设备直接绑定;未绑定设备默认直连。")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function DeviceQuotaCard({
|
||||
</CardIcon>
|
||||
<CardTitle
|
||||
title={zh ? "设备配额" : "Device quota"}
|
||||
subtitle={zh ? "开发者模式下允许配置的设备数量" : "Configured device allowance in developer mode"}
|
||||
subtitle={zh ? "最多允许配置的设备数量" : "Maximum number of configurable devices"}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10 space-y-4">
|
||||
@@ -46,8 +46,8 @@ export function DeviceQuotaCard({
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{zh
|
||||
? `关闭开发者模式后会自动恢复为 ${value?.defaultDeviceLimit ?? 5} 台,不会删除已经添加的设备。`
|
||||
: `Disabling developer mode restores ${value?.defaultDeviceLimit ?? 5}; existing devices are not deleted.`}
|
||||
? `恢复默认配置后会自动恢复为 ${value?.defaultDeviceLimit ?? 5} 台,不会删除已经添加的设备。`
|
||||
: `Restoring the default configuration resets the quota to ${value?.defaultDeviceLimit ?? 5}; existing devices are not deleted.`}
|
||||
</p>
|
||||
<Button variant="primary" loading={saving} disabled={loading} onClick={onSave} className="w-full !border-0">
|
||||
{zh ? "保存设备配额" : "Save device quota"}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function HTTPSCard({
|
||||
</CardIcon>
|
||||
<CardTitle
|
||||
title={zh ? "本机自签 HTTPS" : "Local self-signed HTTPS"}
|
||||
subtitle={zh ? "为浏览器麦克风和安全连接提供 HTTPS" : "HTTPS for browser microphone and secure connections"}
|
||||
subtitle={zh ? "为安全连接提供 HTTPS" : "HTTPS for secure connections"}
|
||||
/>
|
||||
</div>
|
||||
<Switch checked={enabled} disabled={loading || saving} loading={saving} onChange={onToggle} />
|
||||
@@ -48,8 +48,8 @@ export function HTTPSCard({
|
||||
) : null}
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{zh
|
||||
? "自签证书需要在系统或浏览器中信任;否则浏览器可能继续拒绝麦克风权限。"
|
||||
: "Trust the self-signed certificate in the operating system or browser; otherwise microphone access may still be rejected."}
|
||||
? "自签证书需要在系统或浏览器中信任,否则浏览器可能继续提示连接不安全。"
|
||||
: "Trust the self-signed certificate in the operating system or browser; otherwise the browser may keep warning that the connection is not secure."}
|
||||
</p>
|
||||
<Button onClick={() => window.open("/api/settings/https/certificate", "_blank")} disabled={loading}>
|
||||
{zh ? "下载自签证书" : "Download certificate"}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DocumentTextRegular,
|
||||
GlobeRegular,
|
||||
MailRegular,
|
||||
SendClockRegular,
|
||||
PanelLeftContractRegular,
|
||||
PanelLeftExpandRegular,
|
||||
RouterRegular,
|
||||
@@ -30,6 +31,7 @@ const NAV = [
|
||||
{ to: "/devices", label: "设备管理", icon: RouterRegular },
|
||||
{ to: "/proxy", label: "代理管理", icon: GlobeRegular },
|
||||
{ to: "/sms", label: "短信检测", icon: MailRegular },
|
||||
{ to: "/automatic-tasks", label: "自动任务", icon: SendClockRegular },
|
||||
{ to: "/logs", label: "实时日志", icon: DocumentTextRegular },
|
||||
{ to: "/settings", label: "系统设置", icon: SettingsRegular },
|
||||
];
|
||||
|
||||
@@ -48,6 +48,16 @@ export function carrierIso(imsi?: string): string {
|
||||
return data.i[imsiDigits(imsi).slice(0, 3)] ?? "";
|
||||
}
|
||||
|
||||
// carrierBrandIso keeps the normal IMSI country flag for branded/MVNO SIMs.
|
||||
// Lebara UK's Vodafone-NL-hosted 204-04 eSIM is the one known exception: its
|
||||
// customer-facing country is GB even though AKA must continue using 204-04.
|
||||
export function carrierBrandIso(spn?: string, imsi?: string): string {
|
||||
const brand = String(spn ?? "").trim().toLowerCase();
|
||||
const digits = imsiDigits(imsi);
|
||||
if (brand.includes("lebara") && digits.startsWith("20404")) return "gb";
|
||||
return carrierIso(imsi);
|
||||
}
|
||||
|
||||
// flagEmoji converts an alpha-2 country code to its regional-indicator flag emoji.
|
||||
export function flagEmoji(iso?: string): string {
|
||||
const s = String(iso ?? "").trim().toUpperCase();
|
||||
|
||||
+80
-5
@@ -45,10 +45,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
"绑定设备": "Bind Device",
|
||||
"台设备": "devices",
|
||||
"鉴权": "Auth",
|
||||
"无": "None",
|
||||
"加载上游代理失败": "Failed to load upstream proxies",
|
||||
"VoWiFi 上游代理": "VoWiFi Upstream Proxies",
|
||||
"将设备的 VoWiFi 建链、IMS 和短信通信通过支持 UDP Associate 的 SOCKS5 代理传输。":
|
||||
"Route device VoWiFi setup, IMS, and SMS communications through a SOCKS5 proxy with UDP Associate support.",
|
||||
"暂无上游代理": "No upstream proxies",
|
||||
"点击“新增代理”创建 SOCKS5 上游代理,然后将需要使用它的设备直接绑定;未绑定设备默认直连。":
|
||||
"Create a SOCKS5 upstream proxy, then bind the devices that should use it. Unbound devices use a direct connection.",
|
||||
@@ -77,6 +75,74 @@ export const EN_DICT: Record<string, string> = {
|
||||
设备管理: "Devices",
|
||||
代理管理: "Proxy",
|
||||
短信检测: "SMS Test",
|
||||
自动任务: "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",
|
||||
添加任务: "Add Task",
|
||||
"设备 / Profile": "Device / Profile",
|
||||
执行环境: "Environment",
|
||||
周期: "Schedule",
|
||||
下次执行: "Next Run",
|
||||
上次结果: "Last Result",
|
||||
完成后推送通知: "Notify on Completion",
|
||||
不推送通知: "No Notifications",
|
||||
"失败重试 {count} 次": "Retry {count} times on failure",
|
||||
拨打电话并自动挂断: "Call and Auto Hang Up",
|
||||
获取漫游公网IP: "Get Roaming Public IP",
|
||||
基站直连: "Cellular",
|
||||
"每 {days} 天": "Every {days} days",
|
||||
立即执行: "Run Now",
|
||||
暂无自动任务: "No automatic tasks",
|
||||
"添加任务后,系统会按设备排队并在执行前校验目标 Profile": "Tasks are queued per device and the target profile is verified before execution",
|
||||
最近执行记录: "Recent Runs",
|
||||
排队时间: "Queued At",
|
||||
尝试次数: "Attempts",
|
||||
排队中: "Queued",
|
||||
执行中: "Running",
|
||||
暂无执行记录: "No run history",
|
||||
编辑自动任务: "Edit Automatic Task",
|
||||
添加自动任务: "Add Automatic Task",
|
||||
任务名称: "Task Name",
|
||||
"例如:每日短信保活": "For example: Daily SMS keepalive",
|
||||
"eSIM Profile": "eSIM Profile",
|
||||
读取Profile中: "Loading profiles...",
|
||||
请选择Profile: "Select a profile",
|
||||
任务类型: "Task Type",
|
||||
开启漫游流量并获取一次公网IP: "Enable roaming data and get the public IP once",
|
||||
"基站直连(自动选网)": "Cellular (automatic network selection)",
|
||||
"该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。需要开启开发者模式。": "This task always uses cellular direct mode with automatic network selection. It enables roaming data and accesses ipinfo.io through the modem interface. Developer mode is required.",
|
||||
首次执行日期: "First Run Date",
|
||||
执行时间: "Run Time",
|
||||
执行周期: "Interval",
|
||||
任务失败重试次数: "Failure Retries",
|
||||
"{count} 次": "{count}",
|
||||
启用任务: "Enable Task",
|
||||
停用后不会进入执行队列: "Disabled tasks are not queued",
|
||||
发送到全部已配置并启用的通知渠道: "Send to every configured and enabled notification channel",
|
||||
请输入任务名称: "Enter a task name",
|
||||
请选择eSIMProfile: "Select an eSIM profile",
|
||||
任务已加入设备队列: "Task added to the device queue",
|
||||
确定删除这个自动任务吗: "Delete this automatic task?",
|
||||
自动任务已删除: "Automatic task deleted",
|
||||
自动任务已更新: "Automatic task updated",
|
||||
自动任务已创建: "Automatic task created",
|
||||
编辑: "Edit",
|
||||
成功: "Success",
|
||||
"读取 Profile 中...": "Loading profiles...",
|
||||
号码: "Number",
|
||||
"获取漫游公网 IP": "Get Roaming Public IP",
|
||||
结果: "Result",
|
||||
"开启漫游流量并获取一次公网 IP": "Enable roaming data and get the public IP once",
|
||||
类型: "Type",
|
||||
请输入短信内容: "Enter the message",
|
||||
请输入号码: "Enter a number",
|
||||
"请选择 eSIM Profile": "Select an eSIM profile",
|
||||
"请选择 Profile": "Select a profile",
|
||||
请选择设备: "Select a device",
|
||||
"确定删除这个自动任务吗?": "Delete this automatic task?",
|
||||
任务: "Task",
|
||||
失败: "Failed",
|
||||
状态: "Status",
|
||||
自动挂断: "Auto Hang Up",
|
||||
实时日志: "Live Logs",
|
||||
系统设置: "Settings",
|
||||
主导航: "Main navigation",
|
||||
@@ -530,6 +596,10 @@ export const EN_DICT: Record<string, string> = {
|
||||
"名称修改成功": "Name updated",
|
||||
"否": "No",
|
||||
"启用后进飞行模式,不支持国内运营商": "Once enabled it enters airplane mode; domestic carriers are not supported",
|
||||
"启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式":
|
||||
"Enabling forces cellular RF off; airplane mode remains on after VoWiFi is disabled.",
|
||||
"只有手动关闭此开关才允许设备连接基站":
|
||||
"The device may connect to a cellular base station only after you manually turn this switch off.",
|
||||
"命令": "Command",
|
||||
"命令 / 回复": "Command / Reply",
|
||||
"回复": "Reply",
|
||||
@@ -627,6 +697,12 @@ export const EN_DICT: Record<string, string> = {
|
||||
"此类 WWAN QMI 设备运行后端固定为 QMI;AT 口仍会保留给 AT 终端。": "This WWAN QMI device is fixed to the QMI backend; the AT port remains available for the AT terminal.",
|
||||
"此类设备固定 MBIM,AT 口仅用于终端": "This device is fixed to MBIM; the AT port is for the terminal only",
|
||||
"此类设备固定 QMI,AT 口仅用于终端": "This device is fixed to QMI; the AT port is for the terminal only",
|
||||
"QMI 负责驻网状态与数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令":
|
||||
"QMI handles registration status and packet-data sessions; AT handles SIM/eSIM, RF, SMS, calls, and terminal commands.",
|
||||
"MBIM 负责数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令":
|
||||
"MBIM handles packet-data sessions; AT handles SIM/eSIM, RF, SMS, calls, and terminal commands.",
|
||||
"AT 模式通过串口管理驻网与 PDP 数据会话":
|
||||
"AT mode manages registration and PDP data sessions through the serial port.",
|
||||
"注册状态": "Registration",
|
||||
"浏览器限制,请手动复制": "Blocked by the browser; please copy manually",
|
||||
"添加失败": "Add failed",
|
||||
@@ -753,7 +829,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
"国家/地区": "Country / Region",
|
||||
"公网 IP 检测失败": "Public IP detection failed",
|
||||
"导出代理": "Export Proxy",
|
||||
"将模块漫游数据导出为主机 HTTP 或 SOCKS5 代理;仅在开发者模式下可用": "Export modem roaming data as host HTTP or SOCKS5 proxies; available only in developer mode",
|
||||
"将模块漫游数据导出为主机 HTTP 或 SOCKS5 代理": "Export modem roaming data as host HTTP or SOCKS5 proxies",
|
||||
"添加代理": "Add Proxy",
|
||||
"网络接口": "Network Interface",
|
||||
"协议": "Protocol",
|
||||
@@ -762,7 +838,6 @@ export const EN_DICT: Record<string, string> = {
|
||||
"已停用": "Stopped",
|
||||
"暂无导出代理配置": "No export proxies configured",
|
||||
"先在设备页面开启漫游数据,再创建代理": "Enable roaming data on the device page before creating a proxy",
|
||||
"代理出口使用受保护的蜂窝路由和独立 DNS,不会把模块数据设为主机默认网络。关闭开发者模式会停止漫游数据并永久删除这里的全部配置。": "Proxy traffic uses protected cellular routing and isolated DNS without becoming the host default network. Disabling developer mode stops roaming data and permanently deletes every configuration here.",
|
||||
"编辑导出代理": "Edit Export Proxy",
|
||||
"添加导出代理": "Add Export Proxy",
|
||||
"例如:EC20 漫游出口": "For example: EC20 roaming exit",
|
||||
|
||||
+10687
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,429 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AddRegular,
|
||||
DeleteRegular,
|
||||
EditRegular,
|
||||
PlayRegular,
|
||||
SendClockRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { api, apiMessage } from "../api";
|
||||
import type { DeviceListItem, DevicesResponse } from "../types";
|
||||
import type { EsimProfileGroup } from "../components/devices/types";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Select,
|
||||
Switch,
|
||||
Tag,
|
||||
Textarea,
|
||||
confirmDialog,
|
||||
message,
|
||||
} from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
||||
type TaskType = "sms" | "call" | "public_ip";
|
||||
type TaskEnvironment = "vowifi" | "cellular";
|
||||
|
||||
interface AutomaticTaskPayload {
|
||||
phone?: string;
|
||||
message?: string;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
interface AutomaticTask {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
deviceId: string;
|
||||
profileIccid: string;
|
||||
profileAid: string;
|
||||
taskType: TaskType;
|
||||
environment: TaskEnvironment;
|
||||
intervalDays: number;
|
||||
startDate: string;
|
||||
runTime: string;
|
||||
payload: AutomaticTaskPayload;
|
||||
retryCount: number;
|
||||
notify: boolean;
|
||||
nextRunAt: string;
|
||||
lastRunAt?: string;
|
||||
lastStatus: string;
|
||||
lastError: string;
|
||||
}
|
||||
|
||||
interface AutomaticTaskRun {
|
||||
id: number;
|
||||
taskId: number;
|
||||
deviceId: string;
|
||||
scheduledAt: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
status: "queued" | "running" | "success" | "failed";
|
||||
attempts: number;
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface TaskForm {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
deviceId: string;
|
||||
profileIccid: string;
|
||||
profileAid: string;
|
||||
taskType: TaskType;
|
||||
environment: TaskEnvironment;
|
||||
intervalDays: number;
|
||||
startDate: string;
|
||||
runTime: string;
|
||||
retryCount: number;
|
||||
notify: boolean;
|
||||
phone: string;
|
||||
message: string;
|
||||
durationSeconds: number;
|
||||
}
|
||||
|
||||
interface ProfileOption {
|
||||
iccid: string;
|
||||
aidHex: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function localDate(value = new Date()) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function localTime(value = new Date(Date.now() + 5 * 60_000)) {
|
||||
return `${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function emptyForm(deviceId = ""): TaskForm {
|
||||
return {
|
||||
id: 0,
|
||||
name: "",
|
||||
enabled: true,
|
||||
deviceId,
|
||||
profileIccid: "",
|
||||
profileAid: "",
|
||||
taskType: "sms",
|
||||
environment: "vowifi",
|
||||
intervalDays: 1,
|
||||
startDate: localDate(),
|
||||
runTime: localTime(),
|
||||
retryCount: 1,
|
||||
notify: true,
|
||||
phone: "",
|
||||
message: "",
|
||||
durationSeconds: 30,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value || value.startsWith("0001-")) return "--";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "--" : date.toLocaleString();
|
||||
}
|
||||
|
||||
const fieldLabel = "mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-200";
|
||||
|
||||
export default function AutomaticTasksPage() {
|
||||
const { t } = useI18n();
|
||||
const [tasks, setTasks] = useState<AutomaticTask[]>([]);
|
||||
const [runs, setRuns] = useState<AutomaticTaskRun[]>([]);
|
||||
const [devices, setDevices] = useState<DeviceListItem[]>([]);
|
||||
const [profiles, setProfiles] = useState<ProfileOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState<TaskForm>(() => emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [busy, setBusy] = useState(0);
|
||||
|
||||
const load = useCallback(async (initial = false) => {
|
||||
if (initial) setLoading(true);
|
||||
try {
|
||||
const [taskData, deviceData] = await Promise.all([
|
||||
api<{ tasks?: AutomaticTask[]; runs?: AutomaticTaskRun[] }>("/automatic-tasks"),
|
||||
api<DevicesResponse>("/devices"),
|
||||
]);
|
||||
setTasks(taskData.tasks || []);
|
||||
setRuns(taskData.runs || []);
|
||||
setDevices(deviceData.devices || []);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
if (initial) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load(true);
|
||||
const timer = window.setInterval(() => void load(), 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const loadProfiles = useCallback(async (deviceId: string, keepICCID = "") => {
|
||||
setProfiles([]);
|
||||
if (!deviceId) return;
|
||||
setProfileLoading(true);
|
||||
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;
|
||||
});
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
setProfileLoading(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 next = task ? {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
enabled: task.enabled,
|
||||
deviceId: task.deviceId,
|
||||
profileIccid: task.profileIccid,
|
||||
profileAid: task.profileAid || "",
|
||||
taskType: task.taskType,
|
||||
environment: task.environment,
|
||||
intervalDays: task.intervalDays,
|
||||
startDate: task.startDate,
|
||||
runTime: task.runTime,
|
||||
retryCount: task.retryCount,
|
||||
notify: task.notify,
|
||||
phone: task.payload?.phone || "",
|
||||
message: task.payload?.message || "",
|
||||
durationSeconds: task.payload?.durationSeconds || 30,
|
||||
} : emptyForm(deviceId);
|
||||
setForm(next);
|
||||
setOpen(true);
|
||||
void loadProfiles(deviceId, next.profileIccid);
|
||||
}
|
||||
|
||||
function chooseDevice(deviceId: string) {
|
||||
setForm((current) => ({ ...current, deviceId, profileIccid: "", profileAid: "" }));
|
||||
void loadProfiles(deviceId);
|
||||
}
|
||||
|
||||
function chooseProfile(iccid: string) {
|
||||
const selected = profiles.find((profile) => profile.iccid === iccid);
|
||||
setForm((current) => ({ ...current, profileIccid: iccid, profileAid: selected?.aidHex || "" }));
|
||||
}
|
||||
|
||||
function chooseTaskType(taskType: TaskType) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
taskType,
|
||||
environment: taskType === "public_ip" ? "cellular" : current.environment,
|
||||
}));
|
||||
}
|
||||
|
||||
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.taskType !== "public_ip" && !form.phone.trim()) return message.warning(t("请输入号码"));
|
||||
if (form.taskType === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容"));
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name: form.name,
|
||||
enabled: form.enabled,
|
||||
deviceId: form.deviceId,
|
||||
profileIccid: form.profileIccid,
|
||||
profileAid: form.profileAid,
|
||||
taskType: form.taskType,
|
||||
environment: form.environment,
|
||||
intervalDays: Number(form.intervalDays),
|
||||
startDate: form.startDate,
|
||||
runTime: form.runTime,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
retryCount: Number(form.retryCount),
|
||||
notify: form.notify,
|
||||
payload: {
|
||||
phone: form.phone,
|
||||
message: form.message,
|
||||
durationSeconds: Number(form.durationSeconds),
|
||||
},
|
||||
};
|
||||
await api(form.id ? `/automatic-tasks/${form.id}` : "/automatic-tasks", {
|
||||
method: form.id ? "PUT" : "POST",
|
||||
body,
|
||||
});
|
||||
message.success(t(form.id ? "自动任务已更新" : "自动任务已创建"));
|
||||
setOpen(false);
|
||||
await load();
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(task: AutomaticTask) {
|
||||
setBusy(task.id);
|
||||
try {
|
||||
await api(`/automatic-tasks/${task.id}`, { method: "PUT", body: { ...task, enabled: !task.enabled } });
|
||||
await load();
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
setBusy(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow(task: AutomaticTask) {
|
||||
setBusy(task.id);
|
||||
try {
|
||||
await api(`/automatic-tasks/${task.id}/run`, { method: "POST" });
|
||||
message.success(t("任务已加入设备队列"));
|
||||
await load();
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
setBusy(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(task: AutomaticTask) {
|
||||
if (!await confirmDialog(t("确定删除这个自动任务吗?"), t("确认删除"), { type: "warning", confirmText: t("删除"), cancelText: t("取消") })) return;
|
||||
setBusy(task.id);
|
||||
try {
|
||||
await api(`/automatic-tasks/${task.id}`, { method: "DELETE" });
|
||||
message.success(t("自动任务已删除"));
|
||||
await load();
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
setBusy(0);
|
||||
}
|
||||
}
|
||||
|
||||
const taskTypeLabel = (value: TaskType) => ({ sms: t("发送短信"), call: t("拨打电话并自动挂断"), public_ip: t("获取漫游公网 IP") })[value];
|
||||
const environmentLabel = (value: TaskEnvironment) => value === "vowifi" ? "VoWiFi" : t("基站直连");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader
|
||||
title={t("自动任务")}
|
||||
subtitle={t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")}
|
||||
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => edit()} disabled={!devices.length}>{t("添加任务")}</Button>}
|
||||
/>
|
||||
|
||||
<div className="ui-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1040px] text-left text-sm">
|
||||
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t("任务")}</th>
|
||||
<th className="px-4 py-3">{t("设备 / Profile")}</th>
|
||||
<th className="px-4 py-3">{t("类型")}</th>
|
||||
<th className="px-4 py-3">{t("执行环境")}</th>
|
||||
<th className="px-4 py-3">{t("周期")}</th>
|
||||
<th className="px-4 py-3">{t("下次执行")}</th>
|
||||
<th className="px-4 py-3">{t("上次结果")}</th>
|
||||
<th className="px-4 py-3 text-right">{t("操作")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
|
||||
{tasks.map((task) => (
|
||||
<tr key={task.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2"><Switch checked={task.enabled} loading={busy === task.id} size="small" onChange={() => void toggle(task)} /><span className="font-semibold">{task.name}</span></div>
|
||||
<div className="mt-1 text-xs text-gray-400">{task.notify ? t("完成后推送通知") : t("不推送通知")} · {t("失败重试 {count} 次").replace("{count}", String(task.retryCount))}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div>{deviceByID.get(task.deviceId)?.name || task.deviceId}</div>
|
||||
<div className="mt-1 font-mono text-xs text-gray-400">…{task.profileIccid.slice(-8)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{taskTypeLabel(task.taskType)}</td>
|
||||
<td className="px-4 py-3"><Tag type={task.environment === "vowifi" ? "primary" : "warning"}>{environmentLabel(task.environment)}</Tag></td>
|
||||
<td className="px-4 py-3">{t("每 {days} 天").replace("{days}", String(task.intervalDays))} · {task.runTime}</td>
|
||||
<td className="px-4 py-3 text-xs">{formatDateTime(task.nextRunAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{task.lastStatus ? <Tag type={task.lastStatus === "success" ? "success" : "danger"}>{task.lastStatus === "success" ? t("成功") : t("失败")}</Tag> : <span className="text-gray-400">--</span>}
|
||||
{task.lastError ? <div className="mt-1 max-w-[220px] truncate text-xs text-red-500" title={task.lastError}>{task.lastError}</div> : null}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="small" icon={<PlayRegular />} loading={busy === task.id} onClick={() => void runNow(task)}>{t("立即执行")}</Button>
|
||||
<Button size="small" icon={<EditRegular />} onClick={() => edit(task)}>{t("编辑")}</Button>
|
||||
<Button size="small" variant="danger" plain icon={<DeleteRegular />} loading={busy === task.id} onClick={() => void remove(task)}>{t("删除")}</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!loading && !tasks.length ? (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
|
||||
<SendClockRegular className="mb-3 text-4xl" />
|
||||
<div className="text-sm">{t("暂无自动任务")}</div>
|
||||
<div className="mt-1 text-xs">{t("添加任务后,系统会按设备排队并在执行前校验目标 Profile")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="ui-card mt-5 overflow-hidden">
|
||||
<div className="border-b border-gray-100 px-5 py-4 dark:border-white/10"><h3 className="font-bold">{t("最近执行记录")}</h3></div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[800px] text-left text-sm">
|
||||
<thead className="bg-gray-50/70 text-xs text-gray-500 dark:bg-white/[0.025]"><tr><th className="px-4 py-3">{t("任务")}</th><th className="px-4 py-3">{t("设备")}</th><th className="px-4 py-3">{t("状态")}</th><th className="px-4 py-3">{t("排队时间")}</th><th className="px-4 py-3">{t("尝试次数")}</th><th className="px-4 py-3">{t("结果")}</th></tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
|
||||
{runs.slice(0, 30).map((run) => (
|
||||
<tr key={run.id}><td className="px-4 py-3 font-medium">{taskByID.get(run.taskId)?.name || `#${run.taskId}`}</td><td className="px-4 py-3">{deviceByID.get(run.deviceId)?.name || run.deviceId}</td><td className="px-4 py-3"><Tag type={run.status === "success" ? "success" : run.status === "failed" ? "danger" : run.status === "running" ? "warning" : "info"}>{({ queued: t("排队中"), running: t("执行中"), success: t("成功"), failed: t("失败") })[run.status]}</Tag></td><td className="px-4 py-3 text-xs">{formatDateTime(run.scheduledAt)}</td><td className="px-4 py-3">{run.attempts}</td><td className="px-4 py-3"><div className={run.error ? "max-w-md text-red-500" : "max-w-md text-gray-600 dark:text-gray-300"}>{run.error || run.output || "--"}</div></td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!runs.length ? <div className="p-8 text-center text-sm text-gray-400">{t("暂无执行记录")}</div> : null}
|
||||
</div>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={form.id ? t("编辑自动任务") : t("添加自动任务")} width="max-w-3xl">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2"><label className={fieldLabel}>{t("任务名称")}</label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} /></div>
|
||||
<div><label className={fieldLabel}>{t("设备")}</label><Select value={form.deviceId} onChange={chooseDevice} options={devices.map((device) => ({ value: device.id, label: `${device.name || device.id} (${device.id})` }))} /></div>
|
||||
<div><label className={fieldLabel}>{t("eSIM Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 Profile")} options={profiles.map((profile) => ({ value: profile.iccid, label: profile.label }))} /></div>
|
||||
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={[{ value: "sms", label: t("发送短信") }, { value: "call", label: t("拨打电话并自动挂断") }, { value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }]} /></div>
|
||||
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip"} options={[{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }]} /></div>
|
||||
|
||||
{form.taskType !== "public_ip" ? <div><label className={fieldLabel}>{t("号码")}</label><Input value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" /></div> : null}
|
||||
{form.taskType === "call" ? <div><label className={fieldLabel}>{t("自动挂断")}</label><Input type="number" min={1} max={600} value={form.durationSeconds} suffix="s" onChange={(event) => setForm({ ...form, durationSeconds: Number(event.target.value) })} /></div> : null}
|
||||
{form.taskType === "sms" ? <div className="md:col-span-2"><label className={fieldLabel}>{t("短信内容")}</label><Textarea rows={4} value={form.message} onChange={(event) => setForm({ ...form, message: event.target.value })} /></div> : null}
|
||||
{form.taskType === "public_ip" ? <div className="md:col-span-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300">{t("该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。需要开启开发者模式。")}</div> : null}
|
||||
|
||||
<div><label className={fieldLabel}>{t("首次执行日期")}</label><Input type="date" value={form.startDate} onChange={(event) => setForm({ ...form, startDate: event.target.value })} /></div>
|
||||
<div><label className={fieldLabel}>{t("执行时间")}</label><Input type="time" value={form.runTime} onChange={(event) => setForm({ ...form, runTime: event.target.value })} /></div>
|
||||
<div><label className={fieldLabel}>{t("执行周期")}</label><Input type="number" min={1} max={365} value={form.intervalDays} suffix={t("天")} onChange={(event) => setForm({ ...form, intervalDays: Number(event.target.value) })} /></div>
|
||||
<div><label className={fieldLabel}>{t("任务失败重试次数")}</label><Select value={String(form.retryCount)} onChange={(value) => setForm({ ...form, retryCount: Number(value) })} options={Array.from({ length: 11 }, (_, count) => ({ value: String(count), label: t("{count} 次").replace("{count}", String(count)) }))} /></div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-white/10"><div><div className="text-sm font-semibold">{t("启用任务")}</div><div className="text-xs text-gray-400">{t("停用后不会进入执行队列")}</div></div><Switch checked={form.enabled} onChange={(enabled) => setForm({ ...form, enabled })} /></div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-white/10"><div><div className="text-sm font-semibold">{t("完成后推送通知")}</div><div className="text-xs text-gray-400">{t("发送到全部已配置并启用的通知渠道")}</div></div><Switch checked={form.notify} onChange={(notify) => setForm({ ...form, notify })} /></div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2"><Button onClick={() => setOpen(false)}>{t("取消")}</Button><Button variant="primary" loading={saving} onClick={() => void save()}>{t("保存")}</Button></div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export default function ExportProxyPage() {
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader
|
||||
title={t("导出代理")}
|
||||
subtitle={t("将模块漫游数据导出为主机 HTTP 或 SOCKS5 代理;仅在开发者模式下可用")}
|
||||
subtitle={t("将模块漫游数据导出为主机 HTTP 或 SOCKS5 代理")}
|
||||
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => edit()} disabled={!devices.length}>{t("添加代理")}</Button>}
|
||||
/>
|
||||
|
||||
@@ -213,10 +213,6 @@ export default function ExportProxyPage() {
|
||||
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="ui-panel-muted mt-4 p-4 text-xs leading-6 text-gray-500">
|
||||
{t("代理出口使用受保护的蜂窝路由和独立 DNS,不会把模块数据设为主机默认网络。关闭开发者模式会停止漫游数据并永久删除这里的全部配置。")}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AddRegular } from "@fluentui/react-icons";
|
||||
import { api, ApiError, apiMessage } from "../api";
|
||||
import type { DeviceListItem, DeviceProxyBinding, DevicesResponse, UpstreamProxy } from "../types";
|
||||
import { usePolling } from "../lib/usePolling";
|
||||
import { PageHeader, confirmDialog, message } from "../components/ui";
|
||||
import { Button, PageHeader, confirmDialog, message } from "../components/ui";
|
||||
import {
|
||||
emptyUpstreamForm,
|
||||
ipv6AddrError,
|
||||
@@ -233,13 +234,16 @@ export default function ProxyPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader title={t("代理管理")} subtitle={t("管理 VoWiFi 上游代理和设备绑定")} />
|
||||
<PageHeader
|
||||
title={t("代理管理")}
|
||||
subtitle={t("管理 VoWiFi 上游代理和设备绑定")}
|
||||
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
|
||||
/>
|
||||
<UpstreamSection
|
||||
rows={proxyRows}
|
||||
loading={upstreamLoading}
|
||||
error={upstreamError}
|
||||
onRetry={() => loadUpstream(false)}
|
||||
onNew={() => openUpstreamDialog()}
|
||||
onEdit={openUpstreamDialog}
|
||||
onDelete={removeUpstream}
|
||||
onOpenBindings={openBindingsDialog}
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function SettingsPage() {
|
||||
setDeveloperSettings(data);
|
||||
setDeviceLimit(data.deviceLimit);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error) || (lang === "zh" ? "开发者配置加载失败" : "Failed to load developer settings"));
|
||||
message.error(apiMessage(error) || (lang === "zh" ? "设备配额配置加载失败" : "Failed to load device quota settings"));
|
||||
} finally {
|
||||
setLoadingDeveloper(false);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface ApiErrorBody {
|
||||
export interface VoWiFiRuntime {
|
||||
deviceId: string;
|
||||
phase: string;
|
||||
enabled?: boolean;
|
||||
active?: boolean;
|
||||
dataplaneMode: string;
|
||||
iccid: string;
|
||||
imsi: string;
|
||||
|
||||
Reference in New Issue
Block a user