feat(frontend): 控制台 next-intl 中英双语

接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
ryan
2026-08-16 16:49:16 +08:00
parent 64fbaa7ef1
commit 5a8722ff07
219 changed files with 21437 additions and 5212 deletions
@@ -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>