mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 15:06:36 +08:00
[优化] go 引用调整
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
'use client';
|
||||
|
||||
import {useId, useMemo} from 'react';
|
||||
import {Minus, Plus} from 'lucide-react';
|
||||
|
||||
import type {TlsCertificateItem} from '@/features/tls-certificates/types';
|
||||
import {ResourceInput, ResourceSelect, SecondaryButton,} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
export type DomainListRow = {
|
||||
domain: string;
|
||||
certificateId: string;
|
||||
};
|
||||
|
||||
const actionButtonBaseClassName = 'h-11 w-11 shrink-0 rounded-2xl px-0';
|
||||
const removeButtonClassName =
|
||||
'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:border-[var(--status-danger-border)] hover:bg-[var(--status-danger-soft)] hover:text-[var(--status-danger-foreground)] disabled:border-[var(--border-default)] disabled:bg-[var(--surface-muted)] disabled:text-[var(--foreground-muted)]';
|
||||
const addButtonClassName =
|
||||
'border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-[var(--foreground-secondary)] hover:border-[var(--brand-primary)] hover:bg-[var(--brand-primary-soft)] hover:text-[var(--brand-primary)]';
|
||||
|
||||
function ensureRows(rows: DomainListRow[]) {
|
||||
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
|
||||
}
|
||||
|
||||
function buildSuggestionSources(domains: string[]) {
|
||||
const values = new Set<string>();
|
||||
|
||||
for (const domain of domains) {
|
||||
const normalized = domain.trim().toLowerCase().replace(/^\*\./, '');
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
values.add(normalized);
|
||||
|
||||
const segments = normalized.split('.');
|
||||
for (let index = 1; index < segments.length - 1; index += 1) {
|
||||
values.add(segments.slice(index).join('.'));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(values);
|
||||
}
|
||||
|
||||
function buildDomainSuggestions(
|
||||
input: string,
|
||||
sources: string[],
|
||||
rows: DomainListRow[],
|
||||
) {
|
||||
const normalizedInput = input.trim().toLowerCase();
|
||||
if (!normalizedInput) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingDomains = new Set(
|
||||
rows
|
||||
.map((row) => row.domain.trim().toLowerCase())
|
||||
.filter((row) => row && row !== normalizedInput),
|
||||
);
|
||||
const suggestions: string[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
if (source.startsWith(normalizedInput) && source !== normalizedInput) {
|
||||
suggestions.push(source);
|
||||
}
|
||||
|
||||
const separatorIndex = normalizedInput.lastIndexOf('.');
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prefix = normalizedInput.slice(0, separatorIndex);
|
||||
const suffixInput = normalizedInput.slice(separatorIndex + 1);
|
||||
|
||||
if (!suffixInput || source.startsWith(suffixInput)) {
|
||||
suggestions.push(`${prefix}.${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions.filter((suggestion, index) => {
|
||||
return (
|
||||
suggestion !== normalizedInput &&
|
||||
!existingDomains.has(suggestion) &&
|
||||
suggestions.indexOf(suggestion) === index
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDomainRowsFromRoute(
|
||||
domains: string[],
|
||||
domainCertIDs: number[],
|
||||
certIDs: number[],
|
||||
): DomainListRow[] {
|
||||
if (domains.length === 0) {
|
||||
return ensureRows([]);
|
||||
}
|
||||
|
||||
if (domainCertIDs.length === domains.length) {
|
||||
return domains.map((domain, index) => ({
|
||||
domain,
|
||||
certificateId: domainCertIDs[index] ? String(domainCertIDs[index]) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
if (certIDs.length === 0) {
|
||||
return domains.map((domain) => ({ domain, certificateId: '' }));
|
||||
}
|
||||
|
||||
if (certIDs.length === 1) {
|
||||
return domains.map((domain) => ({
|
||||
domain,
|
||||
certificateId: String(certIDs[0]),
|
||||
}));
|
||||
}
|
||||
|
||||
return domains.map((domain, index) => ({
|
||||
domain,
|
||||
certificateId: certIDs[index] ? String(certIDs[index]) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
export function DomainListInput({
|
||||
rows,
|
||||
onChange,
|
||||
onBlur,
|
||||
suggestionSources = [],
|
||||
certificates = [],
|
||||
domainPlaceholder = 'app.example.com',
|
||||
}: {
|
||||
rows: DomainListRow[];
|
||||
onChange: (rows: DomainListRow[]) => void;
|
||||
onBlur?: () => void;
|
||||
suggestionSources?: string[];
|
||||
certificates?: TlsCertificateItem[];
|
||||
domainPlaceholder?: string;
|
||||
}) {
|
||||
const listId = useId();
|
||||
const safeRows = ensureRows(rows);
|
||||
const normalizedSources = useMemo(
|
||||
() => buildSuggestionSources(suggestionSources),
|
||||
[suggestionSources],
|
||||
);
|
||||
|
||||
const updateRows = (nextRows: DomainListRow[]) => {
|
||||
onChange(ensureRows(nextRows));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{safeRows.map((row, index) => {
|
||||
const suggestions = buildDomainSuggestions(
|
||||
row.domain,
|
||||
normalizedSources,
|
||||
safeRows,
|
||||
).slice(0, 4);
|
||||
|
||||
return (
|
||||
<div key={`${index}-${safeRows.length}`} className="space-y-2">
|
||||
<div className="grid gap-3 md:grid-cols-[44px_minmax(0,1fr)_280px] md:items-start">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
aria-label={`删除域名输入框 ${index + 1}`}
|
||||
className={`${actionButtonBaseClassName} ${removeButtonClassName}`}
|
||||
disabled={safeRows.length === 1}
|
||||
onClick={() => {
|
||||
if (safeRows.length === 1) {
|
||||
updateRows([{ domain: '', certificateId: '' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
updateRows(
|
||||
safeRows.filter((_, rowIndex) => rowIndex !== index),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Minus aria-hidden="true" className="h-[14px] w-[14px]" />
|
||||
</SecondaryButton>
|
||||
|
||||
<div className="min-w-0 space-y-2">
|
||||
<ResourceInput
|
||||
value={row.domain}
|
||||
list={`${listId}-${index}`}
|
||||
aria-label={`域名 ${index + 1}`}
|
||||
placeholder={
|
||||
index === 0 ? domainPlaceholder : 'www.example.com'
|
||||
}
|
||||
onBlur={onBlur}
|
||||
onChange={(event) => {
|
||||
const nextRows = safeRows.slice();
|
||||
nextRows[index] = {
|
||||
...nextRows[index],
|
||||
domain: event.target.value,
|
||||
};
|
||||
updateRows(nextRows);
|
||||
}}
|
||||
className="h-12"
|
||||
/>
|
||||
<datalist id={`${listId}-${index}`}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<option key={suggestion} value={suggestion} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{suggestions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-1 text-xs text-[var(--foreground-secondary)] transition hover:border-[var(--border-strong)] hover:text-[var(--foreground-primary)]"
|
||||
onClick={() => {
|
||||
const nextRows = safeRows.slice();
|
||||
nextRows[index] = {
|
||||
...nextRows[index],
|
||||
domain: suggestion,
|
||||
};
|
||||
updateRows(nextRows);
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ResourceSelect
|
||||
aria-label={`证书 ${index + 1}`}
|
||||
value={row.certificateId}
|
||||
onChange={(event) => {
|
||||
const nextRows = safeRows.slice();
|
||||
nextRows[index] = {
|
||||
...nextRows[index],
|
||||
certificateId: event.target.value,
|
||||
};
|
||||
updateRows(nextRows);
|
||||
}}
|
||||
className="h-12"
|
||||
>
|
||||
<option value="">
|
||||
{certificates.length === 0 ? '暂无可选证书' : '选择证书'}
|
||||
</option>
|
||||
{certificates.map((certificate) => (
|
||||
<option key={certificate.id} value={certificate.id}>
|
||||
{certificate.name}
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
aria-label="新增域名输入框"
|
||||
className={`${actionButtonBaseClassName} ${addButtonClassName}`}
|
||||
onClick={() => {
|
||||
updateRows([...safeRows, { domain: '', certificateId: '' }]);
|
||||
}}
|
||||
>
|
||||
<Plus aria-hidden="true" className="h-[14px] w-[14px]" />
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,523 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation, useQuery} from '@tanstack/react-query';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {Controller, useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Drawer} from '@/components/ui/drawer';
|
||||
import {getManagedDomains} from '@/features/managed-domains/api/managed-domains';
|
||||
import {getPagesProjects} from '@/features/pages/api/pages';
|
||||
import {createProxyRoute} from '@/features/proxy-routes/api/proxy-routes';
|
||||
import {DomainListInput, type DomainListRow,} from '@/features/proxy-routes/components/domain-list-input';
|
||||
import {
|
||||
buildOriginUrl,
|
||||
getErrorMessage,
|
||||
parseOriginUrl,
|
||||
parseOriginUrls,
|
||||
validateDomains,
|
||||
} from '@/features/proxy-routes/helpers';
|
||||
import {getNodes} from '@/features/nodes/api/nodes';
|
||||
import type {ProxyRouteItem} from '@/features/proxy-routes/types';
|
||||
import {getTlsCertificates} from '@/features/tls-certificates/api/tls-certificates';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceTextarea,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const domainRowSchema = z.object({
|
||||
domain: z.string(),
|
||||
certificateId: z.string(),
|
||||
});
|
||||
|
||||
const createWebsiteSchema = z
|
||||
.object({
|
||||
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
||||
domain_rows: z.array(domainRowSchema).min(1),
|
||||
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
|
||||
origin_urls_text: z.string().trim(),
|
||||
tunnel_id: z.string().optional(),
|
||||
tunnel_target_addr: z.string().trim().optional(),
|
||||
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||
pages_project_id: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
redirect_http: z.boolean(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const domains = value.domain_rows
|
||||
.map((item) => item.domain.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const domainError = validateDomains(domains);
|
||||
if (domainError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['domain_rows'],
|
||||
message: domainError,
|
||||
});
|
||||
}
|
||||
|
||||
if (value.upstream_type === 'direct') {
|
||||
if (!value.origin_urls_text.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: '请至少填写一个上游地址',
|
||||
});
|
||||
} else {
|
||||
const { error } = parseOriginUrls(value.origin_urls_text);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (value.upstream_type === 'tunnel') {
|
||||
if (!value.tunnel_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_id'],
|
||||
message: '请选择内网穿透隧道',
|
||||
});
|
||||
}
|
||||
if (!value.tunnel_target_addr) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_target_addr'],
|
||||
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
|
||||
});
|
||||
}
|
||||
} else if (!value.pages_project_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['pages_project_id'],
|
||||
message: '请选择 Pages 项目',
|
||||
});
|
||||
}
|
||||
|
||||
const selectedCertificateCount = new Set(
|
||||
value.domain_rows
|
||||
.map((item) => Number(item.certificateId))
|
||||
.filter((item) => Number.isFinite(item) && item > 0),
|
||||
).size;
|
||||
if (value.redirect_http && selectedCertificateCount === 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['redirect_http'],
|
||||
message: '启用 HTTP 跳转前,请先为域名选择证书',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type CreateWebsiteFormValues = z.infer<typeof createWebsiteSchema>;
|
||||
|
||||
const defaultValues: CreateWebsiteFormValues = {
|
||||
site_name: '',
|
||||
domain_rows: [{ domain: '', certificateId: '' }],
|
||||
upstream_type: 'direct',
|
||||
origin_urls_text: '',
|
||||
tunnel_id: '',
|
||||
tunnel_target_addr: '',
|
||||
tunnel_target_protocol: 'http',
|
||||
pages_project_id: '',
|
||||
enabled: true,
|
||||
redirect_http: false,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
function normalizeSelectedCertificateIDs(rows: DomainListRow[]) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.filter((item) => item.domain.trim() !== '')
|
||||
.map((item) => Number(item.certificateId))
|
||||
.filter((item) => Number.isFinite(item) && item > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildDomainCertificateIDs(rows: DomainListRow[]) {
|
||||
return rows
|
||||
.filter((item) => item.domain.trim() !== '')
|
||||
.map((item) => {
|
||||
const certificateID = Number(item.certificateId);
|
||||
return Number.isFinite(certificateID) && certificateID > 0
|
||||
? certificateID
|
||||
: 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function ProxyRouteCreateDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
domainSuggestionSources = [],
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: (route: ProxyRouteItem) => void;
|
||||
domainSuggestionSources?: string[];
|
||||
}) {
|
||||
const form = useForm<CreateWebsiteFormValues>({
|
||||
resolver: zodResolver(createWebsiteSchema),
|
||||
defaultValues,
|
||||
});
|
||||
const managedDomainsQuery = useQuery({
|
||||
queryKey: ['managed-domains'],
|
||||
queryFn: getManagedDomains,
|
||||
enabled: open,
|
||||
});
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: ['tls-certificates', 'list'],
|
||||
queryFn: getTlsCertificates,
|
||||
enabled: open,
|
||||
});
|
||||
const tunnelsQuery = useQuery({
|
||||
queryKey: ['nodes'],
|
||||
queryFn: getNodes,
|
||||
enabled: open,
|
||||
});
|
||||
const pagesProjectsQuery = useQuery({
|
||||
queryKey: ['pages-projects'],
|
||||
queryFn: getPagesProjects,
|
||||
enabled: open,
|
||||
});
|
||||
const tunnelClients = (tunnelsQuery.data ?? []).filter(
|
||||
(node) => node.node_type === 'tunnel_client',
|
||||
);
|
||||
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
|
||||
(project) => project.enabled && project.active_deployment_id,
|
||||
);
|
||||
|
||||
const combinedDomainSuggestions = useMemo(
|
||||
() => [
|
||||
...domainSuggestionSources,
|
||||
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
|
||||
],
|
||||
[domainSuggestionSources, managedDomainsQuery.data],
|
||||
);
|
||||
const selectedCertificateIDs = normalizeSelectedCertificateIDs(
|
||||
form.watch('domain_rows'),
|
||||
);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (values: CreateWebsiteFormValues) => {
|
||||
const domains = values.domain_rows
|
||||
.map((item) => item.domain.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
|
||||
const selectedCertIDs = normalizeSelectedCertificateIDs(
|
||||
values.domain_rows,
|
||||
);
|
||||
|
||||
let originUrl = '';
|
||||
const originHost = '';
|
||||
let upstreams: string[] = [];
|
||||
|
||||
if (values.upstream_type === 'direct') {
|
||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||
originUrl = buildOriginUrl(
|
||||
primaryOrigin.scheme,
|
||||
primaryOrigin.address,
|
||||
primaryOrigin.port,
|
||||
primaryOrigin.uri,
|
||||
);
|
||||
upstreams = urls.slice(1);
|
||||
} else if (values.upstream_type === 'tunnel') {
|
||||
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
|
||||
} else {
|
||||
originUrl = 'http://127.0.0.1';
|
||||
}
|
||||
|
||||
return createProxyRoute({
|
||||
site_name: values.site_name.trim() || domains[0],
|
||||
domain: domains[0],
|
||||
domains,
|
||||
origin_id: null,
|
||||
origin_url: originUrl,
|
||||
origin_scheme:
|
||||
values.upstream_type === 'direct'
|
||||
? parseOriginUrl(originUrl).scheme
|
||||
: 'http',
|
||||
origin_address:
|
||||
values.upstream_type === 'direct'
|
||||
? parseOriginUrl(originUrl).address
|
||||
: values.tunnel_target_addr || '127.0.0.1',
|
||||
origin_port:
|
||||
values.upstream_type === 'direct'
|
||||
? parseOriginUrl(originUrl).port
|
||||
: '80',
|
||||
origin_uri:
|
||||
values.upstream_type === 'direct'
|
||||
? parseOriginUrl(originUrl).uri
|
||||
: '',
|
||||
origin_host: originHost,
|
||||
upstreams,
|
||||
enabled: values.enabled,
|
||||
enable_https: selectedCertIDs.length > 0,
|
||||
cert_id: selectedCertIDs.length > 0 ? selectedCertIDs[0] : null,
|
||||
cert_ids: selectedCertIDs,
|
||||
domain_cert_ids: domainCertIDs,
|
||||
redirect_http:
|
||||
selectedCertIDs.length > 0 ? values.redirect_http : false,
|
||||
limit_conn_per_server: 0,
|
||||
limit_conn_per_ip: 0,
|
||||
limit_rate: '',
|
||||
cache_enabled: false,
|
||||
cache_policy: 'url',
|
||||
cache_rules: [],
|
||||
custom_headers: [],
|
||||
pow_enabled: false,
|
||||
pow_config: '{}',
|
||||
basic_auth_enabled: false,
|
||||
remark: values.remark.trim(),
|
||||
upstream_type: values.upstream_type,
|
||||
tunnel_node_id:
|
||||
values.upstream_type === 'tunnel' && values.tunnel_id
|
||||
? Number(values.tunnel_id)
|
||||
: null,
|
||||
tunnel_target_addr:
|
||||
values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
|
||||
tunnel_target_protocol:
|
||||
values.upstream_type === 'tunnel'
|
||||
? values.tunnel_target_protocol
|
||||
: '',
|
||||
pages_project_id:
|
||||
values.upstream_type === 'pages' && values.pages_project_id
|
||||
? Number(values.pages_project_id)
|
||||
: null,
|
||||
});
|
||||
},
|
||||
onSuccess: (route) => {
|
||||
form.reset(defaultValues);
|
||||
onOpenChange(false);
|
||||
onCreated(route);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
}, [form, open]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
direction="right"
|
||||
title="新建规则"
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="create-website-form"
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? '创建中...' : '创建'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="create-website-form"
|
||||
className="space-y-5"
|
||||
onSubmit={form.handleSubmit((values) => createMutation.mutate(values))}
|
||||
>
|
||||
<ResourceField
|
||||
label="站点标识"
|
||||
hint="可选,留空时会自动使用第一个域名。"
|
||||
error={form.formState.errors.site_name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
{...form.register('site_name')}
|
||||
placeholder="marketing-site"
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="域名列表"
|
||||
hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。"
|
||||
error={
|
||||
form.formState.errors.domain_rows?.message as string | undefined
|
||||
}
|
||||
container="div"
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="domain_rows"
|
||||
render={({ field }) => (
|
||||
<DomainListInput
|
||||
rows={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
suggestionSources={combinedDomainSuggestions}
|
||||
certificates={certificatesQuery.data ?? []}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="HTTP 自动跳转到 HTTPS"
|
||||
description={
|
||||
selectedCertificateIDs.length > 0
|
||||
? '勾选后会额外生成 80 端口重定向规则。'
|
||||
: '至少为一个域名选择证书后才能启用。'
|
||||
}
|
||||
checked={form.watch('redirect_http')}
|
||||
disabled={selectedCertificateIDs.length === 0}
|
||||
onChange={(checked) =>
|
||||
form.setValue('redirect_http', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-[var(--foreground-primary)]">
|
||||
回源方式
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="direct"
|
||||
{...form.register('upstream_type')}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
直连上游
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="tunnel"
|
||||
{...form.register('upstream_type')}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
内网穿透 (Tunnel)
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="pages"
|
||||
{...form.register('upstream_type')}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Pages 静态站点
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.watch('upstream_type') === 'direct' ? (
|
||||
<ResourceField
|
||||
label="上游地址"
|
||||
hint="每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。"
|
||||
error={form.formState.errors.origin_urls_text?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
aria-label="上游地址"
|
||||
placeholder={
|
||||
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
|
||||
}
|
||||
{...form.register('origin_urls_text')}
|
||||
/>
|
||||
</ResourceField>
|
||||
) : form.watch('upstream_type') === 'tunnel' ? (
|
||||
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
|
||||
<ResourceField
|
||||
label="选择内网穿透隧道"
|
||||
hint="将请求转发到该隧道连接的客户端节点。"
|
||||
error={form.formState.errors.tunnel_id?.message}
|
||||
>
|
||||
<select
|
||||
{...form.register('tunnel_id')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{tunnelClients.map((tunnel) => (
|
||||
<option key={tunnel.id} value={tunnel.id}>
|
||||
{tunnel.name} (
|
||||
{tunnel.status === 'online' ? '在线' : '离线'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="内网服务协议"
|
||||
hint="转发到内网服务时使用的协议。"
|
||||
error={form.formState.errors.tunnel_target_protocol?.message}
|
||||
>
|
||||
<select
|
||||
{...form.register('tunnel_target_protocol')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="内网服务地址"
|
||||
hint="例如: 127.0.0.1:8080 或 192.168.1.10:80"
|
||||
error={form.formState.errors.tunnel_target_addr?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="127.0.0.1:8080"
|
||||
{...form.register('tunnel_target_addr')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
|
||||
<ResourceField
|
||||
label="选择 Pages 项目"
|
||||
hint="仅显示已启用且已有激活部署的 Pages 项目。"
|
||||
error={form.formState.errors.pages_project_id?.message}
|
||||
>
|
||||
<select
|
||||
{...form.register('pages_project_id')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{pagesProjects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name} ({project.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</ResourceField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToggleField
|
||||
label="创建后立即启用"
|
||||
description="关闭后站点会以草稿保存,后续仍可继续编辑。"
|
||||
checked={form.watch('enabled')}
|
||||
onChange={(checked) =>
|
||||
form.setValue('enabled', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceField
|
||||
label="备注"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea {...form.register('remark')} />
|
||||
</ResourceField>
|
||||
|
||||
{createMutation.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{getErrorMessage(createMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
|
||||
import {EmptyState} from '@/components/feedback/empty-state';
|
||||
import {ErrorState} from '@/components/feedback/error-state';
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {PageHeader} from '@/components/layout/page-header';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {StatusBadge} from '@/components/ui/status-badge';
|
||||
import {getConfigVersionDiff, publishConfigVersion,} from '@/features/config-versions/api/config-versions';
|
||||
import {ProxyRouteCreateDrawer} from '@/features/proxy-routes/components/proxy-route-create-drawer';
|
||||
import {getErrorMessage, getUpstreamSummary, getWebsiteStatusBadges,} from '@/features/proxy-routes/helpers';
|
||||
import {deleteProxyRoute, getProxyRoutes,} from '@/features/proxy-routes/api/proxy-routes';
|
||||
import type {ProxyRouteItem} from '@/features/proxy-routes/types';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
function hasConfigChanges(diff: {
|
||||
active_version?: string;
|
||||
added_sites: string[];
|
||||
removed_sites: string[];
|
||||
modified_sites: string[];
|
||||
added_domains: string[];
|
||||
removed_domains: string[];
|
||||
modified_domains: string[];
|
||||
main_config_changed: boolean;
|
||||
waf_config_changed?: boolean;
|
||||
changed_option_keys: string[];
|
||||
}) {
|
||||
return (
|
||||
diff.added_sites.length > 0 ||
|
||||
diff.removed_sites.length > 0 ||
|
||||
diff.modified_sites.length > 0 ||
|
||||
diff.added_domains.length > 0 ||
|
||||
diff.removed_domains.length > 0 ||
|
||||
diff.modified_domains.length > 0 ||
|
||||
diff.main_config_changed ||
|
||||
Boolean(diff.waf_config_changed) ||
|
||||
diff.changed_option_keys.length > 0 ||
|
||||
!diff.active_version
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyRoutesPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
const routesQuery = useQuery({
|
||||
queryKey: ['proxy-routes'],
|
||||
queryFn: getProxyRoutes,
|
||||
});
|
||||
const diffQuery = useQuery({
|
||||
queryKey: ['config-versions', 'diff'],
|
||||
queryFn: getConfigVersionDiff,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProxyRoute,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: '网站已删除。' });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['config-versions', 'diff'],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: publishConfigVersion,
|
||||
onSuccess: async (version) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `配置已发布,版本号 ${version.version}。`,
|
||||
});
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['config-versions', 'diff'],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
|
||||
const filteredRoutes = useMemo(() => {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
if (!normalizedKeyword) {
|
||||
return routes;
|
||||
}
|
||||
|
||||
return routes.filter((route) => {
|
||||
const haystack = [
|
||||
route.site_name,
|
||||
route.primary_domain,
|
||||
...route.domains,
|
||||
...route.upstream_list,
|
||||
route.remark,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return haystack.includes(normalizedKeyword);
|
||||
});
|
||||
}, [keyword, routes]);
|
||||
|
||||
const diff = diffQuery.data;
|
||||
const totalDomains = routes.reduce(
|
||||
(sum, route) => sum + route.domain_count,
|
||||
0,
|
||||
);
|
||||
const enabledCount = routes.filter((route) => route.enabled).length;
|
||||
|
||||
const handleDelete = (route: ProxyRouteItem) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认删除网站 ${route.site_name} 吗?\n这会删除该站点下的全部域名与配置。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(route.id);
|
||||
};
|
||||
|
||||
const handlePublish = () => {
|
||||
if (!diff || !hasConfigChanges(diff)) {
|
||||
setFeedback({ tone: 'info', message: '当前草稿没有可发布的变更。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = [
|
||||
`新增网站 ${diff.added_sites.length} 个`,
|
||||
`删除网站 ${diff.removed_sites.length} 个`,
|
||||
`修改网站 ${diff.modified_sites.length} 个`,
|
||||
`域名变更 ${diff.added_domains.length + diff.removed_domains.length + diff.modified_domains.length} 项`,
|
||||
].join(',');
|
||||
|
||||
if (!window.confirm(`确认发布当前配置吗?\n${summary}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
publishMutation.mutate(false);
|
||||
};
|
||||
|
||||
if (routesQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (routesQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="规则列表加载失败"
|
||||
description={getErrorMessage(routesQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="规则配置"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handlePublish}
|
||||
disabled={publishMutation.isPending || diffQuery.isLoading}
|
||||
>
|
||||
{publishMutation.isPending ? '发布中...' : '发布配置'}
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFeedback(null);
|
||||
setIsCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建规则
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
<AppCard title="网站数">
|
||||
<div className="space-y-2">
|
||||
<p className="text-3xl font-semibold text-[var(--foreground-primary)]">
|
||||
{routes.length}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
已启用 {enabledCount} 个
|
||||
</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="域名数">
|
||||
<div className="space-y-2">
|
||||
<p className="text-3xl font-semibold text-[var(--foreground-primary)]">
|
||||
{totalDomains}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
每个网站第一行域名为主域名
|
||||
</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="草稿状态">
|
||||
<div className="space-y-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
diff && hasConfigChanges(diff)
|
||||
? '有待发布变更'
|
||||
: '已与线上一致'
|
||||
}
|
||||
variant={diff && hasConfigChanges(diff) ? 'warning' : 'success'}
|
||||
/>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{diff
|
||||
? `网站变更 ${diff.added_sites.length + diff.removed_sites.length + diff.modified_sites.length} 项`
|
||||
: '正在读取 diff...'}
|
||||
</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="发布摘要">
|
||||
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
|
||||
<p>新增网站:{diff?.added_sites.length ?? 0}</p>
|
||||
<p>删除网站:{diff?.removed_sites.length ?? 0}</p>
|
||||
<p>修改网站:{diff?.modified_sites.length ?? 0}</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard title="规则列表">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="max-w-xl">
|
||||
<ResourceInput
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索站点"
|
||||
/>
|
||||
</div>
|
||||
{diff && hasConfigChanges(diff) ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{diff.modified_sites.length > 0 ? (
|
||||
<StatusBadge
|
||||
label={`修改网站 ${diff.modified_sites.length}`}
|
||||
variant="warning"
|
||||
/>
|
||||
) : null}
|
||||
{diff.added_sites.length > 0 ? (
|
||||
<StatusBadge
|
||||
label={`新增网站 ${diff.added_sites.length}`}
|
||||
variant="success"
|
||||
/>
|
||||
) : null}
|
||||
{diff.removed_sites.length > 0 ? (
|
||||
<StatusBadge
|
||||
label={`删除网站 ${diff.removed_sites.length}`}
|
||||
variant="danger"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{filteredRoutes.length === 0 ? (
|
||||
<EmptyState
|
||||
title={routes.length === 0 ? '暂无网站' : '没有匹配结果'}
|
||||
description={
|
||||
routes.length === 0
|
||||
? '先创建一个网站,再进入配置子页面继续补齐 HTTPS、缓存和限流。'
|
||||
: '试试调整搜索词,或者清空筛选条件。'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{filteredRoutes.map((route) => (
|
||||
<article
|
||||
key={route.id}
|
||||
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{route.site_name}
|
||||
</h2>
|
||||
{getWebsiteStatusBadges(route).map((badge) => (
|
||||
<StatusBadge
|
||||
key={`${route.id}-${badge.label}`}
|
||||
label={badge.label}
|
||||
variant={badge.variant}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/proxy-route/detail?id=${route.id}§ion=domains`}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
配置
|
||||
</Link>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(route)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
域名列表
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{route.domains.join(' / ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
上游摘要
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{getUpstreamSummary(route)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{route.remark || null}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<ProxyRouteCreateDrawer
|
||||
open={isCreateOpen}
|
||||
onOpenChange={setIsCreateOpen}
|
||||
domainSuggestionSources={routes.flatMap((route) => route.domains)}
|
||||
onCreated={async (route) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['config-versions', 'diff'],
|
||||
}),
|
||||
]);
|
||||
router.push(`/proxy-route/detail?id=${route.id}§ion=domains`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user