mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-19 14:23:42 +08:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user