mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
refactor(frontend): read the panel's navigation from one route manifest (#6922)
The router, the sidebar, the page titles and the command palette each kept their own list of the panel's pages, so a page added to one could go missing from another without any test noticing. They all read src/routes/panelRoutes.tsx now, and a contract test holds the manifest to the router, the translation bundle and the palette keywords. The unused useZodForm helper goes, and CONTRIBUTING, frontend/CLAUDE.md and the frontend README describe the useForm + rhfZodValidate pattern the panel's forms actually use.
This commit is contained in:
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConfigProvider, Tag, Tooltip, message } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
ApartmentOutlined,
|
||||
CheckCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
@@ -13,13 +12,11 @@ import {
|
||||
ClusterOutlined,
|
||||
CodeOutlined,
|
||||
CopyOutlined,
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DiscordOutlined,
|
||||
ExportOutlined,
|
||||
FileTextOutlined,
|
||||
GlobalOutlined,
|
||||
ImportOutlined,
|
||||
LoadingOutlined,
|
||||
MailOutlined,
|
||||
@@ -31,8 +28,6 @@ import {
|
||||
SettingOutlined,
|
||||
SunOutlined,
|
||||
SwapOutlined,
|
||||
TagsOutlined,
|
||||
TeamOutlined,
|
||||
TelegramFilled,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -42,6 +37,7 @@ import { activateOnKey } from '@/utils/a11y';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { PANEL_ROUTES } from '@/routes/panelRoutes';
|
||||
import type { ClientRecord, InboundOption } from '@/schemas/client';
|
||||
import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
|
||||
import './CommandPalette.css';
|
||||
@@ -62,6 +58,34 @@ interface PaletteItem {
|
||||
};
|
||||
}
|
||||
|
||||
// The search terms of every panel page, so the palette finds it by what it
|
||||
// does rather than by its title.
|
||||
export const PALETTE_KEYWORDS: Record<string, string[]> = {
|
||||
'/': ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
|
||||
'/inbounds': [
|
||||
'inbounds',
|
||||
'ports',
|
||||
'vless',
|
||||
'vmess',
|
||||
'reality',
|
||||
'trojan',
|
||||
'shadowsocks',
|
||||
'wireguard',
|
||||
'hysteria',
|
||||
'masque',
|
||||
],
|
||||
'/clients': ['clients', 'users', 'sub', 'traffic', 'quota'],
|
||||
'/groups': ['groups', 'tags', 'batch'],
|
||||
'/nodes': ['nodes', 'servers', 'cluster', 'remote nodes'],
|
||||
'/hosts': ['hosts', 'sni', 'domains'],
|
||||
'/outbound': ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
|
||||
'/routing': ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
|
||||
'/settings': ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
|
||||
'/xray': ['xray', 'templates', 'balancer', 'dns'],
|
||||
'/api-docs': ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
||||
'/sponsors': ['sponsors', 'sponsor', 'partners'],
|
||||
};
|
||||
|
||||
export default function CommandPalette() {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
@@ -343,91 +367,12 @@ export default function CommandPalette() {
|
||||
});
|
||||
});
|
||||
|
||||
const pages = [
|
||||
{
|
||||
path: '/',
|
||||
title: t('menu.dashboard'),
|
||||
keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
|
||||
icon: <DashboardOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/inbounds',
|
||||
title: t('menu.inbounds'),
|
||||
keywords: [
|
||||
'inbounds',
|
||||
'ports',
|
||||
'vless',
|
||||
'vmess',
|
||||
'reality',
|
||||
'trojan',
|
||||
'shadowsocks',
|
||||
'wireguard',
|
||||
'hysteria',
|
||||
'masque',
|
||||
],
|
||||
icon: <ImportOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/clients',
|
||||
title: t('menu.clients'),
|
||||
keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
|
||||
icon: <TeamOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/groups',
|
||||
title: t('menu.groups'),
|
||||
keywords: ['groups', 'tags', 'batch'],
|
||||
icon: <TagsOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/nodes',
|
||||
title: t('menu.nodes'),
|
||||
keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
|
||||
icon: <ClusterOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/hosts',
|
||||
title: t('menu.hosts'),
|
||||
keywords: ['hosts', 'sni', 'domains'],
|
||||
icon: <GlobalOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/outbound',
|
||||
title: t('menu.outbounds'),
|
||||
keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
|
||||
icon: <ExportOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/routing',
|
||||
title: t('menu.routing'),
|
||||
keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
|
||||
icon: <SwapOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
title: t('menu.settings'),
|
||||
keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
|
||||
icon: <SettingOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/xray',
|
||||
title: t('menu.xray'),
|
||||
keywords: ['xray', 'templates', 'balancer', 'dns'],
|
||||
icon: <ToolOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/api-docs',
|
||||
title: t('menu.apiDocs'),
|
||||
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
||||
icon: <ApiOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/sponsors',
|
||||
title: t('menu.sponsors'),
|
||||
keywords: ['sponsors', 'sponsor', 'partners'],
|
||||
icon: <CrownOutlined aria-hidden />,
|
||||
},
|
||||
];
|
||||
const pages = PANEL_ROUTES.map((route) => ({
|
||||
path: route.path,
|
||||
title: t(route.titleKey),
|
||||
keywords: PALETTE_KEYWORDS[route.path] ?? [],
|
||||
icon: <route.icon aria-hidden />,
|
||||
}));
|
||||
|
||||
pages
|
||||
.filter((p) => matches(p.title, undefined, p.keywords))
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useEffect } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, waitFor } from 'storybook/test';
|
||||
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FormField } from './FormField';
|
||||
import { useZodForm } from './useZodForm';
|
||||
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
|
||||
@@ -60,7 +60,8 @@ const ClientSchema = z.object({
|
||||
});
|
||||
|
||||
function ClientDemo() {
|
||||
const methods = useZodForm(ClientSchema, {
|
||||
const methods = useForm<z.infer<typeof ClientSchema>>({
|
||||
resolver: zodResolver(ClientSchema),
|
||||
defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true },
|
||||
});
|
||||
return (
|
||||
@@ -95,8 +96,11 @@ const TrafficSchema = z.object({
|
||||
});
|
||||
|
||||
function TrafficDemo() {
|
||||
const methods = useZodForm(TrafficSchema, { defaultValues: { totalBytes: 50 * GB } });
|
||||
const totalBytes = methods.watch('totalBytes');
|
||||
const methods = useForm<z.infer<typeof TrafficSchema>>({
|
||||
resolver: zodResolver(TrafficSchema),
|
||||
defaultValues: { totalBytes: 50 * GB },
|
||||
});
|
||||
const totalBytes = useWatch({ control: methods.control, name: 'totalBytes' });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical" style={{ maxWidth: 360 }}>
|
||||
@@ -128,7 +132,10 @@ const InboundSchema = z.object({
|
||||
});
|
||||
|
||||
function ValidationDemo() {
|
||||
const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } });
|
||||
const methods = useForm<z.infer<typeof InboundSchema>>({
|
||||
resolver: zodResolver(InboundSchema),
|
||||
defaultValues: { remark: '', port: 0 },
|
||||
});
|
||||
useEffect(() => {
|
||||
void methods.trigger();
|
||||
}, [methods]);
|
||||
@@ -157,7 +164,8 @@ const RealitySchema = z.object({
|
||||
});
|
||||
|
||||
function NestedDemo() {
|
||||
const methods = useZodForm(RealitySchema, {
|
||||
const methods = useForm<z.infer<typeof RealitySchema>>({
|
||||
resolver: zodResolver(RealitySchema),
|
||||
defaultValues: {
|
||||
streamSettings: {
|
||||
realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { FormField, type FormFieldProps } from './FormField';
|
||||
export { useZodForm } from './useZodForm';
|
||||
export { rhfZodValidate } from './rhfZodValidate';
|
||||
export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
|
||||
export { toDotted, type FieldName } from './toDotted';
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { FieldValues, Resolver, UseFormProps, UseFormReturn } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export function useZodForm<TFieldValues extends FieldValues>(
|
||||
schema: z.ZodType<TFieldValues>,
|
||||
options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
|
||||
): UseFormReturn<TFieldValues> {
|
||||
const resolver = zodResolver(
|
||||
schema as z.ZodType<TFieldValues, TFieldValues>,
|
||||
) as Resolver<TFieldValues>;
|
||||
return useForm<TFieldValues>({
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onChange',
|
||||
shouldUnregister: false,
|
||||
...options,
|
||||
resolver,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user