mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +08:00
[优化] 界面优化
This commit is contained in:
@@ -15,8 +15,8 @@ type WAFRuleGroup struct {
|
|||||||
CountryBlacklist string `json:"country_blacklist" gorm:"type:text;not null;default:'[]'"`
|
CountryBlacklist string `json:"country_blacklist" gorm:"type:text;not null;default:'[]'"`
|
||||||
RegionWhitelist string `json:"region_whitelist" gorm:"type:text;not null;default:'[]'"`
|
RegionWhitelist string `json:"region_whitelist" gorm:"type:text;not null;default:'[]'"`
|
||||||
RegionBlacklist string `json:"region_blacklist" gorm:"type:text;not null;default:'[]'"`
|
RegionBlacklist string `json:"region_blacklist" gorm:"type:text;not null;default:'[]'"`
|
||||||
PoWEnabled bool `json:"pow_enabled" gorm:"not null;default:false"`
|
PoWEnabled bool `json:"pow_enabled" gorm:"column:pow_enabled;not null;default:false"`
|
||||||
PoWConfig string `json:"pow_config" gorm:"type:text;not null;default:'{}'"`
|
PoWConfig string `json:"pow_config" gorm:"column:pow_config;type:text;not null;default:'{}'"`
|
||||||
Remark string `json:"remark" gorm:"size:255"`
|
Remark string `json:"remark" gorm:"size:255"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -1033,11 +1033,31 @@ func openRestyOptionKeys() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getPoWConfigForRoute(routeID uint, snapshot snapshotWAFDocument) (bool, *ProxyRoutePoWConfig) {
|
||||||
|
for _, binding := range snapshot.Bindings {
|
||||||
|
if binding.RouteID == routeID {
|
||||||
|
for _, groupID := range binding.RuleGroupIDs {
|
||||||
|
for _, group := range snapshot.RuleGroups {
|
||||||
|
if group.ID == groupID && group.PoWEnabled {
|
||||||
|
return true, group.PoWConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, group := range snapshot.RuleGroups {
|
||||||
|
if group.IsGlobal && group.PoWEnabled {
|
||||||
|
return true, group.PoWConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot, wafSnapshot snapshotWAFDocument) (string, []SupportFile, error) {
|
func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot, wafSnapshot snapshotWAFDocument) (string, []SupportFile, error) {
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n")
|
builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n")
|
||||||
supportFiles := make([]SupportFile, 0)
|
supportFiles := make([]SupportFile, 0)
|
||||||
powEnabledByRoute := wafPowEnabledByRoute(wafSnapshot)
|
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
domains, err := decodeStoredDomains(route.Domains, route.Domain)
|
domains, err := decodeStoredDomains(route.Domains, route.Domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1074,8 +1094,9 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot,
|
|||||||
if upstreamConfig.UsesNamedUpstream {
|
if upstreamConfig.UsesNamedUpstream {
|
||||||
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
|
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
|
||||||
}
|
}
|
||||||
|
powEnabled, _ := getPoWConfigForRoute(route.ID, wafSnapshot)
|
||||||
if !route.EnableHTTPS {
|
if !route.EnableHTTPS {
|
||||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
|
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
|
||||||
@@ -1136,7 +1157,7 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot,
|
|||||||
|
|
||||||
if route.RedirectHTTP {
|
if route.RedirectHTTP {
|
||||||
if len(httpOnlyDomains) > 0 {
|
if len(httpOnlyDomains) > 0 {
|
||||||
builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||||
}
|
}
|
||||||
for _, certID := range certIDs {
|
for _, certID := range certIDs {
|
||||||
assignedDomains := domainsByCertID[certID]
|
assignedDomains := domainsByCertID[certID]
|
||||||
@@ -1146,14 +1167,14 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot,
|
|||||||
builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains), displayName))
|
builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains), displayName))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||||
}
|
}
|
||||||
for _, certID := range certIDs {
|
for _, certID := range certIDs {
|
||||||
assignedDomains := domainsByCertID[certID]
|
assignedDomains := domainsByCertID[certID]
|
||||||
if len(assignedDomains) == 0 {
|
if len(assignedDomains) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return builder.String(), dedupeSupportFiles(supportFiles), nil
|
return builder.String(), dedupeSupportFiles(supportFiles), nil
|
||||||
@@ -1818,16 +1839,17 @@ func dedupeSupportFiles(files []SupportFile) []SupportFile {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderPowConfigBundle(routes []*model.ProxyRoute) (string, []SupportFile, error) {
|
func renderPowConfigBundle(routes []*model.ProxyRoute, wafSnapshot snapshotWAFDocument) (string, []SupportFile, error) {
|
||||||
type domainEntry struct {
|
type domainEntry struct {
|
||||||
Domains []string `json:"domains"`
|
Domains []string `json:"domains"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Config map[string]interface{} `json:"config"`
|
Config *ProxyRoutePoWConfig `json:"config"`
|
||||||
}
|
}
|
||||||
entries := make([]domainEntry, 0)
|
entries := make([]domainEntry, 0)
|
||||||
hasPow := false
|
hasPow := false
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
if !route.PoWEnabled {
|
powEnabled, powConfig := getPoWConfigForRoute(route.ID, wafSnapshot)
|
||||||
|
if !powEnabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hasPow = true
|
hasPow = true
|
||||||
@@ -1835,14 +1857,10 @@ func renderPowConfigBundle(routes []*model.ProxyRoute) (string, []SupportFile, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
var cfg map[string]interface{}
|
|
||||||
if err := json.Unmarshal([]byte(route.PoWConfig), &cfg); err != nil {
|
|
||||||
return "", nil, fmt.Errorf("route %s pow_config is invalid", route.Domain)
|
|
||||||
}
|
|
||||||
entries = append(entries, domainEntry{
|
entries = append(entries, domainEntry{
|
||||||
Domains: domains,
|
Domains: domains,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Config: cfg,
|
Config: powConfig,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if !hasPow {
|
if !hasPow {
|
||||||
|
|||||||
@@ -767,7 +767,7 @@ function WAFBindingSection({ route }: { route: ProxyRouteItem }) {
|
|||||||
return (
|
return (
|
||||||
<ConfigSectionShell
|
<ConfigSectionShell
|
||||||
title="WAF"
|
title="WAF"
|
||||||
description="全局规则组始终生效;这里可以为当前网站叠加自定义规则组。PoW 规则请在 WAF 页面统一配置和批量应用。"
|
description="全局规则组始终生效;这里可以为当前网站叠加自定义规则组。"
|
||||||
formId="proxy-route-waf-form"
|
formId="proxy-route-waf-form"
|
||||||
saving={wafMutation.isPending}
|
saving={wafMutation.isPending}
|
||||||
>
|
>
|
||||||
@@ -779,7 +779,14 @@ function WAFBindingSection({ route }: { route: ProxyRouteItem }) {
|
|||||||
description={getErrorMessage(wafQuery.error)}
|
description={getErrorMessage(wafQuery.error)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<form
|
||||||
|
id="proxy-route-waf-form"
|
||||||
|
className="space-y-5"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
wafMutation.mutate(selectedIDs);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{wafMutation.isError ? (
|
{wafMutation.isError ? (
|
||||||
<InlineMessage
|
<InlineMessage
|
||||||
tone="danger"
|
tone="danger"
|
||||||
@@ -852,17 +859,7 @@ function WAFBindingSection({ route }: { route: ProxyRouteItem }) {
|
|||||||
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
|
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
|
||||||
<EmptyState title="暂无自定义 WAF 规则组" />
|
<EmptyState title="暂无自定义 WAF 规则组" />
|
||||||
) : null}
|
) : null}
|
||||||
|
</form>
|
||||||
<div className="flex justify-end">
|
|
||||||
<PrimaryButton
|
|
||||||
type="button"
|
|
||||||
disabled={wafMutation.isPending}
|
|
||||||
onClick={() => wafMutation.mutate(selectedIDs)}
|
|
||||||
>
|
|
||||||
{wafMutation.isPending ? '保存中...' : '保存 WAF 绑定'}
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</ConfigSectionShell>
|
</ConfigSectionShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import type { ProxyRoutePoWConfig } from '@/features/proxy-routes/types';
|
||||||
|
import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types';
|
||||||
|
import type {
|
||||||
|
CountryOption,
|
||||||
|
ListFieldKey,
|
||||||
|
RuleDimension,
|
||||||
|
RuleListRenderable,
|
||||||
|
RuleListType,
|
||||||
|
RuleModalState,
|
||||||
|
WAFTab,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export const defaultPowConfig: ProxyRoutePoWConfig = {
|
||||||
|
difficulty: 4,
|
||||||
|
algorithm: 'fast',
|
||||||
|
session_ttl: 600,
|
||||||
|
challenge_ttl: 300,
|
||||||
|
whitelist: {
|
||||||
|
ips: [],
|
||||||
|
ip_cidrs: [],
|
||||||
|
paths: [],
|
||||||
|
path_regexes: [],
|
||||||
|
user_agents: [],
|
||||||
|
},
|
||||||
|
blacklist: {
|
||||||
|
ips: [],
|
||||||
|
ip_cidrs: [],
|
||||||
|
paths: [],
|
||||||
|
path_regexes: [],
|
||||||
|
user_agents: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyDraft: WAFRuleGroupPayload = {
|
||||||
|
name: '',
|
||||||
|
enabled: true,
|
||||||
|
block_status_code: 418,
|
||||||
|
block_response_body: '',
|
||||||
|
ip_whitelist: [],
|
||||||
|
ip_blacklist: [],
|
||||||
|
country_whitelist: [],
|
||||||
|
country_blacklist: [],
|
||||||
|
region_whitelist: [],
|
||||||
|
region_blacklist: [],
|
||||||
|
pow_enabled: false,
|
||||||
|
pow_config: defaultPowConfig,
|
||||||
|
remark: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultRuleModalState: RuleModalState = {
|
||||||
|
open: false,
|
||||||
|
listType: 'whitelist',
|
||||||
|
dimension: 'ip',
|
||||||
|
ipValue: '',
|
||||||
|
countryValues: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tabItems: Array<{
|
||||||
|
id: WAFTab;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ id: 'basic', label: '基本信息' },
|
||||||
|
{ id: 'lists', label: '黑白名单' },
|
||||||
|
{ id: 'pow', label: 'PoW' },
|
||||||
|
{ id: 'block', label: '拦截返回' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : '操作失败';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textToList(text: string) {
|
||||||
|
return text
|
||||||
|
.split(/[\n,,\s]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listToText(items: string[] | undefined) {
|
||||||
|
return (items ?? []).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTextareaList(text: string) {
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeItems(items: string[]) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(items.map((item) => item.trim()).filter(Boolean)),
|
||||||
|
).sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
|
||||||
|
if (!group) {
|
||||||
|
return { ...emptyDraft };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: group.name,
|
||||||
|
enabled: group.enabled,
|
||||||
|
block_status_code: group.block_status_code || 418,
|
||||||
|
block_response_body: group.block_response_body ?? '',
|
||||||
|
ip_whitelist: group.ip_whitelist ?? [],
|
||||||
|
ip_blacklist: group.ip_blacklist ?? [],
|
||||||
|
country_whitelist: group.country_whitelist ?? [],
|
||||||
|
country_blacklist: group.country_blacklist ?? [],
|
||||||
|
region_whitelist: group.region_whitelist ?? [],
|
||||||
|
region_blacklist: group.region_blacklist ?? [],
|
||||||
|
pow_enabled: group.pow_enabled ?? false,
|
||||||
|
pow_config: group.pow_config ?? defaultPowConfig,
|
||||||
|
remark: group.remark ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countRuleEntries(group: RuleListRenderable) {
|
||||||
|
return (
|
||||||
|
group.ip_whitelist.length +
|
||||||
|
group.ip_blacklist.length +
|
||||||
|
group.country_whitelist.length +
|
||||||
|
group.country_blacklist.length +
|
||||||
|
group.region_whitelist.length +
|
||||||
|
group.region_blacklist.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCountryOptions() {
|
||||||
|
const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' });
|
||||||
|
const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||||
|
const options: CountryOption[] = [];
|
||||||
|
|
||||||
|
for (let first = 65; first <= 90; first += 1) {
|
||||||
|
for (let second = 65; second <= 90; second += 1) {
|
||||||
|
const code = String.fromCharCode(first, second);
|
||||||
|
const zhName = zhDisplayNames.of(code);
|
||||||
|
const enName = enDisplayNames.of(code);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!zhName ||
|
||||||
|
zhName === code ||
|
||||||
|
/未知/.test(zhName) ||
|
||||||
|
!enName ||
|
||||||
|
enName === code ||
|
||||||
|
/Unknown/.test(enName)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
code,
|
||||||
|
zhName,
|
||||||
|
label: `${code} ${zhName}`,
|
||||||
|
searchText: `${code} ${zhName} ${enName}`.toLowerCase(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.sort((left, right) => left.code.localeCompare(right.code));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getListFieldKey(
|
||||||
|
listType: RuleListType,
|
||||||
|
dimension: RuleDimension,
|
||||||
|
): ListFieldKey {
|
||||||
|
if (dimension === 'ip') {
|
||||||
|
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
|
||||||
|
}
|
||||||
|
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDraftList(
|
||||||
|
draft: WAFRuleGroupPayload,
|
||||||
|
key: ListFieldKey,
|
||||||
|
updater: (items: string[]) => string[],
|
||||||
|
) {
|
||||||
|
switch (key) {
|
||||||
|
case 'ip_whitelist':
|
||||||
|
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
|
||||||
|
case 'ip_blacklist':
|
||||||
|
return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
|
||||||
|
case 'country_whitelist':
|
||||||
|
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
|
||||||
|
case 'country_blacklist':
|
||||||
|
return { ...draft, country_blacklist: updater(draft.country_blacklist) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCountryItem(code: string, labelMap: Map<string, string>) {
|
||||||
|
return labelMap.get(code) ?? code;
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { ProxyRoutePoWConfig } from '@/features/proxy-routes/types';
|
||||||
|
import {
|
||||||
|
ResourceField,
|
||||||
|
ResourceInput,
|
||||||
|
ResourceTextarea,
|
||||||
|
ToggleField,
|
||||||
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
import { listToText, parseTextareaList } from './helpers';
|
||||||
|
|
||||||
|
export function PowTabPanel({
|
||||||
|
enabled,
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
config: ProxyRoutePoWConfig;
|
||||||
|
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(() => ({
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft({
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const updateConfig = (
|
||||||
|
newEnabled: boolean,
|
||||||
|
newConfig: Partial<ProxyRoutePoWConfig>,
|
||||||
|
newDraft?: typeof draft,
|
||||||
|
) => {
|
||||||
|
const nextConfig = { ...config, ...newConfig };
|
||||||
|
if (newDraft) {
|
||||||
|
setDraft(newDraft);
|
||||||
|
nextConfig.whitelist = {
|
||||||
|
ips: parseTextareaList(newDraft.whitelist.ips),
|
||||||
|
ip_cidrs: parseTextareaList(newDraft.whitelist.ip_cidrs),
|
||||||
|
paths: parseTextareaList(newDraft.whitelist.paths),
|
||||||
|
path_regexes: parseTextareaList(newDraft.whitelist.path_regexes),
|
||||||
|
user_agents: parseTextareaList(newDraft.whitelist.user_agents),
|
||||||
|
};
|
||||||
|
nextConfig.blacklist = {
|
||||||
|
ips: parseTextareaList(newDraft.blacklist.ips),
|
||||||
|
ip_cidrs: parseTextareaList(newDraft.blacklist.ip_cidrs),
|
||||||
|
paths: parseTextareaList(newDraft.blacklist.paths),
|
||||||
|
path_regexes: parseTextareaList(newDraft.blacklist.path_regexes),
|
||||||
|
user_agents: parseTextareaList(newDraft.blacklist.user_agents),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
onChange(newEnabled, nextConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateList = (
|
||||||
|
scope: 'whitelist' | 'blacklist',
|
||||||
|
key: keyof ProxyRoutePoWConfig['whitelist'],
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const nextDraft = {
|
||||||
|
...draft,
|
||||||
|
[scope]: {
|
||||||
|
...draft[scope],
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateConfig(enabled, {}, nextDraft);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ToggleField
|
||||||
|
label="启用 PoW 防护"
|
||||||
|
description="启用后,命中该规则组的请求需要完成浏览器计算挑战。"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(newEnabled) => updateConfig(newEnabled, {})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<ResourceField label="算法">
|
||||||
|
<select
|
||||||
|
value={config.algorithm}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateConfig(enabled, { algorithm: event.target.value as 'fast' | 'slow' })
|
||||||
|
}
|
||||||
|
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition focus:border-[var(--border-strong)]"
|
||||||
|
>
|
||||||
|
<option value="fast">Fast</option>
|
||||||
|
<option value="slow">Slow</option>
|
||||||
|
</select>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="难度">
|
||||||
|
<ResourceInput
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={16}
|
||||||
|
value={config.difficulty}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateConfig(enabled, { difficulty: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="会话 TTL">
|
||||||
|
<ResourceInput
|
||||||
|
type="number"
|
||||||
|
min={60}
|
||||||
|
value={config.session_ttl}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateConfig(enabled, { session_ttl: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="挑战 TTL">
|
||||||
|
<ResourceInput
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
value={config.challenge_ttl}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateConfig(enabled, { challenge_ttl: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
{(['whitelist', 'blacklist'] as const).map((scope) => (
|
||||||
|
<div
|
||||||
|
key={scope}
|
||||||
|
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{scope === 'whitelist'
|
||||||
|
? '白名单(跳过 PoW)'
|
||||||
|
: '黑名单(必须 PoW)'}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<ResourceField label="IP">
|
||||||
|
<ResourceTextarea
|
||||||
|
className="min-h-20"
|
||||||
|
value={draft[scope].ips}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateList(scope, 'ips', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="IP CIDR">
|
||||||
|
<ResourceTextarea
|
||||||
|
className="min-h-20"
|
||||||
|
value={draft[scope].ip_cidrs}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateList(scope, 'ip_cidrs', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="路径">
|
||||||
|
<ResourceTextarea
|
||||||
|
className="min-h-20"
|
||||||
|
value={draft[scope].paths}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateList(scope, 'paths', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="路径正则">
|
||||||
|
<ResourceTextarea
|
||||||
|
className="min-h-20"
|
||||||
|
value={draft[scope].path_regexes}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateList(scope, 'path_regexes', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="User-Agent">
|
||||||
|
<ResourceTextarea
|
||||||
|
className="min-h-20"
|
||||||
|
value={draft[scope].user_agents}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateList(scope, 'user_agents', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
export function RuleChip({
|
||||||
|
label,
|
||||||
|
tone,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
tone: 'whitelist' | 'blacklist';
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm',
|
||||||
|
tone === 'whitelist'
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/18 text-[var(--foreground-primary)]'
|
||||||
|
: 'border-rose-500/30 bg-rose-500/18 text-[var(--foreground-primary)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="break-all">{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-current/20 text-xs text-[var(--foreground-primary)] transition hover:bg-black/10"
|
||||||
|
aria-label={`移除 ${label}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { AppModal } from '@/components/ui/app-modal';
|
||||||
|
import {
|
||||||
|
PrimaryButton,
|
||||||
|
ResourceField,
|
||||||
|
ResourceTextarea,
|
||||||
|
SecondaryButton,
|
||||||
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { normalizeItems } from './helpers';
|
||||||
|
import type { CountryOption, RuleListType, RuleDimension, RuleModalState } from './types';
|
||||||
|
|
||||||
|
export function RuleEntryModal({
|
||||||
|
state,
|
||||||
|
countryOptions,
|
||||||
|
pending,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
state: RuleModalState;
|
||||||
|
countryOptions: CountryOption[];
|
||||||
|
pending: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (patch: Partial<RuleModalState>) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setKeyword('');
|
||||||
|
}, [state.dimension, state.open]);
|
||||||
|
|
||||||
|
const selectedCountrySet = useMemo(
|
||||||
|
() => new Set(state.countryValues),
|
||||||
|
[state.countryValues],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredCountries = useMemo(() => {
|
||||||
|
const normalized = keyword.trim().toLowerCase();
|
||||||
|
|
||||||
|
return countryOptions
|
||||||
|
.filter((option) => !normalized || option.searchText.includes(normalized))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0;
|
||||||
|
const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0;
|
||||||
|
return (
|
||||||
|
rightSelected - leftSelected || left.code.localeCompare(right.code)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [countryOptions, keyword, selectedCountrySet]);
|
||||||
|
|
||||||
|
const toggleCountry = (code: string) => {
|
||||||
|
const values = selectedCountrySet.has(code)
|
||||||
|
? state.countryValues.filter((item) => item !== code)
|
||||||
|
: normalizeItems([...state.countryValues, code]);
|
||||||
|
onChange({ countryValues: values });
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectFiltered = () => {
|
||||||
|
onChange({
|
||||||
|
countryValues: normalizeItems([
|
||||||
|
...state.countryValues,
|
||||||
|
...filteredCountries.map((option) => option.code),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCountries = () => onChange({ countryValues: [] });
|
||||||
|
|
||||||
|
const typeLabel = state.listType === 'whitelist' ? '白名单' : '黑名单';
|
||||||
|
const dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppModal
|
||||||
|
isOpen={state.open}
|
||||||
|
title={`添加${typeLabel}规则`}
|
||||||
|
description={`当前准备新增 ${dimensionLabel} 维度的${typeLabel}项。`}
|
||||||
|
size="lg"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<SecondaryButton type="button" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton type="button" disabled={pending} onClick={onSubmit}>
|
||||||
|
{pending ? '处理中...' : '添加'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<ResourceField label="类型" container="div">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ value: 'blacklist', label: '黑名单' },
|
||||||
|
{ value: 'whitelist', label: '白名单' },
|
||||||
|
].map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({ listType: option.value as RuleListType })
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
|
||||||
|
state.listType === option.value
|
||||||
|
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||||
|
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="维度" container="div">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ value: 'ip', label: 'IP' },
|
||||||
|
{ value: 'country', label: '地域' },
|
||||||
|
].map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({ dimension: option.value as RuleDimension })
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
|
||||||
|
state.dimension === option.value
|
||||||
|
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||||
|
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ResourceField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.dimension === 'ip' ? (
|
||||||
|
<ResourceField
|
||||||
|
label="IP / IP 段"
|
||||||
|
hint="支持单个 IP、CIDR,或使用换行/逗号一次添加多个。"
|
||||||
|
>
|
||||||
|
<ResourceTextarea
|
||||||
|
value={state.ipValue}
|
||||||
|
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
|
||||||
|
onChange={(event) => onChange({ ipValue: event.target.value })}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||||
|
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
|
||||||
|
<input
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="搜索国家代码或中文名"
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={selectFiltered}
|
||||||
|
className="text-xs font-medium text-[var(--brand-primary)]"
|
||||||
|
>
|
||||||
|
全选当前
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearCountries}
|
||||||
|
className="text-xs font-medium text-[var(--foreground-secondary)]"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
地域多选
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||||
|
选项显示为「国家代码 国家中文名」。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
|
||||||
|
已选 {state.countryValues.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||||
|
{filteredCountries.map((option) => {
|
||||||
|
const selected = selectedCountrySet.has(option.code);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={option.code}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
|
||||||
|
selected
|
||||||
|
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||||
|
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => toggleCountry(option.code)}
|
||||||
|
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { RuleChip } from './rule-chip';
|
||||||
|
|
||||||
|
export function RuleListSection({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
tone,
|
||||||
|
emptyText,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
items: string[];
|
||||||
|
tone: 'whitelist' | 'blacklist';
|
||||||
|
emptyText: string;
|
||||||
|
onRemove: (item: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-full bg-[var(--surface-muted)] px-3 py-1 text-xs font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<RuleChip
|
||||||
|
key={item}
|
||||||
|
label={item}
|
||||||
|
tone={tone}
|
||||||
|
onRemove={() => onRemove(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--foreground-muted)]">{emptyText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Check, Search } from 'lucide-react';
|
||||||
|
import { Drawer } from '@/components/ui/drawer';
|
||||||
|
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
||||||
|
import { PrimaryButton, SecondaryButton } from '@/features/shared/components/resource-primitives';
|
||||||
|
import type { WAFRuleGroup } from '@/features/waf/types';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
export function SiteApplyDrawer({
|
||||||
|
group,
|
||||||
|
routes,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSave,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
group: WAFRuleGroup | null;
|
||||||
|
routes: ProxyRouteItem[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSave: (ids: number[]) => void;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIDs(group?.applied_site_ids ?? []);
|
||||||
|
setKeyword('');
|
||||||
|
}, [group, open]);
|
||||||
|
|
||||||
|
const filteredRoutes = useMemo(() => {
|
||||||
|
const normalized = keyword.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
return routes.filter((route) =>
|
||||||
|
[route.site_name, route.primary_domain, ...route.domains]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(normalized),
|
||||||
|
);
|
||||||
|
}, [keyword, routes]);
|
||||||
|
|
||||||
|
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
|
||||||
|
const toggleID = (id: number) => {
|
||||||
|
setSelectedIDs((current) =>
|
||||||
|
current.includes(id)
|
||||||
|
? current.filter((item) => item !== id)
|
||||||
|
: [...current, id].sort((left, right) => left - right),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectFiltered = () => {
|
||||||
|
const next = new Set(selectedIDs);
|
||||||
|
filteredRoutes.forEach((route) => next.add(route.id));
|
||||||
|
setSelectedIDs([...next].sort((left, right) => left - right));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
direction="right"
|
||||||
|
title={group ? `应用 ${group.name}` : '应用规则组'}
|
||||||
|
description="选择这个自定义规则组要叠加到哪些网站。"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<SecondaryButton type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
disabled={!group || pending}
|
||||||
|
onClick={() => onSave(selectedIDs)}
|
||||||
|
>
|
||||||
|
{pending ? '保存中...' : '保存应用范围'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||||
|
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
|
||||||
|
<input
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="搜索网站或域名"
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={selectFiltered}
|
||||||
|
className="text-xs font-medium text-[var(--brand-primary)]"
|
||||||
|
>
|
||||||
|
全选当前
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredRoutes.map((route) => (
|
||||||
|
<button
|
||||||
|
key={route.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleID(route.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition',
|
||||||
|
selectedSet.has(route.id)
|
||||||
|
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||||
|
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex h-5 w-5 items-center justify-center rounded-md border',
|
||||||
|
selectedSet.has(route.id)
|
||||||
|
? 'border-[var(--brand-primary)] bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
|
||||||
|
: 'border-[var(--border-default)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedSet.has(route.id) ? (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{route.site_name}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-[var(--foreground-secondary)]">
|
||||||
|
{route.domains.join(', ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-primary)]">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hint && (
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
export function TabButton({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'group rounded-[24px] border px-4 py-4 text-left transition',
|
||||||
|
active
|
||||||
|
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] shadow-[var(--shadow-soft)]'
|
||||||
|
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { WAFRuleGroupPayload } from '@/features/waf/types';
|
||||||
|
|
||||||
|
export type FeedbackState = {
|
||||||
|
tone: 'success' | 'danger' | 'info';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
|
||||||
|
export type RuleListType = 'whitelist' | 'blacklist';
|
||||||
|
export type RuleDimension = 'ip' | 'country';
|
||||||
|
export type ListFieldKey =
|
||||||
|
| 'ip_whitelist'
|
||||||
|
| 'ip_blacklist'
|
||||||
|
| 'country_whitelist'
|
||||||
|
| 'country_blacklist';
|
||||||
|
|
||||||
|
export type CountryOption = {
|
||||||
|
code: string;
|
||||||
|
zhName: string;
|
||||||
|
label: string;
|
||||||
|
searchText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuleModalState = {
|
||||||
|
open: boolean;
|
||||||
|
listType: RuleListType;
|
||||||
|
dimension: RuleDimension;
|
||||||
|
ipValue: string;
|
||||||
|
countryValues: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuleListRenderable = Pick<
|
||||||
|
WAFRuleGroupPayload,
|
||||||
|
| 'ip_whitelist'
|
||||||
|
| 'ip_blacklist'
|
||||||
|
| 'country_whitelist'
|
||||||
|
| 'country_blacklist'
|
||||||
|
| 'region_whitelist'
|
||||||
|
| 'region_blacklist'
|
||||||
|
>;
|
||||||
@@ -4,13 +4,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Check,
|
|
||||||
Cpu,
|
|
||||||
Globe2,
|
Globe2,
|
||||||
type LucideIcon,
|
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -21,17 +17,7 @@ import { InlineMessage } from '@/components/feedback/inline-message';
|
|||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { AppModal } from '@/components/ui/app-modal';
|
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
|
||||||
import { Drawer } from '@/components/ui/drawer';
|
|
||||||
import {
|
|
||||||
getProxyRoutes,
|
|
||||||
updateProxyRoute,
|
|
||||||
} from '@/features/proxy-routes/api/proxy-routes';
|
|
||||||
import { buildPayloadFromRoute } from '@/features/proxy-routes/helpers';
|
|
||||||
import type {
|
|
||||||
ProxyRouteItem,
|
|
||||||
ProxyRoutePoWConfig,
|
|
||||||
} from '@/features/proxy-routes/types';
|
|
||||||
import {
|
import {
|
||||||
DangerButton,
|
DangerButton,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
@@ -51,940 +37,26 @@ import {
|
|||||||
import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types';
|
import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
type FeedbackState = {
|
import { RuleEntryModal } from './rule-entry-modal';
|
||||||
tone: 'success' | 'danger' | 'info';
|
import { SiteApplyDrawer } from './site-apply-drawer';
|
||||||
message: string;
|
import { PowTabPanel } from './pow-tab-panel';
|
||||||
};
|
import { RuleListSection } from './rule-list-section';
|
||||||
|
import { TabButton } from './tab-button';
|
||||||
type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
|
import {
|
||||||
type RuleListType = 'whitelist' | 'blacklist';
|
buildCountryOptions,
|
||||||
type RuleDimension = 'ip' | 'country';
|
buildDraft,
|
||||||
type ListFieldKey =
|
countRuleEntries,
|
||||||
| 'ip_whitelist'
|
defaultRuleModalState,
|
||||||
| 'ip_blacklist'
|
emptyDraft,
|
||||||
| 'country_whitelist'
|
formatCountryItem,
|
||||||
| 'country_blacklist';
|
getErrorMessage,
|
||||||
|
getListFieldKey,
|
||||||
type CountryOption = {
|
normalizeItems,
|
||||||
code: string;
|
tabItems,
|
||||||
zhName: string;
|
textToList,
|
||||||
label: string;
|
updateDraftList,
|
||||||
searchText: string;
|
} from './helpers';
|
||||||
};
|
import type { FeedbackState, ListFieldKey, RuleModalState, WAFTab } from './types';
|
||||||
|
|
||||||
type RuleModalState = {
|
|
||||||
open: boolean;
|
|
||||||
listType: RuleListType;
|
|
||||||
dimension: RuleDimension;
|
|
||||||
ipValue: string;
|
|
||||||
countryValues: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type RuleListRenderable = Pick<
|
|
||||||
WAFRuleGroupPayload,
|
|
||||||
| 'ip_whitelist'
|
|
||||||
| 'ip_blacklist'
|
|
||||||
| 'country_whitelist'
|
|
||||||
| 'country_blacklist'
|
|
||||||
| 'region_whitelist'
|
|
||||||
| 'region_blacklist'
|
|
||||||
>;
|
|
||||||
|
|
||||||
const defaultPowConfig: ProxyRoutePoWConfig = {
|
|
||||||
difficulty: 4,
|
|
||||||
algorithm: 'fast',
|
|
||||||
session_ttl: 600,
|
|
||||||
challenge_ttl: 300,
|
|
||||||
whitelist: {
|
|
||||||
ips: [],
|
|
||||||
ip_cidrs: [],
|
|
||||||
paths: [],
|
|
||||||
path_regexes: [],
|
|
||||||
user_agents: [],
|
|
||||||
},
|
|
||||||
blacklist: {
|
|
||||||
ips: [],
|
|
||||||
ip_cidrs: [],
|
|
||||||
paths: [],
|
|
||||||
path_regexes: [],
|
|
||||||
user_agents: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyDraft: WAFRuleGroupPayload = {
|
|
||||||
name: '',
|
|
||||||
enabled: true,
|
|
||||||
block_status_code: 418,
|
|
||||||
block_response_body: '',
|
|
||||||
ip_whitelist: [],
|
|
||||||
ip_blacklist: [],
|
|
||||||
country_whitelist: [],
|
|
||||||
country_blacklist: [],
|
|
||||||
region_whitelist: [],
|
|
||||||
region_blacklist: [],
|
|
||||||
pow_enabled: false,
|
|
||||||
pow_config: defaultPowConfig,
|
|
||||||
remark: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultRuleModalState: RuleModalState = {
|
|
||||||
open: false,
|
|
||||||
listType: 'whitelist',
|
|
||||||
dimension: 'ip',
|
|
||||||
ipValue: '',
|
|
||||||
countryValues: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabItems: Array<{
|
|
||||||
id: WAFTab;
|
|
||||||
label: string;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
id: 'basic',
|
|
||||||
label: '基本信息',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lists',
|
|
||||||
label: '黑白名单',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pow',
|
|
||||||
label: 'PoW',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block',
|
|
||||||
label: '拦截返回',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
|
||||||
return error instanceof Error ? error.message : '操作失败';
|
|
||||||
}
|
|
||||||
|
|
||||||
function textToList(text: string) {
|
|
||||||
return text
|
|
||||||
.split(/[\n,,\s]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function listToText(items: string[] | undefined) {
|
|
||||||
return (items ?? []).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTextareaList(text: string) {
|
|
||||||
return text
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeItems(items: string[]) {
|
|
||||||
return Array.from(
|
|
||||||
new Set(items.map((item) => item.trim()).filter(Boolean)),
|
|
||||||
).sort((left, right) => left.localeCompare(right));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
|
|
||||||
if (!group) {
|
|
||||||
return { ...emptyDraft };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: group.name,
|
|
||||||
enabled: group.enabled,
|
|
||||||
block_status_code: group.block_status_code || 418,
|
|
||||||
block_response_body: group.block_response_body ?? '',
|
|
||||||
ip_whitelist: group.ip_whitelist ?? [],
|
|
||||||
ip_blacklist: group.ip_blacklist ?? [],
|
|
||||||
country_whitelist: group.country_whitelist ?? [],
|
|
||||||
country_blacklist: group.country_blacklist ?? [],
|
|
||||||
region_whitelist: group.region_whitelist ?? [],
|
|
||||||
region_blacklist: group.region_blacklist ?? [],
|
|
||||||
pow_enabled: group.pow_enabled ?? false,
|
|
||||||
pow_config: group.pow_config ?? defaultPowConfig,
|
|
||||||
remark: group.remark ?? '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function countRuleEntries(group: RuleListRenderable) {
|
|
||||||
return (
|
|
||||||
group.ip_whitelist.length +
|
|
||||||
group.ip_blacklist.length +
|
|
||||||
group.country_whitelist.length +
|
|
||||||
group.country_blacklist.length +
|
|
||||||
group.region_whitelist.length +
|
|
||||||
group.region_blacklist.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCountryOptions() {
|
|
||||||
const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' });
|
|
||||||
const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
|
||||||
const options: CountryOption[] = [];
|
|
||||||
|
|
||||||
for (let first = 65; first <= 90; first += 1) {
|
|
||||||
for (let second = 65; second <= 90; second += 1) {
|
|
||||||
const code = String.fromCharCode(first, second);
|
|
||||||
const zhName = zhDisplayNames.of(code);
|
|
||||||
const enName = enDisplayNames.of(code);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!zhName ||
|
|
||||||
zhName === code ||
|
|
||||||
/未知/.test(zhName) ||
|
|
||||||
!enName ||
|
|
||||||
enName === code ||
|
|
||||||
/Unknown/.test(enName)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
options.push({
|
|
||||||
code,
|
|
||||||
zhName,
|
|
||||||
label: `${code} ${zhName}`,
|
|
||||||
searchText: `${code} ${zhName} ${enName}`.toLowerCase(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return options.sort((left, right) => left.code.localeCompare(right.code));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getListFieldKey(
|
|
||||||
listType: RuleListType,
|
|
||||||
dimension: RuleDimension,
|
|
||||||
): ListFieldKey {
|
|
||||||
if (dimension === 'ip') {
|
|
||||||
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
|
|
||||||
}
|
|
||||||
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDraftList(
|
|
||||||
draft: WAFRuleGroupPayload,
|
|
||||||
key: ListFieldKey,
|
|
||||||
updater: (items: string[]) => string[],
|
|
||||||
) {
|
|
||||||
switch (key) {
|
|
||||||
case 'ip_whitelist':
|
|
||||||
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
|
|
||||||
case 'ip_blacklist':
|
|
||||||
return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
|
|
||||||
case 'country_whitelist':
|
|
||||||
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
|
|
||||||
case 'country_blacklist':
|
|
||||||
return { ...draft, country_blacklist: updater(draft.country_blacklist) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCountryItem(code: string, labelMap: Map<string, string>) {
|
|
||||||
return labelMap.get(code) ?? code;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
hint,
|
|
||||||
}: {
|
|
||||||
icon: LucideIcon;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
hint?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-semibold text-[var(--foreground-primary)]">
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-primary)]">
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{hint && (
|
|
||||||
<p className="mt-3 text-sm leading-6 text-[var(--foreground-secondary)]">
|
|
||||||
{hint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabButton({
|
|
||||||
label,
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
'group rounded-[24px] border px-4 py-4 text-left transition',
|
|
||||||
active
|
|
||||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] shadow-[var(--shadow-soft)]'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RuleChip({
|
|
||||||
label,
|
|
||||||
tone,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
tone: 'whitelist' | 'blacklist';
|
|
||||||
onRemove: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm',
|
|
||||||
tone === 'whitelist'
|
|
||||||
? 'border-emerald-500/30 bg-emerald-500/18 text-[var(--foreground-primary)]'
|
|
||||||
: 'border-rose-500/30 bg-rose-500/18 text-[var(--foreground-primary)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="break-all">{label}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRemove}
|
|
||||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-current/20 text-xs text-[var(--foreground-primary)] transition hover:bg-black/10"
|
|
||||||
aria-label={`移除 ${label}`}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RuleListSection({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
items,
|
|
||||||
tone,
|
|
||||||
emptyText,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
items: string[];
|
|
||||||
tone: 'whitelist' | 'blacklist';
|
|
||||||
emptyText: string;
|
|
||||||
onRemove: (item: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
|
|
||||||
{items.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{items.length > 0 ? (
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{items.map((item) => (
|
|
||||||
<RuleChip
|
|
||||||
key={item}
|
|
||||||
label={item}
|
|
||||||
tone={tone}
|
|
||||||
onRemove={() => onRemove(item)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 rounded-2xl border border-dashed border-[var(--border-default)] px-4 py-6 text-sm text-[var(--foreground-secondary)]">
|
|
||||||
{emptyText}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RuleEntryModal({
|
|
||||||
state,
|
|
||||||
countryOptions,
|
|
||||||
pending,
|
|
||||||
onClose,
|
|
||||||
onChange,
|
|
||||||
onSubmit,
|
|
||||||
}: {
|
|
||||||
state: RuleModalState;
|
|
||||||
countryOptions: CountryOption[];
|
|
||||||
pending: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onChange: (patch: Partial<RuleModalState>) => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
}) {
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!state.open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setKeyword('');
|
|
||||||
}, [state.dimension, state.open]);
|
|
||||||
|
|
||||||
const selectedCountrySet = useMemo(
|
|
||||||
() => new Set(state.countryValues),
|
|
||||||
[state.countryValues],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredCountries = useMemo(() => {
|
|
||||||
const normalized = keyword.trim().toLowerCase();
|
|
||||||
|
|
||||||
return countryOptions
|
|
||||||
.filter((option) => !normalized || option.searchText.includes(normalized))
|
|
||||||
.sort((left, right) => {
|
|
||||||
const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0;
|
|
||||||
const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0;
|
|
||||||
return (
|
|
||||||
rightSelected - leftSelected || left.code.localeCompare(right.code)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [countryOptions, keyword, selectedCountrySet]);
|
|
||||||
|
|
||||||
const toggleCountry = (code: string) => {
|
|
||||||
const values = selectedCountrySet.has(code)
|
|
||||||
? state.countryValues.filter((item) => item !== code)
|
|
||||||
: normalizeItems([...state.countryValues, code]);
|
|
||||||
onChange({ countryValues: values });
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectFiltered = () => {
|
|
||||||
onChange({
|
|
||||||
countryValues: normalizeItems([
|
|
||||||
...state.countryValues,
|
|
||||||
...filteredCountries.map((option) => option.code),
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearCountries = () => onChange({ countryValues: [] });
|
|
||||||
|
|
||||||
const typeLabel = state.listType === 'whitelist' ? '白名单' : '黑名单';
|
|
||||||
const dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppModal
|
|
||||||
isOpen={state.open}
|
|
||||||
title={`添加${typeLabel}规则`}
|
|
||||||
description={`当前准备新增 ${dimensionLabel} 维度的${typeLabel}项。`}
|
|
||||||
size="lg"
|
|
||||||
onClose={onClose}
|
|
||||||
footer={
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<SecondaryButton type="button" onClick={onClose}>
|
|
||||||
取消
|
|
||||||
</SecondaryButton>
|
|
||||||
<PrimaryButton type="button" disabled={pending} onClick={onSubmit}>
|
|
||||||
{pending ? '处理中...' : '添加'}
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
|
||||||
<ResourceField label="类型" container="div">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{[
|
|
||||||
{ value: 'blacklist', label: '黑名单' },
|
|
||||||
{ value: 'whitelist', label: '白名单' },
|
|
||||||
].map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
onChange({ listType: option.value as RuleListType })
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
|
|
||||||
state.listType === option.value
|
|
||||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="维度" container="div">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{[
|
|
||||||
{ value: 'ip', label: 'IP' },
|
|
||||||
{ value: 'country', label: '地域' },
|
|
||||||
].map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
onChange({ dimension: option.value as RuleDimension })
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
|
|
||||||
state.dimension === option.value
|
|
||||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ResourceField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.dimension === 'ip' ? (
|
|
||||||
<ResourceField
|
|
||||||
label="IP / IP 段"
|
|
||||||
hint="支持单个 IP、CIDR,或使用换行/逗号一次添加多个。"
|
|
||||||
>
|
|
||||||
<ResourceTextarea
|
|
||||||
value={state.ipValue}
|
|
||||||
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
|
|
||||||
onChange={(event) => onChange({ ipValue: event.target.value })}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
|
||||||
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
|
|
||||||
<input
|
|
||||||
value={keyword}
|
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
|
||||||
placeholder="搜索国家代码或中文名"
|
|
||||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={selectFiltered}
|
|
||||||
className="text-xs font-medium text-[var(--brand-primary)]"
|
|
||||||
>
|
|
||||||
全选当前
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={clearCountries}
|
|
||||||
className="text-xs font-medium text-[var(--foreground-secondary)]"
|
|
||||||
>
|
|
||||||
清空
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
|
||||||
地域多选
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
|
||||||
选项显示为「国家代码 国家中文名」。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
|
|
||||||
已选 {state.countryValues.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
|
|
||||||
{filteredCountries.map((option) => {
|
|
||||||
const selected = selectedCountrySet.has(option.code);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={option.code}
|
|
||||||
className={cn(
|
|
||||||
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
|
|
||||||
selected
|
|
||||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selected}
|
|
||||||
onChange={() => toggleCountry(option.code)}
|
|
||||||
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
|
|
||||||
/>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block text-sm font-medium text-[var(--foreground-primary)]">
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AppModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SiteApplyDrawer({
|
|
||||||
group,
|
|
||||||
routes,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSave,
|
|
||||||
pending,
|
|
||||||
}: {
|
|
||||||
group: WAFRuleGroup | null;
|
|
||||||
routes: ProxyRouteItem[];
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSave: (ids: number[]) => void;
|
|
||||||
pending: boolean;
|
|
||||||
}) {
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedIDs(group?.applied_site_ids ?? []);
|
|
||||||
setKeyword('');
|
|
||||||
}, [group, open]);
|
|
||||||
|
|
||||||
const filteredRoutes = useMemo(() => {
|
|
||||||
const normalized = keyword.trim().toLowerCase();
|
|
||||||
if (!normalized) {
|
|
||||||
return routes;
|
|
||||||
}
|
|
||||||
return routes.filter((route) =>
|
|
||||||
[route.site_name, route.primary_domain, ...route.domains]
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(normalized),
|
|
||||||
);
|
|
||||||
}, [keyword, routes]);
|
|
||||||
|
|
||||||
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
|
|
||||||
const toggleID = (id: number) => {
|
|
||||||
setSelectedIDs((current) =>
|
|
||||||
current.includes(id)
|
|
||||||
? current.filter((item) => item !== id)
|
|
||||||
: [...current, id].sort((left, right) => left - right),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectFiltered = () => {
|
|
||||||
const next = new Set(selectedIDs);
|
|
||||||
filteredRoutes.forEach((route) => next.add(route.id));
|
|
||||||
setSelectedIDs([...next].sort((left, right) => left - right));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
direction="right"
|
|
||||||
title={group ? `应用 ${group.name}` : '应用规则组'}
|
|
||||||
description="选择这个自定义规则组要叠加到哪些网站。"
|
|
||||||
footer={
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<SecondaryButton type="button" onClick={() => onOpenChange(false)}>
|
|
||||||
取消
|
|
||||||
</SecondaryButton>
|
|
||||||
<PrimaryButton
|
|
||||||
type="button"
|
|
||||||
disabled={!group || pending}
|
|
||||||
onClick={() => onSave(selectedIDs)}
|
|
||||||
>
|
|
||||||
{pending ? '保存中...' : '保存应用范围'}
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
|
||||||
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
|
|
||||||
<input
|
|
||||||
value={keyword}
|
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
|
||||||
placeholder="搜索网站或域名"
|
|
||||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={selectFiltered}
|
|
||||||
className="text-xs font-medium text-[var(--brand-primary)]"
|
|
||||||
>
|
|
||||||
全选当前
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{filteredRoutes.map((route) => (
|
|
||||||
<button
|
|
||||||
key={route.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleID(route.id)}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition',
|
|
||||||
selectedSet.has(route.id)
|
|
||||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'flex h-5 w-5 items-center justify-center rounded-md border',
|
|
||||||
selectedSet.has(route.id)
|
|
||||||
? 'border-[var(--brand-primary)] bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
|
|
||||||
: 'border-[var(--border-default)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{selectedSet.has(route.id) ? (
|
|
||||||
<Check className="h-3 w-3" />
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
|
|
||||||
{route.site_name}
|
|
||||||
</span>
|
|
||||||
<span className="block truncate text-xs text-[var(--foreground-secondary)]">
|
|
||||||
{route.domains.join(', ')}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function PowTabPanel({
|
|
||||||
enabled,
|
|
||||||
config,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
enabled: boolean;
|
|
||||||
config: ProxyRoutePoWConfig;
|
|
||||||
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
|
|
||||||
}) {
|
|
||||||
const [draft, setDraft] = useState(() => ({
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDraft({
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const updateConfig = (
|
|
||||||
newEnabled: boolean,
|
|
||||||
newConfig: Partial<ProxyRoutePoWConfig>,
|
|
||||||
newDraft?: typeof draft,
|
|
||||||
) => {
|
|
||||||
const nextConfig = { ...config, ...newConfig };
|
|
||||||
if (newDraft) {
|
|
||||||
setDraft(newDraft);
|
|
||||||
nextConfig.whitelist = {
|
|
||||||
ips: parseTextareaList(newDraft.whitelist.ips),
|
|
||||||
ip_cidrs: parseTextareaList(newDraft.whitelist.ip_cidrs),
|
|
||||||
paths: parseTextareaList(newDraft.whitelist.paths),
|
|
||||||
path_regexes: parseTextareaList(newDraft.whitelist.path_regexes),
|
|
||||||
user_agents: parseTextareaList(newDraft.whitelist.user_agents),
|
|
||||||
};
|
|
||||||
nextConfig.blacklist = {
|
|
||||||
ips: parseTextareaList(newDraft.blacklist.ips),
|
|
||||||
ip_cidrs: parseTextareaList(newDraft.blacklist.ip_cidrs),
|
|
||||||
paths: parseTextareaList(newDraft.blacklist.paths),
|
|
||||||
path_regexes: parseTextareaList(newDraft.blacklist.path_regexes),
|
|
||||||
user_agents: parseTextareaList(newDraft.blacklist.user_agents),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
onChange(newEnabled, nextConfig);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateList = (
|
|
||||||
scope: 'whitelist' | 'blacklist',
|
|
||||||
key: keyof ProxyRoutePoWConfig['whitelist'],
|
|
||||||
value: string,
|
|
||||||
) => {
|
|
||||||
const nextDraft = {
|
|
||||||
...draft,
|
|
||||||
[scope]: {
|
|
||||||
...draft[scope],
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
updateConfig(enabled, {}, nextDraft);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<ToggleField
|
|
||||||
label="启用 PoW 防护"
|
|
||||||
description="启用后,命中该规则组的请求需要完成浏览器计算挑战。"
|
|
||||||
checked={enabled}
|
|
||||||
onChange={(newEnabled) => updateConfig(newEnabled, {})}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<ResourceField label="算法">
|
|
||||||
<select
|
|
||||||
value={config.algorithm}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateConfig(enabled, { algorithm: event.target.value as 'fast' | 'slow' })
|
|
||||||
}
|
|
||||||
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition focus:border-[var(--border-strong)]"
|
|
||||||
>
|
|
||||||
<option value="fast">Fast</option>
|
|
||||||
<option value="slow">Slow</option>
|
|
||||||
</select>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="难度">
|
|
||||||
<ResourceInput
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={16}
|
|
||||||
value={config.difficulty}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateConfig(enabled, { difficulty: Number(event.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="会话 TTL">
|
|
||||||
<ResourceInput
|
|
||||||
type="number"
|
|
||||||
min={60}
|
|
||||||
value={config.session_ttl}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateConfig(enabled, { session_ttl: Number(event.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="挑战 TTL">
|
|
||||||
<ResourceInput
|
|
||||||
type="number"
|
|
||||||
min={30}
|
|
||||||
value={config.challenge_ttl}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateConfig(enabled, { challenge_ttl: Number(event.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-5 xl:grid-cols-2">
|
|
||||||
{(['whitelist', 'blacklist'] as const).map((scope) => (
|
|
||||||
<div
|
|
||||||
key={scope}
|
|
||||||
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
|
||||||
{scope === 'whitelist'
|
|
||||||
? '白名单(跳过 PoW)'
|
|
||||||
: '黑名单(必须 PoW)'}
|
|
||||||
</h3>
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<ResourceField label="IP">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
value={draft[scope].ips}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateList(scope, 'ips', event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="IP CIDR">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
value={draft[scope].ip_cidrs}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateList(scope, 'ip_cidrs', event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="路径">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
value={draft[scope].paths}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateList(scope, 'paths', event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="路径正则">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
value={draft[scope].path_regexes}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateList(scope, 'path_regexes', event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="User-Agent">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
value={draft[scope].user_agents}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateList(scope, 'user_agents', event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WAFPage() {
|
export function WAFPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1080,8 +152,6 @@ export function WAFPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (groupsQuery.isLoading || routesQuery.isLoading) {
|
if (groupsQuery.isLoading || routesQuery.isLoading) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user