mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 06:43:42 +08:00
556 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|