mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 10:26:38 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -30,49 +31,51 @@ import type {
|
||||
NodeType,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
const nodeSchema = z
|
||||
.object({
|
||||
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
|
||||
name: z.string().trim().min(1, '请输入节点名称').max(255),
|
||||
ip: z.string(),
|
||||
ip_manual_override: z.boolean(),
|
||||
auto_update_enabled: z.boolean(),
|
||||
geo_manual_override: z.boolean(),
|
||||
geo_name: z.string(),
|
||||
geo_latitude: z.string(),
|
||||
geo_longitude: z.string(),
|
||||
relay_bind_port: z.string(),
|
||||
relay_vhost_http_port: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.ip_manual_override && !value.ip.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['ip'],
|
||||
message: '锁定 IP 时必须填写节点 IP',
|
||||
});
|
||||
}
|
||||
if (value.node_type === 'tunnel_relay') {
|
||||
const bindPort = Number(value.relay_bind_port);
|
||||
const vhostPort = Number(value.relay_vhost_http_port);
|
||||
if (!Number.isFinite(bindPort) || bindPort <= 0) {
|
||||
function buildNodeSchema(t: (key: string) => string) {
|
||||
return z
|
||||
.object({
|
||||
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
|
||||
name: z.string().trim().min(1, t('editor.errName')).max(255),
|
||||
ip: z.string(),
|
||||
ip_manual_override: z.boolean(),
|
||||
auto_update_enabled: z.boolean(),
|
||||
geo_manual_override: z.boolean(),
|
||||
geo_name: z.string(),
|
||||
geo_latitude: z.string(),
|
||||
geo_longitude: z.string(),
|
||||
relay_bind_port: z.string(),
|
||||
relay_vhost_http_port: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.ip_manual_override && !value.ip.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_bind_port'],
|
||||
message: '请输入有效的绑定端口',
|
||||
path: ['ip'],
|
||||
message: t('editor.errLockIp'),
|
||||
});
|
||||
}
|
||||
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_vhost_http_port'],
|
||||
message: '请输入有效的 VHost 端口',
|
||||
});
|
||||
if (value.node_type === 'tunnel_relay') {
|
||||
const bindPort = Number(value.relay_bind_port);
|
||||
const vhostPort = Number(value.relay_vhost_http_port);
|
||||
if (!Number.isFinite(bindPort) || bindPort <= 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_bind_port'],
|
||||
message: t('editor.errBindPort'),
|
||||
});
|
||||
}
|
||||
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_vhost_http_port'],
|
||||
message: t('editor.errVhostPort'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type NodeFormValues = z.infer<typeof nodeSchema>;
|
||||
type NodeFormValues = z.infer<ReturnType<typeof buildNodeSchema>>;
|
||||
|
||||
const defaultForm: NodeFormValues = {
|
||||
node_type: 'edge_node',
|
||||
@@ -156,8 +159,10 @@ export function NodeEditorDialog({
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: NodeMutationPayload) => Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations('nodes');
|
||||
const tc = useTranslations('common');
|
||||
const form = useForm<NodeFormValues>({
|
||||
resolver: zodResolver(nodeSchema),
|
||||
resolver: zodResolver(buildNodeSchema(t)),
|
||||
defaultValues: defaultForm,
|
||||
});
|
||||
|
||||
@@ -179,10 +184,10 @@ export function NodeEditorDialog({
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{node ? '编辑节点' : '新增节点'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
预创建节点后可在详情页查看专属 Token 与部署命令。
|
||||
</DialogDescription>
|
||||
<DialogTitle>
|
||||
{node ? t('editor.editTitle') : t('editor.createTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('editor.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
@@ -190,7 +195,7 @@ export function NodeEditorDialog({
|
||||
className='space-y-4'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<Label>节点类型</Label>
|
||||
<Label>{t('editor.nodeType')}</Label>
|
||||
<Select
|
||||
value={nodeType}
|
||||
disabled={Boolean(node)}
|
||||
@@ -202,18 +207,22 @@ export function NodeEditorDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='edge_node'>Edge 节点</SelectItem>
|
||||
<SelectItem value='tunnel_relay'>Relay 节点</SelectItem>
|
||||
<SelectItem value='tunnel_client'>Tunnel 节点</SelectItem>
|
||||
<SelectItem value='edge_node'>{t('editor.typeEdge')}</SelectItem>
|
||||
<SelectItem value='tunnel_relay'>
|
||||
{t('editor.typeRelay')}
|
||||
</SelectItem>
|
||||
<SelectItem value='tunnel_client'>
|
||||
{t('editor.typeTunnel')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='node-name'>节点名称</Label>
|
||||
<Label htmlFor='node-name'>{t('editor.name')}</Label>
|
||||
<Input
|
||||
id='node-name'
|
||||
placeholder='例如 edge-hk-01'
|
||||
placeholder={t('editor.namePlaceholder')}
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{form.formState.errors.name ? (
|
||||
@@ -224,10 +233,10 @@ export function NodeEditorDialog({
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='node-ip'>节点 IP</Label>
|
||||
<Label htmlFor='node-ip'>{t('editor.ip')}</Label>
|
||||
<Input
|
||||
id='node-ip'
|
||||
placeholder='可选,接入后自动上报'
|
||||
placeholder={t('editor.ipPlaceholder')}
|
||||
{...form.register('ip')}
|
||||
/>
|
||||
{form.formState.errors.ip ? (
|
||||
@@ -239,9 +248,9 @@ export function NodeEditorDialog({
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>锁定 IP</p>
|
||||
<p className='text-sm font-medium'>{t('editor.lockIp')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
启用后管理端不再自动覆盖 IP
|
||||
{t('editor.lockIpDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -254,9 +263,9 @@ export function NodeEditorDialog({
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>自动更新 Agent</p>
|
||||
<p className='text-sm font-medium'>{t('editor.autoUpdate')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
启用后节点将自动拉取正式版更新
|
||||
{t('editor.autoUpdateDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -270,7 +279,7 @@ export function NodeEditorDialog({
|
||||
{nodeType === 'tunnel_relay' ? (
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='relay-bind-port'>绑定端口</Label>
|
||||
<Label htmlFor='relay-bind-port'>{t('editor.bindPort')}</Label>
|
||||
<Input
|
||||
id='relay-bind-port'
|
||||
{...form.register('relay_bind_port')}
|
||||
@@ -282,7 +291,9 @@ export function NodeEditorDialog({
|
||||
) : null}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='relay-vhost-port'>VHost 端口</Label>
|
||||
<Label htmlFor='relay-vhost-port'>
|
||||
{t('editor.vhostPort')}
|
||||
</Label>
|
||||
<Input
|
||||
id='relay-vhost-port'
|
||||
{...form.register('relay_vhost_http_port')}
|
||||
@@ -298,8 +309,10 @@ export function NodeEditorDialog({
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>手动地图点位</p>
|
||||
<p className='text-xs text-muted-foreground'>用于总览地图展示</p>
|
||||
<p className='text-sm font-medium'>{t('editor.manualGeo')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{t('editor.manualGeoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={geoManualOverride}
|
||||
@@ -312,15 +325,15 @@ export function NodeEditorDialog({
|
||||
{geoManualOverride ? (
|
||||
<div className='grid gap-3 sm:grid-cols-3'>
|
||||
<div className='space-y-2 sm:col-span-3'>
|
||||
<Label htmlFor='geo-name'>位置名称</Label>
|
||||
<Label htmlFor='geo-name'>{t('editor.geoName')}</Label>
|
||||
<Input id='geo-name' {...form.register('geo_name')} />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='geo-lat'>纬度</Label>
|
||||
<Label htmlFor='geo-lat'>{t('editor.latitude')}</Label>
|
||||
<Input id='geo-lat' {...form.register('geo_latitude')} />
|
||||
</div>
|
||||
<div className='space-y-2 sm:col-span-2'>
|
||||
<Label htmlFor='geo-lng'>经度</Label>
|
||||
<Label htmlFor='geo-lng'>{t('editor.longitude')}</Label>
|
||||
<Input id='geo-lng' {...form.register('geo_longitude')} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,10 +346,14 @@ export function NodeEditorDialog({
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</Button>
|
||||
<Button type='submit' disabled={submitting}>
|
||||
{submitting ? '保存中...' : node ? '保存修改' : '新增节点'}
|
||||
{submitting
|
||||
? t('editor.saving')
|
||||
: node
|
||||
? t('editor.saveEdit')
|
||||
: t('editor.createAction')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user