mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-22 15:53:43 +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:
@@ -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 },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user