-
This commit is contained in:
36
web/src/pages/Connections.tsx
Normal file
36
web/src/pages/Connections.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import {ScrollableBody, ContentHeader} from "@/components/app-content.tsx";
|
||||
import {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemTitle,
|
||||
ItemActions,
|
||||
} from "@/components/ui/item.tsx";
|
||||
import {BadgeCheckIcon, ChevronRightIcon} from "lucide-react";
|
||||
|
||||
export function Connections() {
|
||||
return (
|
||||
<ScrollableBody>
|
||||
<ContentHeader>
|
||||
Connections
|
||||
</ContentHeader>
|
||||
<div className={'flex flex-col gap-y-2'}>
|
||||
{new Array(100).fill(0).map(() => (
|
||||
<Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
</ScrollableBody>
|
||||
)
|
||||
}
|
||||
50
web/src/pages/Home/LocationCard.tsx
Normal file
50
web/src/pages/Home/LocationCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import {BadgeCheckIcon, GlobeIcon} from "lucide-react";
|
||||
import {Container} from "@/components/app-content.tsx";
|
||||
import {LabelItem, LabelName, LabelValue} from "@/pages/Home/index.tsx";
|
||||
|
||||
const Latency = () => {
|
||||
return <div className={'flex gap-1'}>
|
||||
<span
|
||||
className={'text-[11px] text-gray-500 font-medium whitespace-nowrap'}>Github</span>
|
||||
<span
|
||||
className={'text-[11px] text-gray-800 font-semibold whitespace-nowrap'}>122ms</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function LocationCard() {
|
||||
return (<Container>
|
||||
{/*底部*/}
|
||||
<div className={'grid grid-cols-2 gap-6'}>
|
||||
<LabelItem>
|
||||
<LabelName className="flex items-center gap-1.5 mb-1">
|
||||
<GlobeIcon size={12}/>
|
||||
IP
|
||||
</LabelName>
|
||||
<LabelValue>47.238.198.100</LabelValue>
|
||||
</LabelItem>
|
||||
<LabelItem>
|
||||
<LabelName className="flex items-center gap-1.5 mb-1">
|
||||
<GlobeIcon size={12}/>
|
||||
所属地址
|
||||
</LabelName>
|
||||
<LabelValue>日本 · 东京</LabelValue>
|
||||
</LabelItem>
|
||||
</div>
|
||||
<LabelItem className={'flex flex-col mt-4'}>
|
||||
<LabelName className={'mt-1 flex items-center gap-1.5 mb-2'}>
|
||||
<BadgeCheckIcon size={12}/>
|
||||
延迟
|
||||
</LabelName>
|
||||
<div className={'grid grid-cols-4 gap-y-2'}>
|
||||
<Latency/>
|
||||
<Latency/>
|
||||
<Latency/>
|
||||
<Latency/>
|
||||
<Latency/>
|
||||
<Latency/>
|
||||
<Latency/>
|
||||
<Latency/>
|
||||
</div>
|
||||
</LabelItem>
|
||||
</Container>)
|
||||
}
|
||||
64
web/src/pages/Home/ProfileCard.tsx
Normal file
64
web/src/pages/Home/ProfileCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import {Container} from "@/components/app-content.tsx";
|
||||
import {BadgeCheckIcon, CloudDownloadIcon, GlobeIcon, RotateCwIcon} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
import {LabelItem, LabelName, LabelValue} from "@/pages/Home/index.tsx";
|
||||
|
||||
|
||||
export function ProfileCard() {
|
||||
return (
|
||||
<Container>
|
||||
{/*第一行*/}
|
||||
<div className={'flex items-center justify-between pb-4 border-b border-border/40'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<div className="p-2.5 bg-primary/10 rounded-xl text-primary">
|
||||
<CloudDownloadIcon size={24}/>
|
||||
</div>
|
||||
<div>
|
||||
<p className={'text-lg font-bold text-foreground flex items-center gap-2'}>
|
||||
NanoCloud
|
||||
</p>
|
||||
<div className={'flex items-center gap-2 mt-0.5'}>
|
||||
<GlobeIcon size={12} className="text-muted-foreground"/>
|
||||
<p className="text-xs text-muted-foreground font-medium">免费-日本1-Ver.7</p>
|
||||
<div className={'flex gap-1'}>
|
||||
<Badge variant={'outline'} className={'text-gray-500 text-[10px] rounded-md px-1 h-4'}>Vmess</Badge>
|
||||
<Badge variant={'outline'} className={'text-gray-500 text-[10px] rounded-md px-1 h-4'}>UDP</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-4 w-4 text-muted-foreground hover:text-foreground">
|
||||
<RotateCwIcon size={10}/>
|
||||
</Button>
|
||||
</div>
|
||||
{/*底部*/}
|
||||
<div className={'grid grid-cols-3 gap-6 mt-4'}>
|
||||
<LabelItem>
|
||||
<LabelName className="flex items-center gap-1.5 mb-1">
|
||||
<CloudDownloadIcon size={12}/>
|
||||
已使用 / 总量
|
||||
</LabelName>
|
||||
<LabelValue>1.26GB / 100GB</LabelValue>
|
||||
</LabelItem>
|
||||
<LabelItem>
|
||||
<LabelName className="flex items-center gap-1.5 mb-1">
|
||||
<BadgeCheckIcon size={12}/>
|
||||
到期时间
|
||||
</LabelName>
|
||||
<LabelValue>2025-11-11</LabelValue>
|
||||
</LabelItem>
|
||||
<LabelItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<LabelName className="flex items-center gap-1.5 mb-1">
|
||||
<BadgeCheckIcon size={12}/>
|
||||
更新时间
|
||||
</LabelName>
|
||||
</div>
|
||||
<LabelValue>2025-10-12 10:05</LabelValue>
|
||||
</LabelItem>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
)
|
||||
}
|
||||
271
web/src/pages/Home/SettingCard.tsx
Normal file
271
web/src/pages/Home/SettingCard.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import {Container} from "@/components/app-content.tsx";
|
||||
import {Item, ItemActions, ItemContent, ItemMedia, ItemTitle} from "@/components/ui/item.tsx";
|
||||
import {BadgeCheckIcon, Loader2, RotateCwIcon, ServerIcon, Trash2Icon} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Switch} from "@/components/ui/switch.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {z} from "zod/v4";
|
||||
import {Controller, useForm} from "react-hook-form";
|
||||
import {zodResolver} from "@hookform/resolvers/zod";
|
||||
import {useState} from "react";
|
||||
import {useMihomoConfig} from "@/hooks/use-mihomo-config";
|
||||
import {useServiceStatus} from "@/hooks/use-service-status.ts";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
import {TipButton} from "@/components/button.tsx";
|
||||
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip.tsx";
|
||||
import {WrenchScrewdriverIcon} from "@heroicons/react/24/outline";
|
||||
|
||||
const port = z.object({
|
||||
port: z.number().min(0).max(65535),
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
mix: port,
|
||||
http: port,
|
||||
socks: port,
|
||||
});
|
||||
|
||||
export type Ports = z.infer<typeof schema>;
|
||||
|
||||
function PortSettingDialog({open, onOpenChange}: {
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
}) {
|
||||
|
||||
const {config, mutation} = useMihomoConfig()
|
||||
|
||||
const form = useForm<Ports>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: config.data!
|
||||
});
|
||||
|
||||
const onSubmit = (ports: Ports) => {
|
||||
mutation.mutate({
|
||||
...config.data!,
|
||||
...ports
|
||||
});
|
||||
onOpenChange(false)
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='w-96' aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>端口设置</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form id="port-form" onSubmit={form.handleSubmit(onSubmit)} className="py-2 flex flex-col gap-3">
|
||||
{(['mix', 'http', 'socks'] as const).map((key) => (
|
||||
<Controller
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({field}) => (
|
||||
<div className={`p-2`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold capitalize tracking-wide">{key} Port</span>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={key == 'mix'}
|
||||
checked={field.value.enabled}
|
||||
onCheckedChange={(enabled) => field.onChange({
|
||||
...field.value,
|
||||
enabled
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
|
||||
value={field.value.port}
|
||||
disabled={!field.value.enabled}
|
||||
onChange={(e) => field.onChange({
|
||||
...field.value,
|
||||
port: parseInt(e.target.value) || 0
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="port-form" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "保存中..." : "保存更改"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceControlDialog(props: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const {status: {data: status}, install, uninstall} = useServiceStatus();
|
||||
const action = status?.installed ? uninstall : install;
|
||||
|
||||
const mutate = async () => {
|
||||
action.mutate();
|
||||
props.onOpenChange(false)
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{status?.installed ? '卸载服务' : '安装服务'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{status?.installed
|
||||
? '卸载服务后,TUN 模式将无法使用。此操作需要管理员权限。'
|
||||
: '如果不安装服务,程序将只能以受限模式运行,无法使用 TUN 模式等高级功能。此操作需要管理员权限。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => props.onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={async () => mutate()} disabled={action.isPending}>
|
||||
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
|
||||
{status?.installed ? '确认卸载' : '确认安装'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DaemonSetting() {
|
||||
const [serviceDialog, setServiceDialog] = useState(false)
|
||||
|
||||
const {status: {data: status}, reload} = useServiceStatus();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ServiceControlDialog
|
||||
open={serviceDialog}
|
||||
onOpenChange={setServiceDialog}
|
||||
/>
|
||||
|
||||
<Item variant="default" size="sm" className={'py-1 h-10'} asChild>
|
||||
<a className={'rounded-none'}>
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<div className={'flex gap-2'}>
|
||||
<ItemTitle>服务模式</ItemTitle>
|
||||
{status?.installed && status.sidecar ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge variant={'outline'}
|
||||
className={'text-yellow-500 rounded-sm bg-yellow-50'}><ServerIcon/>Sidecar
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>系统服务已安装但未启动, 回退到Sidecar模式</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Badge variant={'outline'}
|
||||
className={'text-green-500 rounded-sm bg-green-50'}>
|
||||
<div className={'bg-green-500 size-1 rounded-full'}/>
|
||||
运行中
|
||||
</Badge>
|
||||
)}
|
||||
<TipButton
|
||||
variant={'ghost'}
|
||||
size={'icon-sm'}
|
||||
className={`p-0 m-0 text-muted-foreground ${status?.installed && 'hover:text-red-500'}`}
|
||||
onClick={() => setServiceDialog(true)}
|
||||
tipContent={status?.installed ? '卸载系统服务' : '安装系统服务'}
|
||||
>
|
||||
{status?.installed ? <Trash2Icon/> : <WrenchScrewdriverIcon/>}
|
||||
</TipButton>
|
||||
</div>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<TipButton
|
||||
variant={'ghost'}
|
||||
size={'icon-sm'}
|
||||
className={'text-muted-foreground'}
|
||||
tipContent={'重载daemon状态'}
|
||||
onClick={() => reload.mutate()}
|
||||
>
|
||||
<RotateCwIcon/>
|
||||
</TipButton>
|
||||
|
||||
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export function SettingCard() {
|
||||
const [portUpdateDialog, setPortUpdateDialog] = useState(false);
|
||||
const {config, mutation} = useMihomoConfig();
|
||||
|
||||
if (!config.data) {
|
||||
return (<div>Loading</div>)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className={'px-0 py-2'}>
|
||||
<DaemonSetting/>
|
||||
<Item variant="default" size="sm" className={'py-1 h-10'}>
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Tun 模式</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch onCheckedChange={tunMode => mutation.mutate({...config.data, tunMode})}/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item variant="default" size="sm" className={'py-1 h-10'}>
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>系统代理</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item variant="default" size="sm" className={'py-1 h-10'}>
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>代理端口</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button variant={'link'} className={'p-0 h-5'} onClick={() => setPortUpdateDialog(true)}>
|
||||
{config.data.mix.port || '--'}
|
||||
</Button>
|
||||
<PortSettingDialog open={portUpdateDialog} onOpenChange={setPortUpdateDialog}/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
50
web/src/pages/Home/index.tsx
Normal file
50
web/src/pages/Home/index.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import {cn} from "@/lib/utils.ts";
|
||||
import {ContentHeader, ScrollableBody} from "@/components/app-content.tsx";
|
||||
import {ProfileCard} from "@/pages/Home/ProfileCard.tsx";
|
||||
import {LocationCard} from "@/pages/Home/LocationCard.tsx";
|
||||
import {SettingCard} from "@/pages/Home/SettingCard.tsx";
|
||||
|
||||
type DivProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
|
||||
|
||||
type SpanProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
|
||||
|
||||
export const LabelItem = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DivProps) => {
|
||||
return <div className={cn('flex flex-col', className)} {...props}>{children}</div>
|
||||
}
|
||||
|
||||
export const LabelName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SpanProps) => {
|
||||
return <span
|
||||
className={cn('text-[11px] text-gray-400 uppercase tracking-[0.5px] font-semibold', className)} {...props}>{children}</span>
|
||||
}
|
||||
|
||||
export const LabelValue = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SpanProps) => {
|
||||
return <span className={cn('text-[13px] text-gray-800 font-medium', className)} {...props}>{children}</span>
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
|
||||
return (
|
||||
<ScrollableBody>
|
||||
<ContentHeader>
|
||||
Home
|
||||
</ContentHeader>
|
||||
<div className={'flex flex-col gap-2'}>
|
||||
<ProfileCard/>
|
||||
<LocationCard/>
|
||||
<SettingCard/>
|
||||
</div>
|
||||
</ScrollableBody>
|
||||
)
|
||||
}
|
||||
94
web/src/pages/Logs.tsx
Normal file
94
web/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {ContentHeader} from "@/components/app-content.tsx";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {invoke} from "@tauri-apps/api/core";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||
import {useQuery} from "@tanstack/react-query";
|
||||
|
||||
|
||||
function LogView({logs}: { logs: Record<string, string>[] }) {
|
||||
return (
|
||||
<ScrollArea className="h-full px-2 min-h-0 overflow-hidden">
|
||||
<div className="font-mono text-xs pb-4">
|
||||
{logs.map((log, i) => (
|
||||
<LogRow key={i} log={log}/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function formatTime(isoTime?: string) {
|
||||
if (!isoTime) return "";
|
||||
try {
|
||||
return new Date(isoTime).toLocaleTimeString('zh-CN', {hour12: false});
|
||||
} catch (e) {
|
||||
console.error("Error formatting date ", e);
|
||||
return isoTime;
|
||||
}
|
||||
}
|
||||
|
||||
interface Log {
|
||||
time?: string
|
||||
level?: 'debug' | 'info' | 'warn' | 'error'
|
||||
msg: string
|
||||
}
|
||||
|
||||
const LEVEL_COLOR = {
|
||||
debug: 'text-red-500',
|
||||
info: 'text-blue-500',
|
||||
warn: 'text-yellow-500',
|
||||
error: 'text-red-500',
|
||||
default: 'text-muted-foreground'
|
||||
}
|
||||
|
||||
function LogRow({log}: { log: Record<string, string> }) {
|
||||
const {time, level, msg} = log as unknown as Log;
|
||||
const color = LEVEL_COLOR[level ?? 'default']
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2 px-2 py-0.5 hover:bg-muted/50 odd:bg-muted/20 rounded-sm transition-colors whitespace-nowrap">
|
||||
{time && <span className="text-muted-foreground w-16 shrink-0">{formatTime(time)}</span>}
|
||||
{level && <span className={cn("w-10 font-bold shrink-0 uppercase", color)}>{level}</span>}
|
||||
<span className="text-foreground">{msg}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function Logs() {
|
||||
|
||||
|
||||
const {data: mihomoLogs} = useQuery({
|
||||
queryKey: ['mihomo_logs'],
|
||||
queryFn: async () => await invoke<Record<string, string>[]>("mihomo_logs"),
|
||||
refetchInterval: 1000
|
||||
})
|
||||
const {data: appLogs} = useQuery({
|
||||
queryKey: ['app_logs'],
|
||||
queryFn: async () => await invoke<Record<string, string>[]>("mihomo_logs"),
|
||||
refetchInterval: 1000
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="h-svh w-full flex flex-col overflow-hidden py-2 pl-0 pr-4">
|
||||
<Tabs defaultValue={'mihomo'} className="size-full min-h-0">
|
||||
<ContentHeader className="justify-between">
|
||||
Logs
|
||||
<TabsList className="sticky z-50">
|
||||
<TabsTrigger value="mihomo">Mihomo Logs</TabsTrigger>
|
||||
<TabsTrigger value="app">App Logs</TabsTrigger>
|
||||
</TabsList>
|
||||
</ContentHeader>
|
||||
<TabsContent value="mihomo" className="flex-1 overflow-hidden">
|
||||
<LogView logs={mihomoLogs ?? []}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="app" className="flex-1 overflow-hidden">
|
||||
<LogView logs={appLogs ?? []}/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
web/src/pages/Profiles/Dialog.tsx
Normal file
247
web/src/pages/Profiles/Dialog.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import {Controller, ControllerFieldState, useForm} from "react-hook-form";
|
||||
import React, {createContext, ReactNode, useContext, useState} from "react";
|
||||
import {Field, FieldLabel, FieldSet} from "@/components/ui/field.tsx";
|
||||
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip.tsx";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
import {InfoIcon, PlusIcon, TrashIcon} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {zodResolver} from "@hookform/resolvers/zod";
|
||||
import {z} from "zod/v4";
|
||||
import {toast} from "sonner";
|
||||
import {open as openFileSelector} from '@tauri-apps/plugin-dialog';
|
||||
import {Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle} from "@/components/ui/dialog.tsx";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {InputGroup, InputGroupAddon, InputGroupInput} from "@/components/ui/input-group.tsx";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import {useProfiles} from "@/hooks/use-profiles";
|
||||
|
||||
|
||||
const schema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('local'),
|
||||
name: z.string("输入配置名").trim().min(1, "输入配置名"),
|
||||
file: z.string("选择文件").trim().min(1, "选择文件"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('remote'),
|
||||
name: z.string("输入配置名").trim().min(1, "输入配置名"),
|
||||
url: z.url("订阅地址无效"),
|
||||
interval: z.number().min(5, "更新间隔不合法")
|
||||
})
|
||||
])
|
||||
|
||||
export type Profile = { id?: string } & z.infer<typeof schema>
|
||||
|
||||
|
||||
/**
|
||||
* 错误展示在Label上
|
||||
*/
|
||||
function NoticeFieldLabel({fieldState, children}: { fieldState: ControllerFieldState, children: React.ReactNode }) {
|
||||
return <FieldLabel>{children}
|
||||
{fieldState.invalid && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge className={'p-0 text-red-500 '} variant={'outline'}><InfoIcon/></Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{fieldState.error?.message}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FieldLabel>
|
||||
}
|
||||
|
||||
|
||||
function ProfileDialog({profile, ...props}: React.ComponentProps<typeof DialogPrimitive.Root> & {
|
||||
profile?: Profile
|
||||
}) {
|
||||
const profiles = useProfiles();
|
||||
const form = useForm<z.infer<typeof schema>>({
|
||||
mode: 'all',
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: profile || {
|
||||
type: 'remote',
|
||||
name: '',
|
||||
url: '',
|
||||
interval: 1440,
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.open) {
|
||||
form.reset(profile || {
|
||||
type: 'remote',
|
||||
name: '',
|
||||
url: '',
|
||||
interval: 1440,
|
||||
})
|
||||
}
|
||||
}, [props.open, profile]);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
try {
|
||||
if (profile?.id) {
|
||||
profiles.update.mutate({...data, id: profile.id})
|
||||
} else {
|
||||
profiles.add.mutate(data)
|
||||
}
|
||||
props.onOpenChange?.(false)
|
||||
} catch (e) {
|
||||
toast.error(e as string)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
return await openFileSelector({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
filters: [{
|
||||
name: 'yml',
|
||||
extensions: ['yml', 'yaml']
|
||||
}]
|
||||
}) ?? '';
|
||||
}
|
||||
|
||||
const type = form.watch('type')
|
||||
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className={'w-96'} aria-describedby={undefined}>
|
||||
<DialogTitle>{profile ? '编辑配置' : '添加配置'}</DialogTitle>
|
||||
<form id='profile-editor' onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldSet>
|
||||
<Controller name={'type'} control={form.control} render={({field, fieldState}) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<NoticeFieldLabel fieldState={fieldState}>
|
||||
配置类型
|
||||
</NoticeFieldLabel>
|
||||
<Select name={field.name} value={field.value} defaultValue={'remote'}
|
||||
onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-full" aria-invalid={fieldState.invalid}>
|
||||
<SelectValue placeholder={'选择配置类型'}/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">本地</SelectItem>
|
||||
<SelectItem value="remote">远程</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}/>
|
||||
<Controller name={'name'} control={form.control} render={({field, fieldState}) =>
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<NoticeFieldLabel fieldState={fieldState}>配置名</NoticeFieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="name"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="请输入配置名"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Field>
|
||||
}/>
|
||||
{type == 'remote' && (
|
||||
<>
|
||||
<Controller name={'url'} control={form.control} render={({field, fieldState}) =>
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<NoticeFieldLabel fieldState={fieldState}>订阅链接</NoticeFieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="url"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="请输入订阅链接"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Field>
|
||||
}/>
|
||||
<Controller name={'interval'} control={form.control} render={({field, fieldState}) =>
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<NoticeFieldLabel fieldState={fieldState}>更新间隔</NoticeFieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
id="interval"
|
||||
type={'number'}
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="更新间隔"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">分钟</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
}/>
|
||||
</>
|
||||
)}
|
||||
{type == 'local' && (
|
||||
<Controller name={'file'} control={form.control} render={({field, fieldState}) =>
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<NoticeFieldLabel fieldState={fieldState}>选择文件</NoticeFieldLabel>
|
||||
|
||||
{field.value ? (
|
||||
<div className={'flex space-x-2'}>
|
||||
<Input {...field}
|
||||
readOnly={true}
|
||||
aria-invalid={fieldState.invalid}/>
|
||||
<Button variant={'outline'}
|
||||
onClick={() => field.onChange('')}><TrashIcon/></Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant={'outline'}
|
||||
onClick={async () => {
|
||||
field.onChange(await handleSelectFile())
|
||||
}}
|
||||
><PlusIcon/>请选择文件</Button>
|
||||
)}
|
||||
</Field>
|
||||
}/>
|
||||
)}
|
||||
</FieldSet>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form={'profile-editor'}>Save changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
type DialogContextProps = {
|
||||
edit: (profile?: Profile) => void,
|
||||
close: () => void
|
||||
}
|
||||
const DialogContext = createContext<DialogContextProps | null>(null);
|
||||
|
||||
export function DialogProvider({children}: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [profile, setProfile] = useState<Profile | undefined>()
|
||||
const edit = (profile?: Profile) => {
|
||||
setProfile(profile)
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setProfile(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{edit, close}}>
|
||||
{children}
|
||||
<ProfileDialog open={open} profile={profile} onOpenChange={open => {
|
||||
if (!open) close();
|
||||
else setOpen(true);
|
||||
}}/>
|
||||
</DialogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
const ctx = useContext(DialogContext);
|
||||
if (!ctx) throw new Error("useDialog must be used within Provider");
|
||||
return ctx;
|
||||
}
|
||||
101
web/src/pages/Profiles/Item.tsx
Normal file
101
web/src/pages/Profiles/Item.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import {Profile, useDialog} from "@/pages/Profiles/Dialog.tsx";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from "@/components/ui/context-menu.tsx";
|
||||
import {Item, ItemContent, ItemDescription, ItemTitle} from "@/components/ui/item.tsx";
|
||||
import {cn} from "@/lib/utils.ts";
|
||||
import {CloudDownloadIcon, FileIcon, RotateCwIcon} from "lucide-react";
|
||||
import {useProfiles} from "@/hooks/use-profiles.ts";
|
||||
|
||||
|
||||
function ProfileItemContent({item}: { item: Profile }) {
|
||||
const {refresh} = useProfiles()
|
||||
if (item.type == 'local') {
|
||||
return (
|
||||
<ItemContent className={'min-w-0 h-full'}>
|
||||
<ItemTitle className={'text-base'}><FileIcon size={20}/>{item.name}</ItemTitle>
|
||||
<ItemDescription>
|
||||
<span className={'block truncate flex-1'}>A simple item with title and description. Abcdef</span>
|
||||
</ItemDescription>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-auto">
|
||||
<span>Local Profile</span>
|
||||
</div>
|
||||
</ItemContent>
|
||||
)
|
||||
}
|
||||
if (item.type == 'remote') {
|
||||
return (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-primary/10 z-0 pointer-events-none transition-all" style={{width: '66%'}}/>
|
||||
<ItemContent className={'min-w-0 z-10'}>
|
||||
<ItemTitle className="w-full justify-between text-base">
|
||||
<div className={'flex items-center gap-2'}><CloudDownloadIcon size={20}/>{item.name}</div>
|
||||
<div
|
||||
className="p-1 rounded-md hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
refresh.mutate(item.id!)
|
||||
}}>
|
||||
<RotateCwIcon size={16}
|
||||
className={cn("text-muted-foreground", refresh.isPending && "force-animate-spin")}/>
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
<span className={'block truncate flex-1'}>A simple item with title and description. Abcdef</span>
|
||||
</ItemDescription>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-1">
|
||||
<span>Updated: Just now</span>
|
||||
<span>66 / 100 GB</span>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function ProfileItem({item, selected}: { item: Profile, selected: boolean }) {
|
||||
const dialog = useDialog();
|
||||
const {use, remove, refresh, editingFile} = useProfiles()
|
||||
|
||||
return <div id={item.id}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Item
|
||||
className={cn('relative overflow-hidden transition-all items-start h-24', selected && 'border-primary/50 bg-accent/60')}
|
||||
variant="outline" size="sm"
|
||||
onClick={() => (!selected) && use.mutate(item.id!)}
|
||||
asChild>
|
||||
<a>
|
||||
<ProfileItemContent item={item}/>
|
||||
</a>
|
||||
</Item>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => item.id && use.mutate(item.id)}>
|
||||
Use
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => dialog.edit(item)}>
|
||||
Edit
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => item.id && editingFile.mutate(item.id)}>
|
||||
Edit File
|
||||
</ContextMenuItem>
|
||||
{item.type === 'remote' && (
|
||||
<ContextMenuItem onClick={() => refresh.mutate(item.id!)}>
|
||||
Update
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator/>
|
||||
<ContextMenuItem className="text-red-600"
|
||||
onClick={() => remove.mutate((item.id!))}>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
}
|
||||
66
web/src/pages/Profiles/index.tsx
Normal file
66
web/src/pages/Profiles/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import {ScrollableBody, ContentHeader} from "@/components/app-content.tsx";
|
||||
import {
|
||||
PlusIcon,
|
||||
RotateCwIcon,
|
||||
} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {DialogProvider, useDialog} from "@/pages/Profiles/Dialog.tsx";
|
||||
import {ProfileItem} from "@/pages/Profiles/Item.tsx";
|
||||
import {useProfiles} from "@/hooks/use-profiles.ts";
|
||||
|
||||
|
||||
function ProfileSetContent() {
|
||||
const {profiles: {isPending, error, data}} = useProfiles()
|
||||
|
||||
if (isPending) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
if (!data?.items.length) {
|
||||
return <div>Nothing</div>
|
||||
}
|
||||
|
||||
return data.items.map((item) => {
|
||||
return (
|
||||
<ProfileItem key={item.id} item={item} selected={data.current === item.id}/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 主要是因为 DialogProvider 内才能useDialog(), 所以单独拿出来
|
||||
* @constructor
|
||||
*/
|
||||
function Inner() {
|
||||
const dialog = useDialog();
|
||||
return (
|
||||
<>
|
||||
<ContentHeader className={'justify-between'}>
|
||||
Profiles
|
||||
<div>
|
||||
<Button variant={'ghost'} size={'icon'} onClick={() => dialog.edit()}>
|
||||
<PlusIcon/>
|
||||
</Button>
|
||||
<Button variant={'ghost'} size={'icon'}>
|
||||
<RotateCwIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
</ContentHeader>
|
||||
<div className={'grid grid-cols-[repeat(auto-fill,minmax(18rem,1fr))] gap-2'}>
|
||||
<ProfileSetContent/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Profiles() {
|
||||
return (
|
||||
<ScrollableBody>
|
||||
<DialogProvider>
|
||||
<Inner/>
|
||||
</DialogProvider>
|
||||
</ScrollableBody>
|
||||
)
|
||||
}
|
||||
347
web/src/pages/Proxies.tsx
Normal file
347
web/src/pages/Proxies.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import {Container, ScrollableBody, ContentHeader} from "@/components/app-content.tsx";
|
||||
import {Tabs, TabsTrigger, TabsContent, TabsList} from "@/components/ui/tabs.tsx";
|
||||
import {Accordion, AccordionItem, AccordionTrigger, AccordionContent} from "@/components/ui/accordion.tsx";
|
||||
import {Item, ItemMedia, ItemContent, ItemTitle, ItemActions} from "@/components/ui/item.tsx";
|
||||
import {BadgeCheckIcon, ChevronRightIcon} from "lucide-react";
|
||||
|
||||
|
||||
export default function Proxies() {
|
||||
return (
|
||||
<ScrollableBody className={'pr-4'}>
|
||||
<Tabs defaultValue="rule" className="size-full">
|
||||
<ContentHeader className={'justify-between'}>
|
||||
Proxies
|
||||
<TabsList className={'sticky z-50'}>
|
||||
<TabsTrigger className={'w-14'} value="rule">Rule</TabsTrigger>
|
||||
<TabsTrigger className={'w-14'} value="global">Global</TabsTrigger>
|
||||
<TabsTrigger className={'w-14'} value="direct">Direct</TabsTrigger>
|
||||
</TabsList>
|
||||
</ContentHeader>
|
||||
|
||||
<TabsContent value="rule" className={'size-full min-h-0'}>
|
||||
<div className={'size-full flex flex-col gap-y-2'}>
|
||||
<Container className={'py-0'}>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full"
|
||||
defaultValue={[]}
|
||||
>
|
||||
<AccordionItem value="item1">
|
||||
<AccordionTrigger>Product Information</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className={'grid grid-cols-2 gap-4 text-balance'}>
|
||||
<Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been
|
||||
verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
<Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been
|
||||
verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Container>
|
||||
<Container className={'py-0'}>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full"
|
||||
defaultValue={[]}
|
||||
>
|
||||
<AccordionItem value="item1">
|
||||
<AccordionTrigger>Product Information</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className={'grid grid-cols-2 gap-4 text-balance'}>
|
||||
<Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been
|
||||
verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
<Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been
|
||||
verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Container>
|
||||
<Container className={'py-0'}>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full"
|
||||
defaultValue={[]}
|
||||
>
|
||||
<AccordionItem value="item1">
|
||||
<AccordionTrigger>Product Information</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className={'grid grid-cols-2 gap-4 text-balance'}>
|
||||
<Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been
|
||||
verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
<Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been
|
||||
verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Container>
|
||||
<Container className={'py-0'}>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full"
|
||||
defaultValue={[]}
|
||||
>
|
||||
<AccordionItem value="item1">
|
||||
<AccordionTrigger>Product Information</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className={'grid grid-cols-2 gap-4 text-balance'}>
|
||||
<Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been
|
||||
verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
<Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been
|
||||
verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item><Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4"/>
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Container>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="global">Change your password here.</TabsContent>
|
||||
<TabsContent value="direct">Change your password here.</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollableBody>
|
||||
)
|
||||
|
||||
}
|
||||
253
web/src/pages/Settings.tsx
Normal file
253
web/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import {ScrollableBody, ContentHeader, Container} from "@/components/app-content.tsx";
|
||||
import {BadgeCheckIcon, SettingsIcon} from "lucide-react";
|
||||
import {Item, ItemActions, ItemContent, ItemMedia, ItemTitle} from "@/components/ui/item.tsx";
|
||||
import {Switch} from "@/components/ui/switch.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {TunConfigModal} from "@/components/settings/tun-config-modal.tsx";
|
||||
import {useState} from "react";
|
||||
import {TunConfig} from "@/lib/types.ts";
|
||||
|
||||
export function Settings() {
|
||||
const [tunModalOpen, setTunModalOpen] = useState(false);
|
||||
|
||||
// 示例 TUN 配置数据
|
||||
const [tunConfig, setTunConfig] = useState<TunConfig>({
|
||||
enable: false,
|
||||
stack: 'system',
|
||||
dnsHijack: ['0.0.0.0:53'],
|
||||
autoRoute: true,
|
||||
autoDetectInterface: true,
|
||||
mtu: 9000,
|
||||
});
|
||||
|
||||
const handleSaveTunConfig = (newConfig: TunConfig) => {
|
||||
setTunConfig(newConfig);
|
||||
// TODO: 这里应该调用 Tauri 命令保存配置到后端
|
||||
console.log('保存 TUN 配置:', newConfig);
|
||||
};
|
||||
|
||||
const config = {
|
||||
auto_route: false,
|
||||
device: "",
|
||||
dns_hijack: [],
|
||||
enable: false,
|
||||
mtu: 0,
|
||||
stack: 'System',
|
||||
strict_route: false
|
||||
}
|
||||
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<ScrollableBody>
|
||||
<ContentHeader>Settings</ContentHeader>
|
||||
<div className="p-4">Loading...</div>
|
||||
</ScrollableBody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableBody>
|
||||
<ContentHeader>Settings</ContentHeader>
|
||||
<Container className={'py-2 px-4'}>
|
||||
<span>App 设置</span>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>开机自启</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>静默启动</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>Clash 内核</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</Container>
|
||||
<Container className={'py-2 px-4 mt-2'}>
|
||||
<span>Clash 设置</span>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle className="flex items-center gap-1.5">
|
||||
<span>虚拟网卡模式</span>
|
||||
<Button
|
||||
size={'icon-sm'}
|
||||
variant={'ghost'}
|
||||
onClick={() => setTunModalOpen(true)}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<SettingsIcon className="size-3.5 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
</Button>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={tunConfig.enable}
|
||||
onCheckedChange={(checked) => {
|
||||
setTunConfig({ ...tunConfig, enable: checked });
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>允许局域网</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>Ipv6</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>统一延迟</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>日志等级</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
INFO
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>端口设置</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button size={'icon-sm'} variant={'link'}>7890</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</Container>
|
||||
<Container>
|
||||
<span>杂项</span>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>App 目录</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button size={'icon-sm'} variant={'link'}>7890</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>App 配置目录</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button size={'icon-sm'} variant={'link'}>7890</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>内核目录</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button size={'icon-sm'} variant={'link'}>7890</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item className={'py-2 px-0'} variant="default" size="sm">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5"/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>App 版本</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button size={'icon-sm'} variant={'link'}>7890</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</Container>
|
||||
|
||||
<TunConfigModal
|
||||
open={tunModalOpen}
|
||||
onOpenChange={setTunModalOpen}
|
||||
config={tunConfig}
|
||||
onSave={handleSaveTunConfig}
|
||||
/>
|
||||
</ScrollableBody>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user