mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
[优化] 改名
This commit is contained in:
@@ -0,0 +1,820 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
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 { AppModal } from '@/components/ui/app-modal';
|
||||
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 {
|
||||
createProxyRoute,
|
||||
deleteProxyRoute,
|
||||
getProxyRoutes,
|
||||
getTlsCertificates,
|
||||
matchManagedDomainCertificate,
|
||||
updateProxyRoute,
|
||||
} from '@/features/proxy-routes/api/proxy-routes';
|
||||
import type {
|
||||
ManagedDomainMatchResult,
|
||||
ProxyRouteCustomHeader,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
TlsCertificateItem,
|
||||
} from '@/features/proxy-routes/types';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
ResourceTextarea,
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
const customHeaderSchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const proxyRouteSchema = z
|
||||
.object({
|
||||
domain: z.string().trim().min(1, '请输入域名'),
|
||||
origin_url: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入源站地址')
|
||||
.refine(
|
||||
(value) => /^https?:\/\//.test(value),
|
||||
'源站地址必须以 http:// 或 https:// 开头',
|
||||
)
|
||||
.refine((value) => {
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, '请输入合法的源站地址'),
|
||||
enabled: z.boolean(),
|
||||
enable_https: z.boolean(),
|
||||
cert_id: z.string(),
|
||||
redirect_http: z.boolean(),
|
||||
custom_headers: z.array(customHeaderSchema).min(1),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.enable_https && !value.cert_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['cert_id'],
|
||||
message: '启用 HTTPS 时必须选择证书',
|
||||
});
|
||||
}
|
||||
|
||||
value.custom_headers.forEach((header, index) => {
|
||||
const key = header.key.trim();
|
||||
const headerValue = header.value.trim();
|
||||
|
||||
if (!key && !headerValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['custom_headers', index, 'key'],
|
||||
message: '请求头名称不能为空',
|
||||
});
|
||||
}
|
||||
|
||||
if (key && !/^[A-Za-z0-9_-]+$/.test(key)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['custom_headers', index, 'key'],
|
||||
message: '请求头名称仅支持字母、数字、下划线和中划线',
|
||||
});
|
||||
}
|
||||
|
||||
if (/\r|\n/.test(key) || /\r|\n/.test(headerValue)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['custom_headers', index, 'value'],
|
||||
message: '请求头不能包含换行',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
type ProxyRouteFormValues = z.infer<typeof proxyRouteSchema>;
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
const defaultValues: ProxyRouteFormValues = {
|
||||
domain: '',
|
||||
origin_url: '',
|
||||
enabled: true,
|
||||
enable_https: false,
|
||||
cert_id: '',
|
||||
redirect_http: false,
|
||||
custom_headers: [{ key: '', value: '' }],
|
||||
remark: '',
|
||||
};
|
||||
|
||||
const routesQueryKey = ['proxy-routes'];
|
||||
const certificatesQueryKey = ['tls-certificates'];
|
||||
const versionsQueryKey = ['config-versions'];
|
||||
|
||||
function hasConfigChanges(diff: {
|
||||
active_version?: string;
|
||||
added_domains: string[];
|
||||
removed_domains: string[];
|
||||
modified_domains: string[];
|
||||
main_config_changed: boolean;
|
||||
changed_option_keys: string[];
|
||||
}) {
|
||||
return (
|
||||
diff.added_domains.length > 0 ||
|
||||
diff.removed_domains.length > 0 ||
|
||||
diff.modified_domains.length > 0 ||
|
||||
diff.main_config_changed ||
|
||||
diff.changed_option_keys.length > 0 ||
|
||||
!diff.active_version
|
||||
);
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function parseCustomHeaders(rawValue: string) {
|
||||
if (!rawValue) {
|
||||
return [] as ProxyRouteCustomHeader[];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as ProxyRouteCustomHeader[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildCertificateLabel(certificate: TlsCertificateItem) {
|
||||
return certificate.not_after
|
||||
? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})`
|
||||
: certificate.name;
|
||||
}
|
||||
|
||||
function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload {
|
||||
return {
|
||||
domain: values.domain.trim(),
|
||||
origin_url: values.origin_url.trim(),
|
||||
enabled: values.enabled,
|
||||
enable_https: values.enable_https,
|
||||
cert_id:
|
||||
values.enable_https && values.cert_id ? Number(values.cert_id) : null,
|
||||
redirect_http: values.enable_https ? values.redirect_http : false,
|
||||
custom_headers: values.custom_headers
|
||||
.map((item) => ({ key: item.key.trim(), value: item.value.trim() }))
|
||||
.filter((item) => item.key || item.value),
|
||||
remark: values.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function toFormValues(route: ProxyRouteItem): ProxyRouteFormValues {
|
||||
const headers = parseCustomHeaders(route.custom_headers);
|
||||
|
||||
return {
|
||||
domain: route.domain,
|
||||
origin_url: route.origin_url,
|
||||
enabled: route.enabled,
|
||||
enable_https: route.enable_https,
|
||||
cert_id: route.cert_id ? String(route.cert_id) : '',
|
||||
redirect_http: route.redirect_http,
|
||||
custom_headers: headers.length > 0 ? headers : [{ key: '', value: '' }],
|
||||
remark: route.remark || '',
|
||||
};
|
||||
}
|
||||
|
||||
function getMatchMessage(
|
||||
matchResult: ManagedDomainMatchResult | null,
|
||||
isMatching: boolean,
|
||||
domain: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
if (!enabled) {
|
||||
return '启用 HTTPS 后,系统会根据输入域名尝试自动匹配托管证书。';
|
||||
}
|
||||
|
||||
if (isMatching) {
|
||||
return '正在按域名自动匹配托管证书...';
|
||||
}
|
||||
|
||||
if (!domain.trim()) {
|
||||
return '输入域名后会自动匹配证书,并优先推荐精确匹配规则。';
|
||||
}
|
||||
|
||||
if (matchResult?.matched && matchResult.candidate) {
|
||||
return `已匹配${matchResult.candidate.match_type === 'exact' ? '精确' : '通配符'}规则 ${matchResult.candidate.domain},推荐证书:${matchResult.candidate.certificate_name}`;
|
||||
}
|
||||
|
||||
return '未找到匹配证书,可继续手动选择。';
|
||||
}
|
||||
|
||||
export function ProxyRoutesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [matchResult, setMatchResult] =
|
||||
useState<ManagedDomainMatchResult | null>(null);
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
|
||||
const form = useForm<ProxyRouteFormValues>({
|
||||
resolver: zodResolver(proxyRouteSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { fields, append, remove, replace } = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'custom_headers',
|
||||
});
|
||||
|
||||
const watchedDomain = useWatch({ control: form.control, name: 'domain' });
|
||||
const watchedEnabled = useWatch({ control: form.control, name: 'enabled' });
|
||||
const watchedEnableHttps = useWatch({
|
||||
control: form.control,
|
||||
name: 'enable_https',
|
||||
});
|
||||
const watchedRedirectHttp = useWatch({
|
||||
control: form.control,
|
||||
name: 'redirect_http',
|
||||
});
|
||||
const watchedCertId = useWatch({ control: form.control, name: 'cert_id' });
|
||||
|
||||
const routesQuery = useQuery({
|
||||
queryKey: routesQueryKey,
|
||||
queryFn: getProxyRoutes,
|
||||
});
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: certificatesQueryKey,
|
||||
queryFn: getTlsCertificates,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: ProxyRouteFormValues) => {
|
||||
const payload = toPayload(values);
|
||||
return editingRouteId
|
||||
? updateProxyRoute(editingRouteId, payload)
|
||||
: createProxyRoute(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: editingRouteId ? '规则已更新。' : '规则已创建。',
|
||||
});
|
||||
setEditingRouteId(null);
|
||||
setIsEditorOpen(false);
|
||||
setMatchResult(null);
|
||||
form.reset(defaultValues);
|
||||
await queryClient.invalidateQueries({ queryKey: routesQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProxyRoute,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: '规则已删除。' });
|
||||
await queryClient.invalidateQueries({ queryKey: routesQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: publishConfigVersion,
|
||||
onSuccess: async (version) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `发布成功,版本 ${version.version}`,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const handlePublish = async () => {
|
||||
setFeedback(null);
|
||||
|
||||
try {
|
||||
const diff = await getConfigVersionDiff();
|
||||
if (!hasConfigChanges(diff)) {
|
||||
setFeedback({
|
||||
tone: 'info',
|
||||
message: '当前规则没有变更,已阻止重复发布。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
publishMutation.mutate();
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!watchedEnableHttps) {
|
||||
setMatchResult(null);
|
||||
setIsMatching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedDomain = watchedDomain.trim().toLowerCase();
|
||||
if (!normalizedDomain) {
|
||||
setMatchResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
setIsMatching(true);
|
||||
const result = await matchManagedDomainCertificate(normalizedDomain);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setMatchResult(result);
|
||||
if (result.candidate?.certificate_id && !form.getValues('cert_id')) {
|
||||
form.setValue('cert_id', String(result.candidate.certificate_id), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setMatchResult(null);
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsMatching(false);
|
||||
}
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [watchedDomain, watchedEnableHttps, form]);
|
||||
|
||||
const certificates = useMemo(
|
||||
() => certificatesQuery.data ?? [],
|
||||
[certificatesQuery.data],
|
||||
);
|
||||
|
||||
const handleReset = () => {
|
||||
setFeedback(null);
|
||||
setEditingRouteId(null);
|
||||
setIsEditorOpen(false);
|
||||
setMatchResult(null);
|
||||
form.reset(defaultValues);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFeedback(null);
|
||||
setEditingRouteId(null);
|
||||
setMatchResult(null);
|
||||
form.reset(defaultValues);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setFeedback(null);
|
||||
saveMutation.mutate(values);
|
||||
});
|
||||
|
||||
const handleEdit = (route: ProxyRouteItem) => {
|
||||
setFeedback(null);
|
||||
setEditingRouteId(route.id);
|
||||
setMatchResult(null);
|
||||
form.reset(toFormValues(route));
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (route: ProxyRouteItem) => {
|
||||
if (!window.confirm(`确认删除规则 ${route.domain} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(route.id);
|
||||
};
|
||||
|
||||
const handleRemoveHeader = (index: number) => {
|
||||
if (fields.length === 1) {
|
||||
replace([{ key: '', value: '' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
remove(index);
|
||||
};
|
||||
|
||||
const routes = routesQuery.data || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="反代规则"
|
||||
description="维护域名到源站的映射、HTTPS 证书绑定与自定义请求头,并可直接触发配置发布。"
|
||||
action={
|
||||
<>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => void handlePublish()}
|
||||
disabled={publishMutation.isPending}
|
||||
>
|
||||
{publishMutation.isPending ? '发布中...' : '发布当前规则'}
|
||||
</PrimaryButton>
|
||||
<SecondaryButton type="button" onClick={handleCreate}>
|
||||
新增规则
|
||||
</SecondaryButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||
) : null}
|
||||
|
||||
<AppCard title="规则列表">
|
||||
{routesQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : routesQuery.isError ? (
|
||||
<ErrorState
|
||||
title="规则列表加载失败"
|
||||
description={getErrorMessage(routesQuery.error)}
|
||||
/>
|
||||
) : routes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无反代规则"
|
||||
description="请先创建至少一条规则,然后再进行发布。"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">域名</th>
|
||||
<th className="px-3 py-3 font-medium">源站地址</th>
|
||||
<th className="px-3 py-3 font-medium">HTTPS</th>
|
||||
<th className="px-3 py-3 font-medium">请求头</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">备注</th>
|
||||
<th className="px-3 py-3 font-medium">更新时间</th>
|
||||
<th className="px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{routes.map((route) => {
|
||||
const headers = parseCustomHeaders(route.custom_headers);
|
||||
|
||||
return (
|
||||
<tr key={route.id} className="align-top">
|
||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||
{route.domain}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{route.origin_url}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
{route.enable_https ? (
|
||||
<div className="space-y-2">
|
||||
<StatusBadge
|
||||
label={
|
||||
route.redirect_http
|
||||
? 'HTTPS + 重定向'
|
||||
: 'HTTPS'
|
||||
}
|
||||
variant="info"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge label="HTTP" variant="warning" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={
|
||||
headers.length > 0 ? `${headers.length} 条` : '无'
|
||||
}
|
||||
variant={headers.length > 0 ? 'success' : 'warning'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={route.enabled ? '启用' : '停用'}
|
||||
variant={route.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{route.remark || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(route.updated_at)}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => handleEdit(route)}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
编辑
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(route)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
<AppModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={handleReset}
|
||||
title={editingRouteId ? '编辑规则' : '新增规则'}
|
||||
description="新增或修改反代规则后,可直接回到列表页继续发布。"
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="proxy-route-editor-form"
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending
|
||||
? '保存中...'
|
||||
: editingRouteId
|
||||
? '保存修改'
|
||||
: '新增规则'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="proxy-route-editor-form"
|
||||
className="space-y-5"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="域名"
|
||||
hint="示例:example.com"
|
||||
error={form.formState.errors.domain?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="example.com"
|
||||
{...form.register('domain')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="源站地址"
|
||||
hint="示例:https://origin.internal"
|
||||
error={form.formState.errors.origin_url?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="https://origin.internal"
|
||||
{...form.register('origin_url')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ToggleField
|
||||
label="启用规则"
|
||||
description="关闭后该规则不会参与配置渲染与发布。"
|
||||
checked={watchedEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('enabled', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<ToggleField
|
||||
label="启用 HTTPS"
|
||||
description="启用后必须关联 TLS 证书,可选择是否将 HTTP 自动重定向到 HTTPS。"
|
||||
checked={watchedEnableHttps}
|
||||
onChange={(checked) => {
|
||||
form.setValue('enable_https', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
if (!checked) {
|
||||
form.setValue('cert_id', '', {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue('redirect_http', false, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<ResourceField
|
||||
label="TLS 证书"
|
||||
hint="启用 HTTPS 后可自动推荐匹配证书,也支持手动选择。"
|
||||
error={form.formState.errors.cert_id?.message}
|
||||
>
|
||||
<ResourceSelect
|
||||
value={watchedCertId}
|
||||
disabled={!watchedEnableHttps || certificatesQuery.isLoading}
|
||||
onChange={(event) =>
|
||||
form.setValue('cert_id', event.target.value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">请选择证书</option>
|
||||
{certificates.map((certificate) => (
|
||||
<option key={certificate.id} value={certificate.id}>
|
||||
{buildCertificateLabel(certificate)}
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="HTTP 跳转 HTTPS"
|
||||
description="仅在启用 HTTPS 后可开启。开启后会将 HTTP 请求重定向到 HTTPS。"
|
||||
checked={watchedRedirectHttp}
|
||||
disabled={!watchedEnableHttps}
|
||||
onChange={(checked) =>
|
||||
form.setValue('redirect_http', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
自定义请求头
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
可为空。若填写,请保证 Header 名称合法且不包含换行。
|
||||
</p>
|
||||
</div>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => append({ key: '', value: '' })}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
添加请求头
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid gap-3 md:grid-cols-[1fr_1fr_auto]"
|
||||
>
|
||||
<ResourceField
|
||||
label={
|
||||
index === 0 ? 'Header 名称' : `Header 名称 ${index + 1}`
|
||||
}
|
||||
error={
|
||||
form.formState.errors.custom_headers?.[index]?.key
|
||||
?.message
|
||||
}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="X-Trace-Id"
|
||||
{...form.register(`custom_headers.${index}.key`)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label={index === 0 ? 'Header 值' : `Header 值 ${index + 1}`}
|
||||
error={
|
||||
form.formState.errors.custom_headers?.[index]?.value
|
||||
?.message
|
||||
}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="$request_id"
|
||||
{...form.register(`custom_headers.${index}.value`)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<div className="flex items-end">
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleRemoveHeader(index)}
|
||||
className="w-full px-3 py-3 text-xs md:w-auto"
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppCard
|
||||
title="证书匹配提示"
|
||||
description="根据域名自动匹配托管证书,优先使用精确匹配规则。"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{getMatchMessage(
|
||||
matchResult,
|
||||
isMatching,
|
||||
watchedDomain,
|
||||
watchedEnableHttps,
|
||||
)}
|
||||
</p>
|
||||
{matchResult?.matched && matchResult.candidates.length > 1 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{matchResult.candidates.map((candidate) => (
|
||||
<StatusBadge
|
||||
key={`${candidate.managed_domain_id}-${candidate.certificate_id}`}
|
||||
label={`${candidate.domain} → ${candidate.certificate_name}`}
|
||||
variant={
|
||||
candidate.match_type === 'exact' ? 'success' : 'info'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm text-[var(--foreground-secondary)]">
|
||||
当前可选证书:
|
||||
{certificatesQuery.isLoading
|
||||
? '加载中...'
|
||||
: `${certificates.length} 张`}
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<ResourceField
|
||||
label="备注"
|
||||
hint="可选,便于标记用途、责任人或特殊说明。"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
placeholder="例如:主站生产流量入口"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</form>
|
||||
</AppModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user