Files
VoCat/web/src/pages/AutomaticTasksPage.tsx
T

556 lines
26 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AddRegular,
DeleteRegular,
EditRegular,
PlayRegular,
SendClockRegular,
} from "@fluentui/react-icons";
import { api, apiMessage } from "../api";
import type { DeviceListItem, DevicesResponse, SystemInfo } from "../types";
import type { EsimProfileGroup } from "../components/devices/types";
import {
Button,
Input,
Modal,
PageHeader,
Pagination,
Select,
Switch,
Tag,
Textarea,
confirmDialog,
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";
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();
}
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() {
const { t } = useI18n();
const [tasks, setTasks] = useState<AutomaticTask[]>([]);
const [runs, setRuns] = useState<AutomaticTaskRun[]>([]);
const [runsTotal, setRunsTotal] = useState(0);
const [runsPage, setRunsPage] = useState(1);
const [runsPageSize, setRunsPageSize] = useState(20);
const [devices, setDevices] = useState<DeviceListItem[]>([]);
const [advancedTasksAvailable, setAdvancedTasksAvailable] = useState(false);
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);
// Refs mirror the runs page/pageSize so the 5s poll reloads the page the user
// 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);
try {
const [taskData, deviceData, systemInfo] = await Promise.all([
api<{ tasks?: AutomaticTask[] }>("/automatic-tasks"),
api<DevicesResponse>("/devices"),
api<SystemInfo>("/system/info"),
]);
setTasks(taskData.tasks || []);
setDevices(deviceData.devices || []);
setAdvancedTasksAvailable(!!systemInfo.developer);
} catch (error) {
message.error(apiMessage(error));
} finally {
if (initial) setLoading(false);
}
}, []);
const fetchRuns = useCallback(async (page: number, pageSize: number) => {
const request = (target: number) =>
api<{ runs?: AutomaticTaskRun[]; total?: number }>(
`/automatic-tasks/runs?limit=${pageSize}&offset=${(target - 1) * pageSize}`,
);
try {
let data = await request(page);
const total = data.total ?? 0;
const pages = Math.max(1, Math.ceil(total / pageSize));
// Clamp when the current page fell past the end (a larger page size, or
// runs removed with a deleted task) instead of showing an empty slice.
if (page > pages) {
data = await request(pages);
runsPageRef.current = pages;
setRunsPage(pages);
}
setRuns(data.runs || []);
setRunsTotal(total);
} catch (error) {
message.error(apiMessage(error));
}
}, []);
const reloadRuns = useCallback(
() => fetchRuns(runsPageRef.current, runsPageSizeRef.current),
[fetchRuns],
);
useEffect(() => {
void load(true);
void reloadRuns();
const timer = window.setInterval(() => {
void load();
void reloadRuns();
}, 5000);
return () => window.clearInterval(timer);
}, [load, reloadRuns]);
useEffect(() => () => profileRequestGuardRef.current.invalidate(), []);
function changeRunsPage(page: number) {
runsPageRef.current = page;
setRunsPage(page);
void fetchRuns(page, runsPageSizeRef.current);
}
function changeRunsPageSize(pageSize: number) {
runsPageSizeRef.current = pageSize;
setRunsPageSize(pageSize);
runsPageRef.current = 1;
setRunsPage(1);
void fetchRuns(1, pageSize);
}
const loadProfiles = useCallback(async (deviceId: string, keepICCID = "", currentICCID = "") => {
if (!deviceId) {
profileRequestGuardRef.current.invalidate();
setProfiles([]);
setProfileLoading(false);
return;
}
const requestID = profileRequestGuardRef.current.begin();
setProfiles([]);
setProfileLoading(true);
let groups: EsimProfileGroup[] = [];
let inventoryError: unknown;
try {
const data = await api<{ profiles?: EsimProfileGroup[] }>(`/devices/${encodeURIComponent(deviceId)}/esim`);
groups = data.profiles || [];
} catch (error) {
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,
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);
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")) {
next = { ...next, taskType: "sms", environment: "vowifi" };
}
setForm(next);
setOpen(true);
void loadProfiles(deviceId, next.profileIccid, currentDeviceICCID(selectedDevice));
}
function chooseDevice(deviceId: string) {
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, "", currentDeviceICCID(selectedDevice));
}
function chooseProfile(iccid: string) {
const selected = profiles.find((profile) => profile.iccid === iccid);
setForm((current) => ({ ...current, profileIccid: iccid, profileAid: selected?.aidHex || "" }));
}
function chooseTaskType(taskType: TaskType) {
if ((!advancedTasksAvailable || deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader") && taskType === "public_ip") return;
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("请选择 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短信和通话任务"));
}
if (!advancedTasksAvailable && (form.environment !== "vowifi" || form.taskType === "public_ip")) return;
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 ? "自动任务已更新" : "自动任务已创建"));
closeEditor();
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();
changeRunsPage(1);
} 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();
await reloadRuns();
} 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("基站直连");
const selectedTaskDeviceIsReader = deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader";
const taskTypeOptions = [
{ value: "sms", label: t("发送短信") },
{ value: "call", label: t("拨打电话并自动挂断") },
...(advancedTasksAvailable && !selectedTaskDeviceIsReader ? [{ value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }] : []),
];
const environmentOptions = selectedTaskDeviceIsReader || !advancedTasksAvailable
? [{ value: "vowifi", label: "VoWiFi" }]
: [{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }];
return (
<div className="mx-auto max-w-7xl">
<PageHeader
title={t("自动任务")}
subtitle={advancedTasksAvailable
? t("按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")
: t("按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信或通话任务")}
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("设备 / SIM / 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("添加任务后,系统会按设备排队并在执行前校验目标 SIM / 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.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}
{runsTotal > 0 ? (
<div className="border-t border-gray-100 px-5 py-3 dark:border-white/10">
<Pagination
page={runsPage}
pageSize={runsPageSize}
total={runsTotal}
onPageChange={changeRunsPage}
onPageSizeChange={changeRunsPageSize}
/>
</div>
) : null}
</div>
<Modal open={open} onClose={closeEditor} 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("SIM / Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 SIM / 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={taskTypeOptions} /></div>
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip" || selectedTaskDeviceIsReader} options={environmentOptions} /></div>
{selectedTaskDeviceIsReader ? <div className="md:col-span-2 rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-300">{t("USB SIM读卡器仅支持VoWiFi短信和通话任务")}</div> : null}
{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}
{advancedTasksAvailable && 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={closeEditor}>{t("取消")}</Button><Button variant="primary" loading={saving} onClick={() => void save()}>{t("保存")}</Button></div>
</Modal>
</div>
);
}