mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-22 15:53:43 +08:00
Initial
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { DesktopRegular, LinkRegular } from "@fluentui/react-icons";
|
||||
import type { DeviceListItem, DeviceProxyBinding, UpstreamProxy } from "../../types";
|
||||
import { Button, EmptyState, Modal, Tag } from "../ui";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export interface DeviceBindingsDialogProps {
|
||||
open: boolean;
|
||||
proxy: UpstreamProxy | null;
|
||||
proxies: UpstreamProxy[];
|
||||
devices: DeviceListItem[];
|
||||
bindings: DeviceProxyBinding[];
|
||||
busyDevice: string;
|
||||
onBind: (deviceId: string) => void;
|
||||
onUnbind: (deviceId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { open, proxy, proxies, devices, bindings, busyDevice, onBind, onUnbind, onClose } = props;
|
||||
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]));
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={`${t("设备绑定")} — ${proxyName}`} width="max-w-2xl">
|
||||
<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。")}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Button, Input, Modal } from "../ui";
|
||||
import { Field, SectionHeader, ToggleRow } from "./formUi";
|
||||
import { ipv6Hint, type UpstreamForm, type UpstreamProbeResult } from "./shared";
|
||||
import { tl, useI18n } from "../../lib/i18n";
|
||||
|
||||
export interface UpstreamDialogProps {
|
||||
open: boolean;
|
||||
editing: boolean;
|
||||
form: UpstreamForm;
|
||||
testing: boolean;
|
||||
probe: UpstreamProbeResult | null;
|
||||
onPatch: (patch: Partial<UpstreamForm>) => void;
|
||||
onTest: () => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
type ProbeState = "ok" | "fail" | "pending";
|
||||
|
||||
function ProbeRow({ state, label, detail }: { state: ProbeState; label: string; detail?: string }) {
|
||||
const dot = state === "ok" ? "bg-green-500" : state === "fail" ? "bg-red-500" : "bg-gray-300 dark:bg-gray-600";
|
||||
const text =
|
||||
state === "ok" ? "text-green-600 dark:text-green-400" : state === "fail" ? "text-red-600 dark:text-red-400" : "text-gray-400";
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} />
|
||||
<span className={`shrink-0 font-medium ${text}`}>{label}</span>
|
||||
{detail ? <span className="truncate text-gray-400">{detail}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function authMethodLabel(method?: string): string {
|
||||
if (method === "none") return tl("免鉴权");
|
||||
if (method === "username_password") return tl("用户名密码");
|
||||
return method || "";
|
||||
}
|
||||
|
||||
function ProbeResultPanel({ probe }: { probe: UpstreamProbeResult }) {
|
||||
const { t } = useI18n();
|
||||
const reachable = !!probe.reachable;
|
||||
const handshakeOk = !!probe.handshakeOk;
|
||||
const udpOk = !!probe.udpAssociateOk;
|
||||
const handshakeState: ProbeState = !reachable ? "pending" : handshakeOk ? "ok" : "fail";
|
||||
const udpState: ProbeState = !handshakeOk ? "pending" : udpOk ? "ok" : "fail";
|
||||
return (
|
||||
<div className="ui-panel-muted space-y-2 rounded-lg p-3">
|
||||
<ProbeRow state={reachable ? "ok" : "fail"} label={t("TCP 连接")} detail={reachable ? t("可连通") : t("无法连接")} />
|
||||
<ProbeRow state={handshakeState} label={t("SOCKS5 握手")} detail={handshakeOk ? authMethodLabel(probe.authMethod) : undefined} />
|
||||
<ProbeRow
|
||||
state={udpState}
|
||||
label={t("UDP Associate(VoWiFi 依赖)")}
|
||||
detail={udpState === "pending" ? undefined : udpOk ? t("支持") : t("不支持")}
|
||||
/>
|
||||
{probe.relayAddr ? (
|
||||
<div className="text-[11px] text-gray-400">
|
||||
{t("UDP 中继地址:")}<span className="font-mono">{probe.relayAddr}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{probe.hint ? <div className="text-[11px] text-gray-500 dark:text-gray-400">{probe.hint}</div> : null}
|
||||
{probe.error ? <div className="break-all text-[11px] text-red-500">{probe.error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpstreamDialog({ open, editing, form, testing, probe, onPatch, onTest, onClose, onSubmit }: UpstreamDialogProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={editing ? t("编辑前置代理") : t("新增前置代理")}
|
||||
width="max-w-lg"
|
||||
footer={
|
||||
<>
|
||||
<Button className="mr-auto" onClick={onTest} loading={testing} disabled={testing || !form.addr.trim()}>
|
||||
{t("检测连通性")}
|
||||
</Button>
|
||||
<Button onClick={onClose}>{t("取消")}</Button>
|
||||
<Button variant="primary" onClick={onSubmit} loading={testing} disabled={testing}>
|
||||
{editing ? t("更新") : t("创建")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6 pb-6">
|
||||
<div className="space-y-4">
|
||||
<SectionHeader tone="indigo" title={t("代理信息")} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label={t("代理 ID")}>
|
||||
<Input value={form.id} disabled={editing} placeholder={t("唯一标识,如 jp-proxy-01")} onChange={(e) => onPatch({ id: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t("名称")}>
|
||||
<Input value={form.name} placeholder={t("例如:日本代理")} onChange={(e) => onPatch({ name: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t("Socks5 地址")}>
|
||||
<Input
|
||||
value={form.addr}
|
||||
placeholder={t("host:port,例如 1.2.3.4:1080 或 [2001:db8::1]:1080")}
|
||||
onChange={(e) => onPatch({ addr: e.target.value })}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
{t("VoWiFi 通过此 Socks5 代理连接运营商,实现跨区域本地 VoWiFi。")}
|
||||
{ipv6Hint()}
|
||||
{t("。点下方「检测连通性」可在保存前验证 Socks5 握手与 UDP Associate。")}
|
||||
</div>
|
||||
</Field>
|
||||
<ToggleRow
|
||||
title={t("启用代理")}
|
||||
subtitle={t("禁用后,已绑定设备的 VoWiFi 将停止使用该线路,不会泄漏到直连")}
|
||||
checked={form.enabled}
|
||||
onChange={(v) => onPatch({ enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SectionHeader tone="amber" title={t("鉴权设置(可选)")} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label={t("用户名")}>
|
||||
<Input value={form.username} placeholder={t("留空则免鉴权")} onChange={(e) => onPatch({ username: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t("密码")}>
|
||||
<Input type="password" value={form.password} placeholder={t("留空则免鉴权")} onChange={(e) => onPatch({ password: e.target.value })} />
|
||||
<div className="mt-1 text-xs text-gray-400">{t("编辑已有代理时留空会保持原密码不变。")}</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
{probe ? (
|
||||
<div className="space-y-3">
|
||||
<SectionHeader tone={probe.udpAssociateOk ? "green" : "amber"} title={t("连通性检测结果")} />
|
||||
<ProbeResultPanel probe={probe} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { AddRegular, DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
|
||||
import type { UpstreamProxy } from "../../types";
|
||||
import { Button, EmptyState, ErrorState, ListSkeleton, Tag } from "../ui";
|
||||
import type { LoadError, UpstreamRow } from "./shared";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export interface UpstreamSectionProps {
|
||||
rows: UpstreamRow[];
|
||||
loading: boolean;
|
||||
error: LoadError | null;
|
||||
onRetry: () => void;
|
||||
onNew: () => void;
|
||||
onEdit: (proxy: UpstreamProxy) => void;
|
||||
onDelete: (proxy: UpstreamProxy) => void;
|
||||
onOpenBindings: (proxy: UpstreamProxy) => void;
|
||||
}
|
||||
|
||||
function UpstreamRowCard({
|
||||
row,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onOpenBindings,
|
||||
}: {
|
||||
row: UpstreamRow;
|
||||
onEdit: (proxy: UpstreamProxy) => void;
|
||||
onDelete: (proxy: UpstreamProxy) => void;
|
||||
onOpenBindings: (proxy: UpstreamProxy) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="ui-panel-muted flex flex-col gap-3 p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.enabled ? "bg-green-500" : "bg-gray-300"}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-bold text-gray-900 dark:text-white">{row.name || row.id}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-gray-500">
|
||||
SOCKS5 · <span className="font-mono">{row.addr}</span>
|
||||
{row.username ? <span> · {t("鉴权")}: {row.username}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag>
|
||||
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
|
||||
<DesktopRegular className="text-[14px]" />
|
||||
<span>{row.bindingCount} {t("台设备")}</span>
|
||||
</div>
|
||||
<div className="mx-0.5 hidden h-3.5 w-px bg-gray-200 dark:bg-gray-700 sm:block" />
|
||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>
|
||||
{t("设备绑定")}
|
||||
</Button>
|
||||
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)} />
|
||||
<Button size="small" variant="danger" icon={<DeleteRegular />} onClick={() => onDelete(row)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpstreamSection({ rows, loading, error, onRetry, onNew, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div>
|
||||
{error ? (
|
||||
<ErrorState className="mb-6" title={t("加载上游代理失败")} message={error.message} statusCode={error.status} retryText={t("重试")} onRetry={onRetry} />
|
||||
) : null}
|
||||
<div className="ui-card p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-[#0ea5e9] to-[#0284c7] text-white shadow-lg shadow-indigo-500/25">
|
||||
<GlobeRegular className="text-[20px]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{t("VoWiFi 上游代理")}</div>
|
||||
<div className="text-xs text-gray-500">{t("将设备的 VoWiFi 建链、IMS 和短信通信通过支持 UDP Associate 的 SOCKS5 代理传输。")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" className="!border-0" icon={<AddRegular />} onClick={onNew}>
|
||||
{t("新增代理")}
|
||||
</Button>
|
||||
</div>
|
||||
{loading && rows.length === 0 ? (
|
||||
<ListSkeleton rows={2} />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("暂无上游代理")}
|
||||
subtitle={t("点击“新增代理”创建 SOCKS5 上游代理,然后将需要使用它的设备直接绑定;未绑定设备默认直连。")}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => (
|
||||
<UpstreamRowCard key={row.id} row={row} onEdit={onEdit} onDelete={onDelete} onOpenBindings={onOpenBindings} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Switch } from "../ui";
|
||||
|
||||
export function SectionHeader({ tone, title }: { tone: "indigo" | "amber" | "green"; title: string }) {
|
||||
const bar = tone === "amber" ? "bg-amber-500" : tone === "green" ? "bg-green-500" : "bg-indigo-500";
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-gray-100 pb-2 dark:border-gray-800">
|
||||
<div className={`h-4 w-1 rounded-full ${bar}`} />
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">{title}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-gray-500">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToggleRow({
|
||||
title,
|
||||
subtitle,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ui-panel-muted flex items-center justify-between rounded-lg p-3">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-gray-800 dark:text-gray-100">{title}</div>
|
||||
<div className="text-xs text-gray-500">{subtitle}</div>
|
||||
</div>
|
||||
<Switch checked={checked} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { UpstreamProxy } from "../../types";
|
||||
import { tl } from "../../lib/i18n";
|
||||
|
||||
export interface LoadError {
|
||||
message: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface UpstreamForm {
|
||||
id: string;
|
||||
name: string;
|
||||
addr: string;
|
||||
username: string;
|
||||
password: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Result of the SOCKS5 handshake and UDP Associate connectivity probe.
|
||||
export interface UpstreamProbeResult {
|
||||
reachable?: boolean;
|
||||
handshakeOk?: boolean;
|
||||
udpAssociateOk?: boolean;
|
||||
authMethod?: string;
|
||||
relayAddr?: string;
|
||||
diagnosis?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpstreamRow extends UpstreamProxy {
|
||||
bindingCount: number;
|
||||
}
|
||||
|
||||
export function ipv6Hint(): string {
|
||||
return tl("IPv6 地址请使用 [IPv6]:port,例如 [2001:db8::1]:1080");
|
||||
}
|
||||
|
||||
export function ipv6AddrError(addr: string): string {
|
||||
const value = String(addr || "").trim();
|
||||
if (!value || value.startsWith("[")) return "";
|
||||
return (value.match(/:/g) || []).length > 1 ? ipv6Hint() : "";
|
||||
}
|
||||
|
||||
export function emptyUpstreamForm(): UpstreamForm {
|
||||
return { id: "", name: "", addr: "", username: "", password: "", enabled: true };
|
||||
}
|
||||
Reference in New Issue
Block a user