feat(wavelet): 实装完整 PoW 配置面板

迁移旧前端 PoW 难度/TTL 与 IP/路径/UA 黑白名单编辑,替换占位提示。
This commit is contained in:
ryan
2026-06-18 20:21:17 +08:00
parent dde0117a37
commit 89b3f5d843
3 changed files with 241 additions and 109 deletions
@@ -45,11 +45,11 @@ const authSchema = z
.superRefine((value, context) => {
if (value.auth_mode === 'pow') {
const difficulty = Number(value.pow_difficulty);
if (!Number.isFinite(difficulty) || difficulty < 1 || difficulty > 10) {
if (!Number.isFinite(difficulty) || difficulty < 1 || difficulty > 16) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pow_difficulty'],
message: '难度需为 1-10 的整数',
message: '难度需为 1-16 的整数',
});
}
@@ -102,18 +102,14 @@ function resolveAuthMode(route: ProxyRouteItem): AuthValues['auth_mode'] {
return 'none';
}
function buildPowConfig(
values: AuthValues,
route: ProxyRouteItem,
currentConfig: ProxyRoutePoWConfig,
): ProxyRoutePoWConfig {
return {
...currentConfig,
difficulty: Number(values.pow_difficulty),
algorithm: values.pow_algorithm,
session_ttl: Number(values.pow_session_ttl),
challenge_ttl: Number(values.pow_challenge_ttl),
};
function syncPowFormValues(
setValue: ReturnType<typeof useForm<AuthValues>>['setValue'],
config: ProxyRoutePoWConfig,
) {
setValue('pow_difficulty', String(config.difficulty));
setValue('pow_algorithm', config.algorithm);
setValue('pow_session_ttl', String(config.session_ttl));
setValue('pow_challenge_ttl', String(config.challenge_ttl));
}
interface AuthSectionProps {
@@ -171,7 +167,8 @@ export function AuthSection({ route, onRouteUpdate, onSavingChange }: AuthSectio
id={proxyRouteFormIds.auth}
className="space-y-5"
onSubmit={form.handleSubmit(async (values) => {
const nextPowConfig = buildPowConfig(values, route, powConfig);
const nextPowConfig =
values.auth_mode === 'pow' ? powConfig : route.pow_config ?? defaultPowConfig;
const powEnabled = values.auth_mode === 'pow' || values.pow_enabled;
await save(
@@ -247,82 +244,15 @@ export function AuthSection({ route, onRouteUpdate, onSavingChange }: AuthSectio
) : null}
{authMode === 'pow' ? (
<div className="space-y-4 rounded-lg border border-dashed p-4">
<PowConfigPanel
enabled={form.watch('pow_enabled')}
config={powConfig}
onChange={(enabled, config) => {
form.setValue('pow_enabled', enabled);
setPowConfig(config);
}}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="pow_difficulty"
render={({ field }) => (
<FormItem>
<FormLabel>难度</FormLabel>
<FormControl>
<Input placeholder="4" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pow_algorithm"
render={({ field }) => (
<FormItem>
<FormLabel>算法</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fast">fast</SelectItem>
<SelectItem value="slow">slow</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pow_session_ttl"
render={({ field }) => (
<FormItem>
<FormLabel>会话 TTL (秒)</FormLabel>
<FormControl>
<Input placeholder="600" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pow_challenge_ttl"
render={({ field }) => (
<FormItem>
<FormLabel>挑战 TTL (秒)</FormLabel>
<FormControl>
<Input placeholder="300" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<PowConfigPanel
enabled={form.watch('pow_enabled')}
config={powConfig}
onChange={(enabled, config) => {
form.setValue('pow_enabled', enabled);
setPowConfig(config);
syncPowFormValues(form.setValue, config);
}}
/>
) : null}
</form>
</Form>
@@ -1,43 +1,244 @@
'use client';
import {Info} from 'lucide-react';
import {useEffect, useState} from 'react';
import {Switch} from '@/components/ui/switch';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import {Textarea} from '@/components/ui/textarea';
import type {ProxyRoutePoWConfig} from '@/lib/services/openflare';
import {listToText, parseTextareaList} from './helpers';
interface PowConfigPanelProps {
enabled: boolean;
config: ProxyRoutePoWConfig;
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
}
export function PowConfigPanel({ enabled, config, onChange }: PowConfigPanelProps) {
return (
<div className="space-y-4">
<div className="flex gap-3 rounded-lg border border-dashed bg-muted/40 p-4 text-sm">
<Info className="size-4 shrink-0 text-primary mt-0.5" />
<div className="space-y-1">
<p className="font-medium">PoW 配置面板(占位)</p>
<p className="text-muted-foreground text-xs">
完整 PoW 黑白名单与难度参数编辑将在后续迭代中实装。当前可先切换启用状态并保存规则组。
</p>
</div>
</div>
type PowListDraft = {
whitelist: Record<keyof ProxyRoutePoWConfig['whitelist'], string>;
blacklist: Record<keyof ProxyRoutePoWConfig['blacklist'], string>;
};
function buildDraft(config: ProxyRoutePoWConfig): PowListDraft {
return {
whitelist: {
ips: listToText(config.whitelist?.ips),
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
paths: listToText(config.whitelist?.paths),
path_regexes: listToText(config.whitelist?.path_regexes),
user_agents: listToText(config.whitelist?.user_agents),
},
blacklist: {
ips: listToText(config.blacklist?.ips),
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
paths: listToText(config.blacklist?.paths),
path_regexes: listToText(config.blacklist?.path_regexes),
user_agents: listToText(config.blacklist?.user_agents),
},
};
}
function applyDraft(
config: ProxyRoutePoWConfig,
draft: PowListDraft,
): ProxyRoutePoWConfig {
return {
...config,
whitelist: {
ips: parseTextareaList(draft.whitelist.ips),
ip_cidrs: parseTextareaList(draft.whitelist.ip_cidrs),
paths: parseTextareaList(draft.whitelist.paths),
path_regexes: parseTextareaList(draft.whitelist.path_regexes),
user_agents: parseTextareaList(draft.whitelist.user_agents),
},
blacklist: {
ips: parseTextareaList(draft.blacklist.ips),
ip_cidrs: parseTextareaList(draft.blacklist.ip_cidrs),
paths: parseTextareaList(draft.blacklist.paths),
path_regexes: parseTextareaList(draft.blacklist.path_regexes),
user_agents: parseTextareaList(draft.blacklist.user_agents),
},
};
}
function PowListCard({
title,
scope,
draft,
onUpdate,
}: {
title: string;
scope: keyof PowListDraft;
draft: PowListDraft;
onUpdate: (
scope: keyof PowListDraft,
key: keyof ProxyRoutePoWConfig['whitelist'],
value: string,
) => void;
}) {
const fields: Array<{
key: keyof ProxyRoutePoWConfig['whitelist'];
label: string;
placeholder: string;
}> = [
{key: 'ips', label: 'IP', placeholder: '每行一个 IP'},
{key: 'ip_cidrs', label: 'IP CIDR', placeholder: '每行一个网段,如 10.0.0.0/8'},
{key: 'paths', label: '路径', placeholder: '每行一个路径前缀'},
{key: 'path_regexes', label: '路径正则', placeholder: '每行一个正则表达式'},
{key: 'user_agents', label: 'User-Agent', placeholder: '每行一个 UA 关键词或正则'},
];
return (
<div className="space-y-3 rounded-lg border border-dashed p-4">
<p className="text-sm font-medium">{title}</p>
<div className="space-y-3">
{fields.map((field) => (
<div key={field.key} className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{field.label}</Label>
<Textarea
value={draft[scope][field.key]}
onChange={(event) => onUpdate(scope, field.key, event.target.value)}
placeholder={field.placeholder}
className="min-h-16 resize-y font-mono text-xs"
/>
</div>
))}
</div>
</div>
);
}
export function PowConfigPanel({enabled, config, onChange}: PowConfigPanelProps) {
const [draft, setDraft] = useState(() => buildDraft(config));
useEffect(() => {
setDraft(buildDraft(config));
}, [config]);
const emitChange = (
nextEnabled: boolean,
nextConfig: Partial<ProxyRoutePoWConfig>,
nextDraft?: PowListDraft,
) => {
const merged = {
...config,
...nextConfig,
};
onChange(nextEnabled, nextDraft ? applyDraft(merged, nextDraft) : merged);
};
const updateList = (
scope: keyof PowListDraft,
key: keyof ProxyRoutePoWConfig['whitelist'],
value: string,
) => {
const nextDraft: PowListDraft = {
...draft,
[scope]: {
...draft[scope],
[key]: value,
},
};
setDraft(nextDraft);
emitChange(enabled, {}, nextDraft);
};
return (
<div className="space-y-5">
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-1">
<div className="space-y-1 pr-4">
<Label htmlFor="pow-enabled">启用 PoW 防护</Label>
<p className="text-xs text-muted-foreground">
算法 {config.algorithm} · 难度 {config.difficulty}
启用后,命中该规则组的请求需要完成浏览器计算挑战。
</p>
</div>
<Switch
id="pow-enabled"
checked={enabled}
onCheckedChange={(checked) => onChange(checked, config)}
onCheckedChange={(checked) => emitChange(checked, {})}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">算法</Label>
<Select
value={config.algorithm}
onValueChange={(value: 'fast' | 'slow') =>
emitChange(enabled, {algorithm: value})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fast">Fast</SelectItem>
<SelectItem value="slow">Slow</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">难度</Label>
<Input
type="number"
min={1}
max={16}
value={config.difficulty}
onChange={(event) =>
emitChange(enabled, {difficulty: Number(event.target.value) || 1})
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">会话 TTL (秒)</Label>
<Input
type="number"
min={60}
value={config.session_ttl}
onChange={(event) =>
emitChange(enabled, {session_ttl: Number(event.target.value) || 60})
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">挑战 TTL (秒)</Label>
<Input
type="number"
min={30}
value={config.challenge_ttl}
onChange={(event) =>
emitChange(enabled, {challenge_ttl: Number(event.target.value) || 30})
}
/>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<PowListCard
title="白名单(跳过 PoW)"
scope="whitelist"
draft={draft}
onUpdate={updateList}
/>
<PowListCard
title="黑名单(必须 PoW)"
scope="blacklist"
draft={draft}
onUpdate={updateList}
/>
</div>
</div>
);
}
}
+1
View File
@@ -24,6 +24,7 @@ sidebar: false
- Wavelet 全球态势板布局紧凑化:地图高度降至约 260px,指标改为侧栏密集单元格,图例改为行内色点标注。
- 修复 Wavelet 全球态势板 ECharts 地图在紧凑布局下容器尺寸为 0 导致无法渲染的问题。
- Wavelet 全球态势板地图容器调整为约 2:3(宽:高)比例,避免世界地图横向过度拉伸。
- Wavelet WAF / 代理规则认证实装完整 PoW 配置面板:支持难度、算法、TTL 与路径/IP/UA 黑白名单编辑。
- 将 `openflare-server` 后端业务域迁移至 `Wavelet/internal/apps/openflare/`,通过 `/api/*` legacy 兼容层保持旧前端 API 路径不变。
- 新增 OpenFlare 业务表 goose 迁移(`of_options`、`of_origins`、`of_proxy_routes`、`of_nodes`、`of_waf_*`、`of_tls_*`、`of_config_versions`、`of_pages_*`、`of_apply_logs` 等)。