mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 14:26:36 +08:00
0c2961ae6d
Use full country and ISO subdivision data, show localized country names with codes, and add searchable region selection.
391 lines
11 KiB
TypeScript
391 lines
11 KiB
TypeScript
import { Settings2 } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
Field,
|
|
FieldDescription,
|
|
FieldGroup,
|
|
FieldLabel,
|
|
} from '@/components/ui/field';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
|
|
|
|
import { countryOptions, regionOptions, type GeoOption } from './geo-options';
|
|
|
|
export function NodeProperties({
|
|
node,
|
|
ipGroups,
|
|
onChange,
|
|
}: {
|
|
node: WAFRuleNode;
|
|
ipGroups: WAFIPGroup[];
|
|
onChange: (node: WAFRuleNode) => void;
|
|
}) {
|
|
return (
|
|
<aside className='w-80 shrink-0 border-l bg-card'>
|
|
<ScrollArea className='h-full'>
|
|
<div className='flex flex-col gap-5 p-5'>
|
|
<div className='flex items-center gap-2'>
|
|
<Settings2 className='size-5 text-primary' />
|
|
<div>
|
|
<h2 className='text-sm font-semibold'>节点属性</h2>
|
|
<p className='text-xs text-muted-foreground'>配置当前处理单元</p>
|
|
</div>
|
|
</div>
|
|
<Separator />
|
|
<PropertyFields node={node} ipGroups={ipGroups} onChange={onChange} />
|
|
</div>
|
|
</ScrollArea>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function PropertyFields({
|
|
node,
|
|
ipGroups,
|
|
onChange,
|
|
}: {
|
|
node: WAFRuleNode;
|
|
ipGroups: WAFIPGroup[];
|
|
onChange: (node: WAFRuleNode) => void;
|
|
}) {
|
|
if (node.type === 'start' || node.type === 'allow')
|
|
return <p className='text-sm text-muted-foreground'>系统节点无需配置。</p>;
|
|
if (node.type === 'ip_match')
|
|
return (
|
|
<FieldGroup>
|
|
<CsvField
|
|
id={`${node.id}-ips`}
|
|
label='IP 地址'
|
|
value={node.config.ips}
|
|
onChange={(ips) =>
|
|
onChange({ ...node, config: { ...node.config, ips } })
|
|
}
|
|
/>
|
|
<CsvField
|
|
id={`${node.id}-cidrs`}
|
|
label='CIDR 网段'
|
|
value={node.config.cidrs}
|
|
onChange={(cidrs) =>
|
|
onChange({ ...node, config: { ...node.config, cidrs } })
|
|
}
|
|
/>
|
|
<MultiSelect
|
|
id={`${node.id}-groups`}
|
|
label='IP 组'
|
|
options={ipGroups.map((group) => ({
|
|
value: String(group.id),
|
|
label: group.name,
|
|
searchText: `${group.name} ${group.id}`,
|
|
}))}
|
|
value={node.config.ip_group_ids.map(String)}
|
|
onChange={(values) =>
|
|
onChange({
|
|
...node,
|
|
config: { ...node.config, ip_group_ids: values.map(Number) },
|
|
})
|
|
}
|
|
/>
|
|
</FieldGroup>
|
|
);
|
|
if (node.type === 'geo_match')
|
|
return (
|
|
<FieldGroup>
|
|
<MultiSelect
|
|
id={`${node.id}-countries`}
|
|
label='国家代码'
|
|
description={`共 ${countryOptions.length} 个国家/地区,显示国家名与 ISO 代码`}
|
|
options={countryOptions}
|
|
value={node.config.countries}
|
|
creatablePattern={/^[A-Z]{2}$/}
|
|
onChange={(countries) =>
|
|
onChange({ ...node, config: { ...node.config, countries } })
|
|
}
|
|
/>
|
|
<MultiSelect
|
|
id={`${node.id}-regions`}
|
|
label='地区代码'
|
|
description={`共 ${regionOptions.length} 个一级行政区,输入名称或代码搜索`}
|
|
options={regionOptions}
|
|
value={node.config.regions}
|
|
creatablePattern={/^[A-Z]{2}-[A-Z0-9]{1,3}$/}
|
|
searchRequired
|
|
onChange={(regions) =>
|
|
onChange({ ...node, config: { ...node.config, regions } })
|
|
}
|
|
/>
|
|
</FieldGroup>
|
|
);
|
|
if (node.type === 'pow')
|
|
return (
|
|
<FieldGroup>
|
|
<Field>
|
|
<FieldLabel htmlFor={`${node.id}-algorithm`}>算法</FieldLabel>
|
|
<Select
|
|
value={node.config.algorithm}
|
|
onValueChange={(algorithm: 'fast' | 'slow') =>
|
|
onChange({ ...node, config: { ...node.config, algorithm } })
|
|
}
|
|
>
|
|
<SelectTrigger id={`${node.id}-algorithm`} className='w-full'>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value='fast'>快速</SelectItem>
|
|
<SelectItem value='slow'>稳健</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
{(['difficulty', 'session_ttl', 'challenge_ttl'] as const).map(
|
|
(key) => (
|
|
<NumberField
|
|
id={`${node.id}-${key}`}
|
|
key={key}
|
|
min={{ difficulty: 1, session_ttl: 60, challenge_ttl: 30 }[key]}
|
|
max={key === 'difficulty' ? 16 : undefined}
|
|
label={
|
|
{
|
|
difficulty: '难度',
|
|
session_ttl: '会话 TTL(秒)',
|
|
challenge_ttl: '挑战 TTL(秒)',
|
|
}[key]
|
|
}
|
|
value={node.config[key]}
|
|
onChange={(value) =>
|
|
onChange({ ...node, config: { ...node.config, [key]: value } })
|
|
}
|
|
/>
|
|
),
|
|
)}
|
|
</FieldGroup>
|
|
);
|
|
return (
|
|
<FieldGroup>
|
|
<NumberField
|
|
id={`${node.id}-status`}
|
|
min={400}
|
|
max={599}
|
|
label='HTTP 状态码'
|
|
value={node.config.status_code}
|
|
onChange={(status_code) =>
|
|
onChange({ ...node, config: { ...node.config, status_code } })
|
|
}
|
|
/>
|
|
<Field>
|
|
<FieldLabel htmlFor={`${node.id}-body`}>HTML 响应体</FieldLabel>
|
|
<Textarea
|
|
id={`${node.id}-body`}
|
|
rows={9}
|
|
value={node.config.response_body}
|
|
onChange={(event) =>
|
|
onChange({
|
|
...node,
|
|
config: { ...node.config, response_body: event.target.value },
|
|
})
|
|
}
|
|
/>
|
|
<FieldDescription>
|
|
{new TextEncoder().encode(node.config.response_body).length} / 16384
|
|
字节
|
|
</FieldDescription>
|
|
</Field>
|
|
</FieldGroup>
|
|
);
|
|
}
|
|
|
|
function CsvField({
|
|
id,
|
|
label,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
value: string[];
|
|
onChange: (value: string[]) => void;
|
|
}) {
|
|
return (
|
|
<Field>
|
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
|
<Textarea
|
|
id={id}
|
|
value={value.join('\n')}
|
|
onChange={(event) =>
|
|
onChange(
|
|
event.target.value
|
|
.split(/[\n,]/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean),
|
|
)
|
|
}
|
|
/>
|
|
<FieldDescription>每行一个值</FieldDescription>
|
|
</Field>
|
|
);
|
|
}
|
|
function NumberField({
|
|
id,
|
|
label,
|
|
value,
|
|
min,
|
|
max,
|
|
onChange,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
value: number;
|
|
min?: number;
|
|
max?: number;
|
|
onChange: (value: number) => void;
|
|
}) {
|
|
return (
|
|
<Field>
|
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
|
<Input
|
|
id={id}
|
|
min={min}
|
|
max={max}
|
|
type='number'
|
|
value={value}
|
|
onChange={(event) => onChange(Number(event.target.value))}
|
|
/>
|
|
</Field>
|
|
);
|
|
}
|
|
|
|
function MultiSelect({
|
|
id,
|
|
label,
|
|
description,
|
|
options,
|
|
value,
|
|
creatablePattern,
|
|
searchRequired = false,
|
|
onChange,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
options: GeoOption[];
|
|
value: string[];
|
|
creatablePattern?: RegExp;
|
|
searchRequired?: boolean;
|
|
onChange: (value: string[]) => void;
|
|
}) {
|
|
const [draft, setDraft] = useState('');
|
|
const normalized = draft.trim().toUpperCase();
|
|
const query = draft.trim().toLocaleLowerCase();
|
|
const available = [
|
|
...options,
|
|
...value
|
|
.filter(
|
|
(selected) => !options.some((option) => option.value === selected),
|
|
)
|
|
.map((selected) => ({
|
|
value: selected,
|
|
label: selected,
|
|
searchText: selected,
|
|
})),
|
|
];
|
|
const visible = available.filter((option) => {
|
|
if (!query) return !searchRequired || value.includes(option.value);
|
|
return option.searchText.toLocaleLowerCase().includes(query);
|
|
});
|
|
const canCreate = Boolean(
|
|
creatablePattern?.test(normalized) && !value.includes(normalized),
|
|
);
|
|
return (
|
|
<Field>
|
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button id={id} variant='outline' className='w-full justify-start'>
|
|
{value.length ? `已选择 ${value.length} 项` : '请选择'}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align='start' className='flex w-80 flex-col gap-2 p-3'>
|
|
{(creatablePattern || options.length > 0) && (
|
|
<div className='flex gap-2'>
|
|
<Input
|
|
aria-label={`新建${label}`}
|
|
placeholder='搜索名称或代码'
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
/>
|
|
{creatablePattern && (
|
|
<Button
|
|
size='sm'
|
|
disabled={!canCreate}
|
|
onClick={() => {
|
|
onChange([...value, normalized]);
|
|
setDraft('');
|
|
}}
|
|
>
|
|
添加代码
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className='max-h-56 space-y-1 overflow-y-auto pr-1'>
|
|
{visible.length === 0 ? (
|
|
<p className='px-1 py-3 text-sm text-muted-foreground'>
|
|
{searchRequired && !query
|
|
? `输入名称或代码搜索 ${options.length} 个选项`
|
|
: '没有匹配的选项'}
|
|
</p>
|
|
) : (
|
|
visible.map((option) => (
|
|
<label
|
|
key={option.value}
|
|
className='flex min-h-8 cursor-pointer items-center gap-2 rounded-md px-1 text-sm hover:bg-accent'
|
|
>
|
|
<Checkbox
|
|
checked={value.includes(option.value)}
|
|
onCheckedChange={(checked) =>
|
|
onChange(
|
|
checked
|
|
? [...value, option.value]
|
|
: value.filter((item) => item !== option.value),
|
|
)
|
|
}
|
|
/>
|
|
<span
|
|
className='min-w-0 flex-1 truncate'
|
|
title={option.label}
|
|
>
|
|
{option.label}
|
|
</span>
|
|
<span className='font-mono text-xs text-muted-foreground'>
|
|
{option.value}
|
|
</span>
|
|
</label>
|
|
))
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{description && <FieldDescription>{description}</FieldDescription>}
|
|
</Field>
|
|
);
|
|
}
|