mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
feat(cloudflare): add custom targets and node failover (#43)
* feat(cloudflare): add custom targets and node failover * fix(cloudflare): propagate batch sync errors --------- Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type {
|
||||
CloudflareGroup,
|
||||
CloudflareGroupPayload,
|
||||
@@ -50,7 +51,15 @@ export function GroupDialog({
|
||||
() => nodes.filter((node) => node.node_type === 'edge_node'),
|
||||
[nodes],
|
||||
);
|
||||
const recordContentPlaceholders = {
|
||||
CNAME: t('recordContentPlaceholder.CNAME'),
|
||||
A: t('recordContentPlaceholder.A'),
|
||||
AAAA: t('recordContentPlaceholder.AAAA'),
|
||||
};
|
||||
const [name, setName] = useState('');
|
||||
const [targetMode, setTargetMode] = useState<'node' | 'custom'>('node');
|
||||
const [recordType, setRecordType] = useState<'CNAME' | 'A' | 'AAAA'>('A');
|
||||
const [recordContent, setRecordContent] = useState('');
|
||||
const [primaryNodeID, setPrimaryNodeID] = useState('');
|
||||
const [backupNodeID, setBackupNodeID] = useState('none');
|
||||
const [defaultProxied, setDefaultProxied] = useState(true);
|
||||
@@ -59,8 +68,19 @@ export function GroupDialog({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(group?.name ?? '');
|
||||
setPrimaryNodeID(group ? String(group.primary_node.id) : '');
|
||||
setBackupNodeID(group?.backup_node ? String(group.backup_node.id) : 'none');
|
||||
setTargetMode(group?.target_mode === 'custom' ? 'custom' : 'node');
|
||||
setRecordType(group?.record_type ?? 'A');
|
||||
setRecordContent(group?.record_content ?? '');
|
||||
setPrimaryNodeID(
|
||||
group?.target_mode !== 'custom' && group
|
||||
? String(group.primary_node.id)
|
||||
: '',
|
||||
);
|
||||
setBackupNodeID(
|
||||
group?.target_mode !== 'custom' && group?.backup_node
|
||||
? String(group.backup_node.id)
|
||||
: 'none',
|
||||
);
|
||||
setDefaultProxied(group?.default_proxied ?? true);
|
||||
setEnabled(group?.enabled ?? true);
|
||||
}, [group, open]);
|
||||
@@ -82,46 +102,113 @@ export function GroupDialog({
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-primary-node'>{t('primary')}</FieldLabel>
|
||||
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
|
||||
<SelectTrigger id='cf-primary-node' className='w-full'>
|
||||
<SelectValue placeholder={t('primaryPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{edgeNodes.map((node) => (
|
||||
<SelectItem
|
||||
key={node.id}
|
||||
value={String(node.id)}
|
||||
disabled={!node.ip}
|
||||
>
|
||||
{node.name} · {node.ip || t('noIp')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
|
||||
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
|
||||
<SelectTrigger id='cf-backup-node' className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='none'>{t('none')}</SelectItem>
|
||||
{edgeNodes
|
||||
.filter((node) => String(node.id) !== primaryNodeID)
|
||||
.map((node) => (
|
||||
<SelectItem key={node.id} value={String(node.id)}>
|
||||
{node.name} · {node.ip || t('noIp')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldLabel id='cf-target-mode-label'>{t('targetMode')}</FieldLabel>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
variant='outline'
|
||||
value={targetMode}
|
||||
aria-labelledby='cf-target-mode-label'
|
||||
className='grid w-full grid-cols-2'
|
||||
onValueChange={(value) => {
|
||||
if (value === 'node' || value === 'custom') {
|
||||
setTargetMode(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value='node' className='w-full'>
|
||||
{t('bindNode')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value='custom' className='w-full'>
|
||||
{t('customTarget')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</Field>
|
||||
{targetMode === 'node' ? (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-primary-node'>
|
||||
{t('primary')}
|
||||
</FieldLabel>
|
||||
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
|
||||
<SelectTrigger id='cf-primary-node' className='w-full'>
|
||||
<SelectValue placeholder={t('primaryPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{edgeNodes.map((node) => (
|
||||
<SelectItem
|
||||
key={node.id}
|
||||
value={String(node.id)}
|
||||
disabled={!node.ip}
|
||||
>
|
||||
{node.name} · {node.ip || t('noIp')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
|
||||
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
|
||||
<SelectTrigger id='cf-backup-node' className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='none'>{t('none')}</SelectItem>
|
||||
{edgeNodes
|
||||
.filter((node) => String(node.id) !== primaryNodeID)
|
||||
.map((node) => (
|
||||
<SelectItem key={node.id} value={String(node.id)}>
|
||||
{node.name} · {node.ip || t('noIp')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-record-type'>
|
||||
{t('recordType')}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={recordType}
|
||||
onValueChange={(value: 'CNAME' | 'A' | 'AAAA') =>
|
||||
setRecordType(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id='cf-record-type' className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='CNAME'>CNAME</SelectItem>
|
||||
<SelectItem value='A'>A</SelectItem>
|
||||
<SelectItem value='AAAA'>AAAA</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-record-content'>
|
||||
{t('recordContent')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='cf-record-content'
|
||||
value={recordContent}
|
||||
placeholder={recordContentPlaceholders[recordType]}
|
||||
onChange={(event) => setRecordContent(event.target.value)}
|
||||
/>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{t('recordContentHint')}
|
||||
</p>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field orientation='horizontal'>
|
||||
<FieldLabel htmlFor='cf-default-proxied'>
|
||||
{t('defaultProxied')}
|
||||
@@ -146,13 +233,23 @@ export function GroupDialog({
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pending || !name.trim() || !primaryNodeID}
|
||||
disabled={
|
||||
pending ||
|
||||
!name.trim() ||
|
||||
(targetMode === 'node' ? !primaryNodeID : !recordContent.trim())
|
||||
}
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
primary_node_id: Number(primaryNodeID),
|
||||
target_mode: targetMode,
|
||||
record_type: recordType,
|
||||
record_content: recordContent.trim(),
|
||||
primary_node_id:
|
||||
targetMode === 'node' ? Number(primaryNodeID) : 0,
|
||||
backup_node_id:
|
||||
backupNodeID === 'none' ? null : Number(backupNodeID),
|
||||
targetMode === 'node' && backupNodeID !== 'none'
|
||||
? Number(backupNodeID)
|
||||
: null,
|
||||
default_proxied: defaultProxied,
|
||||
enabled,
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowRightLeft,
|
||||
Cloud,
|
||||
CloudCog,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -239,6 +240,16 @@ export function CloudflareGroupDetailPageClient() {
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
const batchEnableProxyMutation = useMutation({
|
||||
mutationFn: (memberIds: number[]) =>
|
||||
CloudflareService.batchEnableProxy(groupID, memberIds),
|
||||
onSuccess: async (_, memberIds) => {
|
||||
toast.success(t('batchProxyEnabled', { count: memberIds.length }));
|
||||
setSelectedMemberIDs(new Set());
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
const handleSingleMove = (member: CloudflareMember) => {
|
||||
setMovingMembers([member]);
|
||||
@@ -324,23 +335,39 @@ export function CloudflareGroupDetailPageClient() {
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>{t('currentPoint')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('activeNode', {
|
||||
name: group.active_node.name,
|
||||
ip: group.active_node.ip,
|
||||
})}
|
||||
{group.target_mode === 'custom'
|
||||
? t('customTargetSummary', {
|
||||
type: group.record_type,
|
||||
content: group.record_content,
|
||||
})
|
||||
: t('activeNode', {
|
||||
name: group.active_node.name,
|
||||
ip: group.active_node.ip,
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-wrap gap-2'>
|
||||
<Badge variant={group.enabled ? 'default' : 'secondary'}>
|
||||
{group.enabled ? t('syncEnabled') : t('syncDisabled')}
|
||||
</Badge>
|
||||
{group.target_mode === 'node' ? (
|
||||
<>
|
||||
<Badge variant='outline'>
|
||||
{t('primaryNode', { name: group.primary_node.name })}
|
||||
</Badge>
|
||||
<Badge variant='outline'>
|
||||
{t('backupNode', {
|
||||
name: group.backup_node?.name ?? t('backupUnset'),
|
||||
})}
|
||||
</Badge>
|
||||
</>
|
||||
) : null}
|
||||
<Badge variant='outline'>
|
||||
{t('primaryNode', { name: group.primary_node.name })}
|
||||
</Badge>
|
||||
<Badge variant='outline'>
|
||||
{t('backupNode', {
|
||||
name: group.backup_node?.name ?? t('backupUnset'),
|
||||
})}
|
||||
{t(
|
||||
group.target_mode === 'custom'
|
||||
? 'customTargetMode'
|
||||
: 'nodeTargetMode',
|
||||
)}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -356,6 +383,19 @@ export function CloudflareGroupDetailPageClient() {
|
||||
<Badge variant='secondary' className='text-xs'>
|
||||
{t('selectedCount', { count: selectedMembers.length })}
|
||||
</Badge>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
batchEnableProxyMutation.mutate(
|
||||
selectedMembers.map((member) => member.id),
|
||||
)
|
||||
}
|
||||
disabled={batchEnableProxyMutation.isPending}
|
||||
>
|
||||
<CloudCog data-icon='inline-start' />
|
||||
{t('batchEnableProxy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
@@ -485,27 +525,30 @@ export function CloudflareGroupDetailPageClient() {
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
size='icon'
|
||||
title={t('move')}
|
||||
aria-label={t('move')}
|
||||
onClick={() => handleSingleMove(member)}
|
||||
>
|
||||
<ArrowRightLeft data-icon='inline-start' />
|
||||
{t('move')}
|
||||
<ArrowRightLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
size='icon'
|
||||
title={t('sync')}
|
||||
aria-label={t('sync')}
|
||||
onClick={() => syncMutation.mutate(member.id)}
|
||||
>
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
{t('sync')}
|
||||
<RefreshCw />
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
size='icon'
|
||||
title={t('remove')}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => removeMutation.mutate(member.id)}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
{t('remove')}
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -182,7 +182,12 @@ export default function CloudflarePage() {
|
||||
<div>
|
||||
<CardTitle className='text-base'>{group.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{group.active_node.name} · {group.active_node.ip}
|
||||
{group.target_mode === 'custom'
|
||||
? t('customTargetSummary', {
|
||||
type: group.record_type,
|
||||
content: group.record_content,
|
||||
})
|
||||
: `${group.active_node.name} · ${group.active_node.ip}`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={group.enabled ? 'default' : 'secondary'}>
|
||||
|
||||
@@ -133,4 +133,10 @@ export class CloudflareService extends OpenFlareBaseService {
|
||||
member_ids: memberIds,
|
||||
});
|
||||
}
|
||||
|
||||
static batchEnableProxy(groupId: number, memberIds: number[]): Promise<void> {
|
||||
return this.post<void>(`/groups/${groupId}/members/batch-proxy`, {
|
||||
member_ids: memberIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1369,6 +1369,9 @@ export interface CloudflareNodeOption {
|
||||
export interface CloudflareGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
target_mode: 'node' | 'custom';
|
||||
record_type: 'CNAME' | 'A' | 'AAAA';
|
||||
record_content: string;
|
||||
primary_node: CloudflareNodeOption;
|
||||
backup_node: CloudflareNodeOption | null;
|
||||
active_node: CloudflareNodeOption;
|
||||
@@ -1381,6 +1384,9 @@ export interface CloudflareGroup {
|
||||
|
||||
export interface CloudflareGroupPayload {
|
||||
name: string;
|
||||
target_mode: 'node' | 'custom';
|
||||
record_type: 'CNAME' | 'A' | 'AAAA';
|
||||
record_content: string;
|
||||
primary_node_id: number;
|
||||
backup_node_id: number | null;
|
||||
default_proxied: boolean;
|
||||
|
||||
@@ -772,10 +772,10 @@
|
||||
"notReadyDesc": "Import an existing Cloudflare DNS account, or enter a standalone API token and test the connection.",
|
||||
"configure": "Configure connection",
|
||||
"groupsTitle": "Pointing groups",
|
||||
"groupsDesc": "Manage the nodes and domain members behind Cloudflare A records.",
|
||||
"groupsDesc": "Manage Cloudflare DNS targets and domain members.",
|
||||
"loadingGroups": "Loading pointing groups...",
|
||||
"emptyTitle": "No pointing groups",
|
||||
"emptyDesc": "After creating a group, you can sync domain members to selected edge nodes.",
|
||||
"emptyDesc": "Create a group to bind domain members to an edge node or a custom DNS target.",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"memberSummary": "{count} members · new members default orange-cloud {proxy}",
|
||||
@@ -788,7 +788,7 @@
|
||||
"syncQueued": "Group sync queued",
|
||||
"deleted": "Pointing group and remote records deleted",
|
||||
"deleteTitle": "Delete pointing group",
|
||||
"deleteDesc": "This deletes all members of {name} and the remote A records managed by this module. This cannot be undone.",
|
||||
"deleteDesc": "This deletes all members of {name} and the remote DNS records managed by this module. This cannot be undone.",
|
||||
"confirmDelete": "Delete",
|
||||
"loadingDetail": "Loading group details...",
|
||||
"groupUpdated": "Group settings updated",
|
||||
@@ -798,13 +798,18 @@
|
||||
"memberPartial": "Partial success: {succeeded} added, {failed} failed",
|
||||
"proxyUpdated": "Orange-cloud setting updated and queued for sync",
|
||||
"memberSyncQueued": "Member sync queued",
|
||||
"memberDeleted": "Member and remote A record deleted",
|
||||
"memberDeleted": "Member and remote DNS record deleted",
|
||||
"batchEnableProxy": "Enable orange cloud",
|
||||
"batchProxyEnabled": "Enabled orange cloud for {count} members and queued sync",
|
||||
"back": "Back to list",
|
||||
"refresh": "Refresh",
|
||||
"edit": "Edit",
|
||||
"addDomain": "Add domain",
|
||||
"currentPoint": "Current target",
|
||||
"activeNode": "Active node {name} · {ip}",
|
||||
"customTargetSummary": "Custom target {type} · {content}",
|
||||
"nodeTargetMode": "Node binding",
|
||||
"customTargetMode": "Custom record",
|
||||
"syncEnabled": "Sync enabled",
|
||||
"syncDisabled": "Sync disabled",
|
||||
"primaryNode": "Primary {name}",
|
||||
@@ -815,7 +820,7 @@
|
||||
"noMembers": "No domain members yet.",
|
||||
"columns": {
|
||||
"domain": "Domain",
|
||||
"desiredIp": "Desired IP",
|
||||
"desiredIp": "Desired value",
|
||||
"status": "Status",
|
||||
"proxied": "Orange cloud",
|
||||
"actions": "Actions"
|
||||
@@ -825,8 +830,19 @@
|
||||
"groupDialog": {
|
||||
"editTitle": "Edit pointing group",
|
||||
"createTitle": "New pointing group",
|
||||
"description": "Phase 1 uses the primary node as the active node. Backup nodes are saved only and do not fail over automatically.",
|
||||
"description": "Bind members to an edge node or assign a custom DNS record target.",
|
||||
"name": "Group name",
|
||||
"targetMode": "Target mode",
|
||||
"bindNode": "Bind node",
|
||||
"customTarget": "Custom",
|
||||
"recordType": "Record type",
|
||||
"recordContent": "Record value",
|
||||
"recordContentHint": "Enter an IPv4 for A, IPv6 for AAAA, or a target hostname for CNAME.",
|
||||
"recordContentPlaceholder": {
|
||||
"CNAME": "e.g. origin.example.com",
|
||||
"A": "e.g. 203.0.113.10",
|
||||
"AAAA": "e.g. 2001:db8::10"
|
||||
},
|
||||
"primary": "Primary node",
|
||||
"primaryPlaceholder": "Select an edge node with IPv4",
|
||||
"noIp": "No IP configured",
|
||||
@@ -838,7 +854,7 @@
|
||||
},
|
||||
"memberDialog": {
|
||||
"title": "Add domain members",
|
||||
"description": "Search and select in bulk. Joining creates or takes over the unique same-name A record. Multiple same-name A records are rejected.",
|
||||
"description": "Search and select in bulk. Joining creates or takes over the unique same-name DNS record used by this group.",
|
||||
"zoneDomain": "Zone domain",
|
||||
"searchPlaceholder": "Search domains or zones…",
|
||||
"selected": "{count} selected",
|
||||
|
||||
@@ -772,10 +772,10 @@
|
||||
"notReadyDesc": "请先导入现有 Cloudflare DNS 账号,或录入独立 API Token 并完成连接测试。",
|
||||
"configure": "配置连接",
|
||||
"groupsTitle": "指向分组",
|
||||
"groupsDesc": "管理 Cloudflare A 记录对应的节点与域名成员。",
|
||||
"groupsDesc": "管理 Cloudflare DNS 记录的指向方式与域名成员。",
|
||||
"loadingGroups": "加载指向分组中...",
|
||||
"emptyTitle": "暂无指向分组",
|
||||
"emptyDesc": "创建分组后,可将域名成员同步到指定的边缘节点。",
|
||||
"emptyDesc": "创建分组后,可将域名成员绑定到边缘节点,或同步到自定义 DNS 目标。",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已停用",
|
||||
"memberSummary": "成员 {count} 个 · 新成员默认{proxy}橙云",
|
||||
@@ -788,7 +788,7 @@
|
||||
"syncQueued": "分组同步任务已入队",
|
||||
"deleted": "指向分组及远端记录已删除",
|
||||
"deleteTitle": "删除指向分组",
|
||||
"deleteDesc": "将删除 {name} 的全部成员及本模块管理的远端 A 记录。此操作不可撤销。",
|
||||
"deleteDesc": "将删除 {name} 的全部成员及本模块管理的远端 DNS 记录。此操作不可撤销。",
|
||||
"confirmDelete": "确认删除",
|
||||
"loadingDetail": "加载分组详情中...",
|
||||
"groupUpdated": "分组配置已更新",
|
||||
@@ -798,13 +798,18 @@
|
||||
"memberPartial": "部分成功:{succeeded} 个已加入,{failed} 个失败",
|
||||
"proxyUpdated": "橙云设置已更新并排队同步",
|
||||
"memberSyncQueued": "成员同步任务已入队",
|
||||
"memberDeleted": "成员及远端 A 记录已删除",
|
||||
"memberDeleted": "成员及远端 DNS 记录已删除",
|
||||
"batchEnableProxy": "批量开启橙云",
|
||||
"batchProxyEnabled": "已为 {count} 个成员开启橙云并排队同步",
|
||||
"back": "返回列表",
|
||||
"refresh": "刷新",
|
||||
"edit": "编辑",
|
||||
"addDomain": "添加域名",
|
||||
"currentPoint": "当前指向",
|
||||
"activeNode": "生效节点 {name} · {ip}",
|
||||
"customTargetSummary": "自定义目标 {type} · {content}",
|
||||
"nodeTargetMode": "绑定节点",
|
||||
"customTargetMode": "自定义记录",
|
||||
"syncEnabled": "同步已启用",
|
||||
"syncDisabled": "同步已停用",
|
||||
"primaryNode": "主节点 {name}",
|
||||
@@ -815,7 +820,7 @@
|
||||
"noMembers": "暂无域名成员。",
|
||||
"columns": {
|
||||
"domain": "域名",
|
||||
"desiredIp": "期望 IP",
|
||||
"desiredIp": "期望值",
|
||||
"status": "状态",
|
||||
"proxied": "橙云",
|
||||
"actions": "操作"
|
||||
@@ -825,8 +830,19 @@
|
||||
"groupDialog": {
|
||||
"editTitle": "编辑指向分组",
|
||||
"createTitle": "新增指向分组",
|
||||
"description": "一期使用主节点作为生效节点;备用节点仅保存,不会自动切换。",
|
||||
"description": "选择绑定边缘节点,或为成员统一指定自定义 DNS 记录目标。",
|
||||
"name": "分组名称",
|
||||
"targetMode": "指向方式",
|
||||
"bindNode": "绑定节点",
|
||||
"customTarget": "自定义",
|
||||
"recordType": "记录类型",
|
||||
"recordContent": "记录值",
|
||||
"recordContentHint": "A 填 IPv4,AAAA 填 IPv6,CNAME 填目标域名。",
|
||||
"recordContentPlaceholder": {
|
||||
"CNAME": "例如:origin.example.com",
|
||||
"A": "例如:203.0.113.10",
|
||||
"AAAA": "例如:2001:db8::10"
|
||||
},
|
||||
"primary": "主节点",
|
||||
"primaryPlaceholder": "选择带 IPv4 的边缘节点",
|
||||
"noIp": "未配置 IP",
|
||||
@@ -838,7 +854,7 @@
|
||||
},
|
||||
"memberDialog": {
|
||||
"title": "添加域名成员",
|
||||
"description": "支持搜索筛选与批量勾选;加入后会创建或接管唯一同名 A 记录,多条同名 A 会拒绝同步。",
|
||||
"description": "支持搜索筛选与批量勾选;加入后会创建或接管当前分组指向所用的唯一同名 DNS 记录。",
|
||||
"zoneDomain": "Zone 域名",
|
||||
"searchPlaceholder": "搜索域名或顶级域…",
|
||||
"selected": "已选 {count}",
|
||||
|
||||
@@ -57,6 +57,9 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
const mockGroup = {
|
||||
id: 7,
|
||||
name: '生产节点',
|
||||
target_mode: 'node' as const,
|
||||
record_type: 'A' as const,
|
||||
record_content: '',
|
||||
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||
backup_node: null,
|
||||
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||
@@ -70,6 +73,9 @@ const mockGroup = {
|
||||
const mockTargetGroup = {
|
||||
id: 8,
|
||||
name: '备用分组',
|
||||
target_mode: 'node' as const,
|
||||
record_type: 'A' as const,
|
||||
record_content: '',
|
||||
primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
||||
backup_node: null,
|
||||
active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
||||
|
||||
@@ -109,6 +109,9 @@ describe('Cloudflare overview', () => {
|
||||
{
|
||||
id: 7,
|
||||
name: '生产节点',
|
||||
target_mode: 'node',
|
||||
record_type: 'A',
|
||||
record_content: '',
|
||||
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||
backup_node: null,
|
||||
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||
|
||||
Reference in New Issue
Block a user