feat: update schema version and refactor proxy binding logic

- Increment schema version from 11 to 12.
- Modify ProxyResolver to use ICCID for device proxy binding resolution.
- Update tests to reflect changes in proxy binding logic using ICCID.
- Enhance DeviceBindingsDialog to manage eSIM profile bindings instead of device bindings.
- Update UI components and translations to reflect the new profile binding terminology.
- Implement pagination for automatic task runs in AutomaticTasksPage.
- Create a new Pagination component for better navigation in lists.
This commit is contained in:
MengMengCode
2026-08-11 01:23:04 +08:00
parent 928ba7746e
commit a09f9af646
23 changed files with 845 additions and 282 deletions
+139 -54
View File
@@ -1,6 +1,8 @@
import { DesktopRegular, LinkRegular } from "@fluentui/react-icons";
import type { DeviceListItem, DeviceProxyBinding, UpstreamProxy } from "../../types";
import { Button, EmptyState, Modal, Tag } from "../ui";
import { AddRegular, DeleteRegular } from "@fluentui/react-icons";
import { useEffect, useMemo, useState } from "react";
import { api, apiMessage } from "../../api";
import type { DeviceListItem, DeviceProxyBinding, EsimOverview, ProfileProxyCandidate, UpstreamProxy } from "../../types";
import { Button, EmptyState, Modal, Tag, message } from "../ui";
import { useI18n } from "../../lib/i18n";
export interface DeviceBindingsDialogProps {
@@ -9,69 +11,152 @@ export interface DeviceBindingsDialogProps {
proxies: UpstreamProxy[];
devices: DeviceListItem[];
bindings: DeviceProxyBinding[];
busyDevice: string;
onBind: (deviceId: string) => void;
onUnbind: (deviceId: string) => void;
busy: boolean;
onAdd: (profiles: ProfileProxyCandidate[]) => void;
onDelete: (iccids: string[]) => void;
onClose: () => void;
}
function profileLabel(profile: { name?: string; serviceProviderName?: string; iccid: string }) {
return String(profile.name || profile.serviceProviderName || profile.iccid).trim();
}
export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
const { t } = useI18n();
const { open, proxy, proxies, devices, bindings, busyDevice, onBind, onUnbind, onClose } = props;
const { open, proxy, proxies, devices, bindings, busy, onAdd, onDelete, onClose } = props;
const [adding, setAdding] = useState(false);
const [loadingProfiles, setLoadingProfiles] = useState(false);
const [candidates, setCandidates] = useState<ProfileProxyCandidate[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const proxyName = proxy?.name || proxy?.id || "";
const bindingByDevice = new Map(bindings.map((item) => [item.deviceId, item]));
const proxyNameById = new Map(proxies.map((item) => [item.id, item.name || item.id]));
const deviceKey = devices.map((device) => device.id).sort().join("|");
const current = useMemo(
() => bindings.filter((item) => item.upstreamProxyId === proxy?.id),
[bindings, proxy?.id],
);
const bindingByICCID = useMemo(() => new Map(bindings.map((item) => [item.iccid, item])), [bindings]);
const proxyNameById = useMemo(() => new Map(proxies.map((item) => [item.id, item.name || item.id])), [proxies]);
useEffect(() => {
if (!open) {
setAdding(false);
setSelected([]);
setCandidates([]);
}
}, [open]);
useEffect(() => {
if (!adding || !open) return;
let active = true;
setLoadingProfiles(true);
Promise.allSettled(devices.map(async (device) => {
const data = await api<EsimOverview>(`/devices/${encodeURIComponent(device.id)}/esim`);
return (data.profiles || []).flatMap((group) => (group.profiles || []).map((profile) => ({
deviceId: device.id,
iccid: String(profile.iccid || "").trim(),
profileName: profileLabel(profile),
stateText: profile.stateText,
}))).filter((profile) => profile.iccid);
})).then((results) => {
if (!active) return;
const unique = new Map<string, ProfileProxyCandidate>();
for (const result of results) {
if (result.status !== "fulfilled") continue;
for (const profile of result.value) if (!unique.has(profile.iccid)) unique.set(profile.iccid, profile);
}
setCandidates(Array.from(unique.values()).sort((a, b) => a.deviceId.localeCompare(b.deviceId) || a.profileName.localeCompare(b.profileName)));
}).catch((error) => {
if (active) message.error(apiMessage(error) || t("读取 eSIM Profile 失败"));
}).finally(() => {
if (active) setLoadingProfiles(false);
});
return () => { active = false; };
}, [adding, open, deviceKey, t]);
useEffect(() => {
if (!adding || selected.length === 0) return;
if (selected.every((iccid) => bindingByICCID.get(iccid)?.upstreamProxyId === proxy?.id)) {
setAdding(false);
setSelected([]);
}
}, [adding, selected, bindingByICCID, proxy?.id]);
useEffect(() => {
if (adding) return;
const available = new Set(current.map((item) => item.iccid));
setSelected((items) => items.filter((iccid) => available.has(iccid)));
}, [adding, current]);
const rows = adding ? candidates : current;
const selectable = rows.filter((row) => adding ? !bindingByICCID.has(row.iccid) : true).map((row) => row.iccid);
const allSelected = selectable.length > 0 && selectable.every((iccid) => selected.includes(iccid));
const toggle = (iccid: string) => setSelected((values) => values.includes(iccid) ? values.filter((item) => item !== iccid) : [...values, iccid]);
const toggleAll = () => setSelected(allSelected ? [] : selectable);
return (
<Modal open={open} onClose={onClose} title={`${t("设备绑定")}${proxyName}`} width="max-w-2xl">
<Modal open={open} onClose={onClose} title={`${adding ? t("添加 Profile 绑定") : t("Profile 绑定")}${proxyName}`} width="max-w-5xl">
<div className="space-y-4 pb-2">
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
{t("绑定后,该设备的 VoWiFi 建链和通信都会使用此 SOCKS5 代理;解绑后恢复直连。配置变更会立即尝试重连 VoWiFi。")}
{t("VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。")}
</div>
{devices.length === 0 ? (
<EmptyState title={t("暂无可绑定设备")} subtitle={t("请先在设备管理中添加设备。")}/>
) : (
<div className="space-y-2">
{devices.map((device) => {
const binding = bindingByDevice.get(device.id);
const boundHere = binding?.upstreamProxyId === proxy?.id;
const boundElsewhere = !!binding && !boundHere;
return (
<div key={device.id} className="ui-panel-muted flex items-center justify-between gap-3 rounded-lg p-3">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white text-sky-600 shadow-sm dark:bg-white/10 dark:text-sky-300">
<DesktopRegular className="text-[18px]" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-semibold text-gray-900 dark:text-white">{device.name || device.id}</span>
<span className="font-mono text-xs text-gray-400">{device.id}</span>
{boundHere ? <Tag type="success">{t("已绑定")}</Tag> : null}
{!device.vowifiEnabled ? <Tag type="info">{t("VoWiFi 未启用")}</Tag> : null}
</div>
<div className="mt-0.5 text-xs text-gray-500">
{boundHere
? t("当前通过此代理通信")
: boundElsewhere
? `${t("当前绑定")}: ${proxyNameById.get(binding.upstreamProxyId) || binding.upstreamProxyId}`
: t("当前直连")}
</div>
</div>
</div>
{boundHere ? (
<Button size="small" variant="danger" loading={busyDevice === device.id} onClick={() => onUnbind(device.id)}>
{t("解绑")}
</Button>
) : (
<Button size="small" variant="primary" icon={<LinkRegular />} loading={busyDevice === device.id} onClick={() => onBind(device.id)}>
{boundElsewhere ? t("切换绑定") : t("绑定设备")}
</Button>
)}
</div>
);
})}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-gray-500">{adding ? t("从设备已安装的 eSIM Profile 中选择") : `${current.length} ${t("个 Profile")}`}</div>
<div className="flex gap-2">
{adding ? (
<Button size="small" onClick={() => { setAdding(false); setSelected([]); }}>{t("返回绑定列表")}</Button>
) : null}
{adding ? (
<Button
size="small"
variant="primary"
icon={<AddRegular />}
loading={busy}
disabled={selected.length === 0 || loadingProfiles}
onClick={() => onAdd(candidates.filter((item) => selected.includes(item.iccid)))}
>{t("添加所选")}</Button>
) : (
<>
<Button size="small" variant="danger" plain icon={<DeleteRegular />} loading={busy} disabled={selected.length === 0} onClick={() => onDelete(selected)}>{t("删除所选")}</Button>
<Button size="small" variant="primary" icon={<AddRegular />} onClick={() => { setAdding(true); setSelected([]); }}>{t("添加")}</Button>
</>
)}
</div>
)}
</div>
<div className="overflow-x-auto rounded-xl border border-gray-100 dark:border-white/10">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="bg-gray-50/80 text-xs uppercase tracking-wide text-gray-500 dark:bg-white/[0.025]">
<tr>
<th className="w-12 px-4 py-3"><input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={selectable.length === 0 || busy} aria-label={t("全选")} /></th>
<th className="px-4 py-3">{t("设备 ID")}</th>
<th className="px-4 py-3">ICCID</th>
<th className="px-4 py-3">{t("Profile 名称")}</th>
{adding ? <th className="px-4 py-3">{t("状态")}</th> : null}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{rows.map((row) => {
const existing = bindingByICCID.get(row.iccid);
const unavailable = adding && !!existing;
return (
<tr key={`${row.deviceId}:${row.iccid}`} className={unavailable ? "opacity-60" : "hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]"}>
<td className="px-4 py-3"><input type="checkbox" checked={selected.includes(row.iccid)} onChange={() => toggle(row.iccid)} disabled={unavailable || busy} aria-label={row.iccid} /></td>
<td className="px-4 py-3 font-mono text-xs">{row.deviceId}</td>
<td className="px-4 py-3 font-mono text-xs">{row.iccid}</td>
<td className="px-4 py-3 font-medium">{row.profileName || row.iccid}</td>
{adding ? (
<td className="px-4 py-3">
{existing ? <Tag type={existing.upstreamProxyId === proxy?.id ? "success" : "info"}>{existing.upstreamProxyId === proxy?.id ? t("已绑定此代理") : `${t("已绑定")}: ${proxyNameById.get(existing.upstreamProxyId) || existing.upstreamProxyId}`}</Tag> : <Tag type="primary">{("stateText" in row && row.stateText) || t("可绑定")}</Tag>}
</td>
) : null}
</tr>
);
})}
</tbody>
</table>
{loadingProfiles ? <div className="px-6 py-12 text-center text-sm text-gray-400">{t("读取 Profile 中...")}</div> : null}
{!loadingProfiles && rows.length === 0 ? <EmptyState title={adding ? t("没有可显示的 eSIM Profile") : t("尚未绑定 Profile")} subtitle={adding ? t("请确认设备在线且支持 eSIM Profile 列表读取。") : t("点击添加,从设备 Profile 列表中选择。")}/>: null}
</div>
</div>
</Modal>
);
+1 -1
View File
@@ -108,7 +108,7 @@ export function UpstreamDialog({ open, editing, form, testing, probe, onPatch, o
</Field>
<ToggleRow
title={t("启用代理")}
subtitle={t("禁用后,已绑定设备的 VoWiFi 将停止使用该线路,不会泄漏到直连")}
subtitle={t("禁用后,已绑定 Profile 的 VoWiFi 将停止使用该线路,不会泄漏到直连")}
checked={form.enabled}
onChange={(v) => onPatch({ enabled: v })}
/>
+4 -4
View File
@@ -38,7 +38,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
<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("Profile 绑定")}</th>
<th className="px-4 py-3 text-right">{t("操作")}</th>
</tr>
</thead>
@@ -53,12 +53,12 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
<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>
<span>{row.bindingCount} {t("个 Profile")}</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={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("Profile 绑定")}</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>
@@ -72,7 +72,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
<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 className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定需要使用它的 eSIM Profile;未绑定 Profile 默认直连。")}</div>
</div>
) : null}
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
+113
View File
@@ -0,0 +1,113 @@
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
import { cx } from "../../lib/utils";
import { useI18n } from "../../lib/i18n";
import { Button } from "./Button";
import { Select } from "./Select";
export interface PaginationProps {
/** Current page, 1-based. */
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
pageSizeOptions?: number[];
className?: string;
}
type PageItem = number | "ellipsis";
// Build the page-number strip: always show the first and last page, the pages
// around the current one, and collapse longer gaps into a single ellipsis
// (filling a gap of exactly one page with that page's number).
function pageWindow(current: number, pages: number): PageItem[] {
if (pages <= 7) {
return Array.from({ length: pages }, (_, index) => index + 1);
}
const left = Math.max(2, current - 1);
const right = Math.min(pages - 1, current + 1);
const kept: number[] = [];
for (let i = 1; i <= pages; i++) {
if (i === 1 || i === pages || (i >= left && i <= right)) {
kept.push(i);
}
}
const items: PageItem[] = [];
let previous = 0;
for (const page of kept) {
if (previous !== 0) {
if (page - previous === 2) items.push(previous + 1);
else if (page - previous > 2) items.push("ellipsis");
}
items.push(page);
previous = page;
}
return items;
}
export function Pagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
className,
}: PaginationProps) {
const { t } = useI18n();
const pages = Math.max(1, Math.ceil(total / pageSize));
const current = Math.min(Math.max(1, page), pages);
const items = pageWindow(current, pages);
if (total <= 0) return null;
return (
<div className={cx("flex flex-wrap items-center gap-x-3 gap-y-2", className)}>
<span className="text-xs text-gray-400">{t("共 {total} 条").replace("{total}", String(total))}</span>
<div className="flex items-center gap-1 sm:ml-auto">
{onPageSizeChange ? (
<Select
value={String(pageSize)}
onChange={(value) => onPageSizeChange(Number(value))}
options={pageSizeOptions.map((count) => ({
value: String(count),
label: t("{count} 条/页").replace("{count}", String(count)),
}))}
className="mr-1 w-24"
/>
) : null}
<Button
size="small"
icon={<ChevronLeftRegular />}
disabled={current <= 1}
onClick={() => onPageChange(current - 1)}
aria-label={t("上一页")}
/>
{items.map((item, index) =>
item === "ellipsis" ? (
<span key={`ellipsis-${index}`} className="px-1 text-xs text-gray-400">
</span>
) : (
<Button
key={item}
size="small"
variant={item === current ? "primary" : "text"}
onClick={() => onPageChange(item)}
aria-current={item === current ? "page" : undefined}
>
{item}
</Button>
),
)}
<Button
size="small"
icon={<ChevronRightRegular />}
disabled={current >= pages}
onClick={() => onPageChange(current + 1)}
aria-label={t("下一页")}
/>
</div>
</div>
);
}
+2
View File
@@ -8,6 +8,8 @@ export { Switch } from "./Switch";
export { Input, Textarea } from "./Input";
export { Select } from "./Select";
export type { SelectOption } from "./Select";
export { Pagination } from "./Pagination";
export type { PaginationProps } from "./Pagination";
export { Tabs } from "./Tabs";
export type { TabItem } from "./Tabs";
export { Tag } from "./Tag";
+34
View File
@@ -31,6 +31,36 @@ export const EN_DICT: Record<string, string> = {
"插件可能已被禁用、卸载或没有注册此页面。": "The plugin may be disabled, uninstalled, or may not register this page.",
// Device-bound VoWiFi upstream routing.
"设备绑定": "Device Bindings",
"Profile 绑定": "Profile Bindings",
"添加 Profile 绑定": "Add Profile Bindings",
"VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。":
"VoWiFi selects its proxy by the active ICCID. An ICCID can use only one proxy, while one proxy can serve profiles across multiple devices.",
"从设备已安装的 eSIM Profile 中选择": "Select from eSIM profiles installed on the devices",
"个 Profile": "profiles",
"返回绑定列表": "Back to bindings",
"添加所选": "Add selected",
: "Add",
: "Select all",
"删除所选": "Delete selected",
"设备 ID": "Device ID",
"Profile 名称": "Profile Name",
"已绑定此代理": "Bound to this proxy",
"可绑定": "Available",
"没有可显示的 eSIM Profile": "No eSIM profiles to display",
"尚未绑定 Profile": "No profiles bound",
"请确认设备在线且支持 eSIM Profile 列表读取。": "Make sure the device is online and supports eSIM profile listing.",
"点击添加,从设备 Profile 列表中选择。": "Click Add and select from the device profile list.",
"读取 eSIM Profile 失败": "Failed to read eSIM profiles",
"Profile 已绑定": "Profiles bound",
"所选 ICCID 已绑定其他代理,请先删除原绑定": "A selected ICCID is bound to another proxy; delete the existing binding first",
"所选 Profile 绑定已删除": "Selected profile bindings deleted",
"删除绑定失败": "Failed to delete bindings",
"绑定到该代理的 Profile 将自动解绑并恢复直连。": "Profiles bound to this proxy will be unbound and return to direct routing.",
"管理 VoWiFi 上游代理和 eSIM Profile 绑定": "Manage VoWiFi upstream proxies and eSIM profile bindings",
"禁用后,已绑定 Profile 的 VoWiFi 将停止使用该线路,不会泄漏到直连":
"When disabled, bound profiles stop using this VoWiFi route and do not leak traffic to a direct connection.",
"点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定需要使用它的 eSIM Profile;未绑定 Profile 默认直连。":
"Create a SOCKS5 upstream proxy, then bind eSIM profiles by ICCID. Unbound profiles use a direct connection.",
"绑定后,该设备的 VoWiFi 建链和通信都会使用此 SOCKS5 代理;解绑后恢复直连。配置变更会立即尝试重连 VoWiFi。":
"Once bound, this device uses the SOCKS5 proxy for VoWiFi setup and communications. Unbinding restores direct routing. Route changes trigger an immediate VoWiFi reconnect.",
"暂无可绑定设备": "No devices available",
@@ -99,6 +129,10 @@ export const EN_DICT: Record<string, string> = {
: "Queued",
: "Running",
: "No run history",
"共 {total} 条": "{total} total",
"{count} 条/页": "{count} / page",
: "Previous",
: "Next",
: "Edit Automatic Task",
: "Add Automatic Task",
: "Task Name",
+72 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AddRegular,
DeleteRegular,
@@ -14,6 +14,7 @@ import {
Input,
Modal,
PageHeader,
Pagination,
Select,
Switch,
Tag,
@@ -135,6 +136,9 @@ 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 [profiles, setProfiles] = useState<ProfileOption[]>([]);
const [loading, setLoading] = useState(true);
@@ -143,16 +147,19 @@ export default function AutomaticTasksPage() {
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 load = useCallback(async (initial = false) => {
if (initial) setLoading(true);
try {
const [taskData, deviceData] = await Promise.all([
api<{ tasks?: AutomaticTask[]; runs?: AutomaticTaskRun[] }>("/automatic-tasks"),
api<{ tasks?: AutomaticTask[] }>("/automatic-tasks"),
api<DevicesResponse>("/devices"),
]);
setTasks(taskData.tasks || []);
setRuns(taskData.runs || []);
setDevices(deviceData.devices || []);
} catch (error) {
message.error(apiMessage(error));
@@ -161,11 +168,57 @@ export default function AutomaticTasksPage() {
}
}, []);
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);
const timer = window.setInterval(() => void load(), 5000);
void reloadRuns();
const timer = window.setInterval(() => {
void load();
void reloadRuns();
}, 5000);
return () => window.clearInterval(timer);
}, [load]);
}, [load, reloadRuns]);
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 = "") => {
setProfiles([]);
@@ -299,6 +352,7 @@ export default function AutomaticTasksPage() {
await api(`/automatic-tasks/${task.id}/run`, { method: "POST" });
message.success(t("任务已加入设备队列"));
await load();
changeRunsPage(1);
} catch (error) {
message.error(apiMessage(error));
} finally {
@@ -313,6 +367,7 @@ export default function AutomaticTasksPage() {
await api(`/automatic-tasks/${task.id}`, { method: "DELETE" });
message.success(t("自动任务已删除"));
await load();
await reloadRuns();
} catch (error) {
message.error(apiMessage(error));
} finally {
@@ -393,13 +448,24 @@ export default function AutomaticTasksPage() {
<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) => (
{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={() => setOpen(false)} title={form.id ? t("编辑自动任务") : t("添加自动任务")} width="max-w-3xl">
+30 -25
View File
@@ -1,7 +1,7 @@
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 type { DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types";
import { usePolling } from "../lib/usePolling";
import { Button, PageHeader, confirmDialog, message } from "../components/ui";
import {
@@ -38,7 +38,7 @@ export default function ProxyPage() {
const [upstreamProbe, setUpstreamProbe] = useState<UpstreamProbeResult | null>(null);
const [bindingsDialogOpen, setBindingsDialogOpen] = useState(false);
const [bindingsProxy, setBindingsProxy] = useState<UpstreamProxy | null>(null);
const [busyDevice, setBusyDevice] = useState("");
const [bindingBusy, setBindingBusy] = useState(false);
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
const proxyRows = useMemo<UpstreamRow[]>(
@@ -55,7 +55,7 @@ export default function ProxyPage() {
try {
const [proxyList, bindingList, deviceList] = await Promise.all([
api<UpstreamProxy[]>("/upstream-proxies"),
api<DeviceProxyBinding[]>("/upstream-proxy-device-bindings"),
api<DeviceProxyBinding[]>("/upstream-proxy-profile-bindings"),
api<DevicesResponse>("/devices"),
]);
setProxies(proxyList || []);
@@ -164,7 +164,7 @@ export default function ProxyPage() {
<>
{tf("确定删除上游代理“{name}”?", { name: proxy.name || proxy.id })}
<br />
{t("绑定到该代理的设备将自动解绑并恢复直连。")}
{t("绑定到该代理的 Profile 将自动解绑并恢复直连。")}
</>,
t("确认删除"),
{ confirmText: t("删除"), cancelText: t("取消"), type: "warning" },
@@ -195,48 +195,53 @@ export default function ProxyPage() {
}
}, [t]);
const bindDevice = useCallback(async (deviceId: string) => {
if (!bindingsProxy) return;
setBusyDevice(deviceId);
const addProfileBindings = useCallback(async (profiles: ProfileProxyCandidate[]) => {
if (!bindingsProxy || profiles.length === 0) return;
setBindingBusy(true);
try {
const result = await api<BindingMutationResult>(`/upstream-proxy-device-bindings/${encodeURIComponent(deviceId)}`, {
method: "PUT",
body: { upstreamProxyId: bindingsProxy.id },
const result = await api<BindingMutationResult>("/upstream-proxy-profile-bindings", {
method: "POST",
body: {
upstreamProxyId: bindingsProxy.id,
bindings: profiles.map(({ deviceId, iccid, profileName }) => ({ deviceId, iccid, profileName })),
},
});
showRouteChangeResult(result, t("设备已绑定"));
showRouteChangeResult(result, t("Profile 已绑定"));
await loadUpstream(false);
} catch (error) {
const code = error instanceof ApiError ? error.code : "";
if (code === "device_already_bound") {
message.error(t("该设备已绑定其他代理,请先解绑后再切换"));
if (code === "profile_already_bound") {
message.error(t("所选 ICCID 已绑定其他代理,请先删除原绑定"));
} else {
message.error(apiMessage(error) || t("绑定失败"));
}
} finally {
setBusyDevice("");
setBindingBusy(false);
}
}, [bindingsProxy, loadUpstream, showRouteChangeResult, t]);
const unbindDevice = useCallback(async (deviceId: string) => {
setBusyDevice(deviceId);
const deleteProfileBindings = useCallback(async (iccids: string[]) => {
if (!bindingsProxy || iccids.length === 0) return;
setBindingBusy(true);
try {
const result = await api<BindingMutationResult>(`/upstream-proxy-device-bindings/${encodeURIComponent(deviceId)}`, {
const result = await api<BindingMutationResult>("/upstream-proxy-profile-bindings", {
method: "DELETE",
body: { upstreamProxyId: bindingsProxy.id, iccids },
});
showRouteChangeResult(result, t("设备已解绑并恢复直连"));
showRouteChangeResult(result, t("所选 Profile 绑定已删除"));
await loadUpstream(false);
} catch (error) {
message.error(apiMessage(error) || t("解绑失败"));
message.error(apiMessage(error) || t("删除绑定失败"));
} finally {
setBusyDevice("");
setBindingBusy(false);
}
}, [loadUpstream, showRouteChangeResult, t]);
}, [bindingsProxy, loadUpstream, showRouteChangeResult, t]);
return (
<div className="mx-auto max-w-7xl">
<PageHeader
title={t("代理管理")}
subtitle={t("管理 VoWiFi 上游代理和设备绑定")}
subtitle={t("管理 VoWiFi 上游代理和 eSIM Profile 绑定")}
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
/>
<UpstreamSection
@@ -283,9 +288,9 @@ export default function ProxyPage() {
proxies={proxies}
devices={devices}
bindings={bindings}
busyDevice={busyDevice}
onBind={(deviceId) => void bindDevice(deviceId)}
onUnbind={(deviceId) => void unbindDevice(deviceId)}
busy={bindingBusy}
onAdd={(profiles) => void addProfileBindings(profiles)}
onDelete={(iccids) => void deleteProfileBindings(iccids)}
onClose={() => setBindingsDialogOpen(false)}
/>
</div>
+9
View File
@@ -316,11 +316,20 @@ export interface CountryRule {
export interface DeviceProxyBinding {
deviceId: string;
iccid: string;
profileName: string;
upstreamProxyId: string;
reconnectRequested?: boolean;
reconnectError?: string;
}
export interface ProfileProxyCandidate {
deviceId: string;
iccid: string;
profileName: string;
stateText?: string;
}
export interface LogEntry {
time: string;
level: "debug" | "info" | "warn" | "error" | string;