This commit is contained in:
2026-01-30 12:56:00 +08:00
parent 91f24cb462
commit dcbbda951e
89 changed files with 13486 additions and 72 deletions

View 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>
)
}

View 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>)
}

View 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>
)
}

View 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>
)
}

View 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
View 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>
);
}

View 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;
}

View 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>
}

View 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
View 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
View 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>
);
}