mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 23:26:38 +08:00
[功能] 添加源站管理功能,包括源站的创建、更新、删除及列表展示
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { OriginDetailPage } from '@/features/origins/components/origin-detail-page';
|
||||
|
||||
export default function OriginDetailRoute() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return <OriginDetailPage originId={searchParams.get('id') ?? ''} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OriginsPage } from '@/features/origins/components/origins-page';
|
||||
|
||||
export default function OriginRoute() {
|
||||
return <OriginsPage />;
|
||||
}
|
||||
@@ -47,6 +47,13 @@ function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
|
||||
<path d="M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z" />
|
||||
</svg>
|
||||
);
|
||||
case 'origin':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M12 4.5c3.3 0 6 2.5 6 5.6 0 4.4-6 9.4-6 9.4s-6-5-6-9.4c0-3.1 2.7-5.6 6-5.6Z" />
|
||||
<circle cx="12" cy="10.1" r="1.9" />
|
||||
</svg>
|
||||
);
|
||||
case 'domain':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
OriginDetail,
|
||||
OriginItem,
|
||||
OriginMutationPayload,
|
||||
} from '@/features/origins/types';
|
||||
|
||||
export function getOrigins() {
|
||||
return apiRequest<OriginItem[]>('/origins/');
|
||||
}
|
||||
|
||||
export function getOrigin(id: number) {
|
||||
return apiRequest<OriginDetail>(`/origins/${id}`);
|
||||
}
|
||||
|
||||
export function createOrigin(payload: OriginMutationPayload) {
|
||||
return apiRequest<OriginItem>('/origins/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOrigin(id: number, payload: OriginMutationPayload) {
|
||||
return apiRequest<OriginItem>(`/origins/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteOrigin(id: number) {
|
||||
return apiRequest<void>(`/origins/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { 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 { deleteOrigin, getOrigin } from '@/features/origins/api/origins';
|
||||
import { OriginEditorModal } from '@/features/origins/components/origin-editor-modal';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
export function OriginDetailPage({ originId }: { originId: string }) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{
|
||||
tone: 'success' | 'danger';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const originQuery = useQuery({
|
||||
queryKey: ['origins', originId],
|
||||
queryFn: () => getOrigin(Number(originId)),
|
||||
enabled: originId !== '',
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrigin,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
router.push('/origin');
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({
|
||||
tone: 'danger',
|
||||
message:
|
||||
error instanceof Error ? error.message : '请求失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (originQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (originQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="源站详情加载失败"
|
||||
description={
|
||||
originQuery.error instanceof Error
|
||||
? originQuery.error.message
|
||||
: '请求失败,请稍后重试。'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const origin = originQuery.data;
|
||||
if (!origin) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="源站不存在"
|
||||
description="该源站可能已被删除,或当前 ID 无法匹配到源站记录。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(origin.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={origin.name}
|
||||
description="源站详情"
|
||||
action={
|
||||
<>
|
||||
<Link
|
||||
href="/origin"
|
||||
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>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsEditorOpen(true)}
|
||||
>
|
||||
编辑源站
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
删除源站
|
||||
</DangerButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
<AppCard title="源站地址">
|
||||
<p className="text-sm text-[var(--foreground-primary)]">
|
||||
{origin.address}
|
||||
</p>
|
||||
</AppCard>
|
||||
<AppCard title="绑定规则">
|
||||
<div className="space-y-3">
|
||||
<StatusBadge
|
||||
label={`${origin.route_count} 条规则`}
|
||||
variant={origin.route_count > 0 ? 'success' : 'warning'}
|
||||
/>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
编辑地址后,绑定规则的主源站地址会一起更新。
|
||||
</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
<AppCard title="创建时间">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(origin.created_at)}
|
||||
</p>
|
||||
</AppCard>
|
||||
<AppCard title="更新时间">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(origin.updated_at)}
|
||||
</p>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard title="备注">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{origin.remark || '暂无备注'}
|
||||
</p>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="关联规则"
|
||||
description="展示当前源站作为主源站绑定的规则。"
|
||||
>
|
||||
{origin.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">状态</th>
|
||||
<th className="px-3 py-3 font-medium">更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{origin.routes.map((route) => (
|
||||
<tr key={route.id}>
|
||||
<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">
|
||||
<StatusBadge
|
||||
label={route.enabled ? '启用' : '停用'}
|
||||
variant={route.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(route.updated_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<OriginEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
origin={origin}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSaved={() => {
|
||||
setFeedback({ tone: 'success', message: '源站已更新。' });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['origins', origin.id],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { createOrigin, updateOrigin } from '@/features/origins/api/origins';
|
||||
import type {
|
||||
OriginItem,
|
||||
OriginMutationPayload,
|
||||
} from '@/features/origins/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceTextarea,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const originSchema = z.object({
|
||||
name: z.string().max(255, '源站名不能超过 255 个字符'),
|
||||
address: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入源站地址')
|
||||
.refine(
|
||||
(value) => !/[/?#]/.test(value) && !value.includes('://'),
|
||||
'源站地址格式不合法',
|
||||
),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
});
|
||||
|
||||
type OriginFormValues = z.infer<typeof originSchema>;
|
||||
|
||||
function toPayload(values: OriginFormValues): OriginMutationPayload {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
address: values.address.trim(),
|
||||
remark: values.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function toFormValues(origin?: OriginItem | null): OriginFormValues {
|
||||
if (!origin) {
|
||||
return {
|
||||
name: '',
|
||||
address: '',
|
||||
remark: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: origin.name,
|
||||
address: origin.address,
|
||||
remark: origin.remark || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function OriginEditorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
origin,
|
||||
onSaved,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
origin?: OriginItem | null;
|
||||
onSaved?: (origin: OriginItem, mode: 'create' | 'update') => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<OriginFormValues>({
|
||||
resolver: zodResolver(originSchema),
|
||||
defaultValues: toFormValues(origin),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(toFormValues(origin));
|
||||
}, [form, origin, isOpen]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: OriginFormValues) => {
|
||||
const payload = toPayload(values);
|
||||
return origin ? updateOrigin(origin.id, payload) : createOrigin(payload);
|
||||
},
|
||||
onSuccess: async (savedOrigin) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['origins'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||
]);
|
||||
onSaved?.(savedOrigin, origin ? 'update' : 'create');
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={origin ? '编辑源站' : '新增源站'}
|
||||
description="源站会作为规则里的可复用地址目录,协议和端口仍由规则决定。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="origin-editor-form"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending
|
||||
? '保存中...'
|
||||
: origin
|
||||
? '保存修改'
|
||||
: '新增源站'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="origin-editor-form"
|
||||
className="space-y-5"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="源站地址"
|
||||
hint="支持 IP、域名或主机名,例如 10.0.0.10、origin.internal。"
|
||||
error={form.formState.errors.address?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="origin.internal"
|
||||
{...form.register('address')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="源站名"
|
||||
hint="可选,留空时默认使用源站地址。"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput placeholder="主站源站" {...form.register('name')} />
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<ResourceField
|
||||
label="备注"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
placeholder="例如:主站内网入口"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
{mutation.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: '请求失败,请稍后重试。'}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
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 { deleteOrigin, getOrigins } from '@/features/origins/api/origins';
|
||||
import { OriginEditorModal } from '@/features/origins/components/origin-editor-modal';
|
||||
import type { OriginItem } from '@/features/origins/types';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function OriginsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [editingOrigin, setEditingOrigin] = useState<OriginItem | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const originsQuery = useQuery({
|
||||
queryKey: ['origins'],
|
||||
queryFn: getOrigins,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrigin,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: '源站已删除。' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({
|
||||
tone: 'danger',
|
||||
message:
|
||||
error instanceof Error ? error.message : '请求失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]);
|
||||
|
||||
const handleDelete = (origin: OriginItem) => {
|
||||
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(origin.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="源站"
|
||||
description="集中维护规则复用的源站地址,减少批量改地址时的重复操作。"
|
||||
action={
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingOrigin(null);
|
||||
setFeedback(null);
|
||||
setIsEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
新增源站
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="源站列表"
|
||||
description="编辑源站地址后,所有引用该源站的规则会同步更新。"
|
||||
>
|
||||
{originsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : originsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="源站列表加载失败"
|
||||
description={
|
||||
originsQuery.error instanceof Error
|
||||
? originsQuery.error.message
|
||||
: '请求失败,请稍后重试。'
|
||||
}
|
||||
/>
|
||||
) : origins.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无源站"
|
||||
description="点击右上角“新增源站”开始录入。后续规则可直接复用这些地址。"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{origins.map((origin) => (
|
||||
<article
|
||||
key={origin.id}
|
||||
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{origin.name}
|
||||
</h2>
|
||||
<StatusBadge
|
||||
label={`${origin.route_count} 条规则`}
|
||||
variant={
|
||||
origin.route_count > 0 ? 'success' : 'warning'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--foreground-primary)]">
|
||||
{origin.address}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{origin.remark || '暂无备注'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3 text-sm text-[var(--foreground-secondary)]">
|
||||
最后更新:{formatDateTime(origin.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<Link
|
||||
href={`/origin/detail?id=${origin.id}`}
|
||||
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>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingOrigin(origin);
|
||||
setFeedback(null);
|
||||
setIsEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(origin)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<OriginEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
origin={editingOrigin}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSaved={(origin, mode) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: mode === 'create' ? '源站已创建。' : '源站已更新。',
|
||||
});
|
||||
setEditingOrigin(origin);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface OriginItem {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
remark: string;
|
||||
route_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OriginRouteSummary {
|
||||
id: number;
|
||||
domain: string;
|
||||
origin_url: string;
|
||||
enabled: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OriginDetail extends OriginItem {
|
||||
routes: OriginRouteSummary[];
|
||||
}
|
||||
|
||||
export interface OriginMutationPayload {
|
||||
name: string;
|
||||
address: string;
|
||||
remark: string;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from '@/features/config-versions/api/config-versions';
|
||||
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
|
||||
import type { ManagedDomainItem } from '@/features/managed-domains/types';
|
||||
import { getOrigins } from '@/features/origins/api/origins';
|
||||
import type { OriginItem } from '@/features/origins/types';
|
||||
import {
|
||||
createProxyRoute,
|
||||
deleteProxyRoute,
|
||||
@@ -63,27 +65,17 @@ const cachePolicyValues = [
|
||||
'path_prefix',
|
||||
'path_exact',
|
||||
] as const;
|
||||
const originProtocolValues = ['http', 'https'] as const;
|
||||
|
||||
const proxyRouteSchema = z
|
||||
.object({
|
||||
managed_domain_id: z.string().trim().min(1, '请选择网站'),
|
||||
subdomain_label: z.string(),
|
||||
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;
|
||||
}
|
||||
}, '请输入合法的源站地址'),
|
||||
origin_id: z.string(),
|
||||
origin_scheme: z.enum(originProtocolValues),
|
||||
origin_address: z.string().trim().min(1, '请输入源站地址'),
|
||||
origin_port: z.string().trim().min(1, '请输入端口'),
|
||||
origin_uri: z.string(),
|
||||
origin_host: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -146,14 +138,70 @@ const proxyRouteSchema = z
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedOriginAddress = value.origin_address.trim();
|
||||
if (
|
||||
normalizedOriginAddress &&
|
||||
(/[/?#]/.test(normalizedOriginAddress) ||
|
||||
normalizedOriginAddress.includes('://'))
|
||||
) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_address'],
|
||||
message: '源站地址仅支持 IP、域名或主机名',
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedOriginPort = value.origin_port.trim();
|
||||
const portNumber = Number(normalizedOriginPort);
|
||||
if (
|
||||
normalizedOriginPort &&
|
||||
(!/^\d+$/.test(normalizedOriginPort) ||
|
||||
!Number.isInteger(portNumber) ||
|
||||
portNumber < 1 ||
|
||||
portNumber > 65535)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_port'],
|
||||
message: '端口需为 1 到 65535 的整数',
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedOriginURI = value.origin_uri.trim();
|
||||
if (
|
||||
normalizedOriginURI &&
|
||||
!normalizedOriginURI.startsWith('/') &&
|
||||
!normalizedOriginURI.startsWith('?')
|
||||
) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_uri'],
|
||||
message: '源站路径需以 / 或 ? 开头',
|
||||
});
|
||||
}
|
||||
|
||||
const primaryOriginURL = buildOriginUrl(
|
||||
value.origin_scheme,
|
||||
value.origin_address,
|
||||
value.origin_port,
|
||||
value.origin_uri,
|
||||
);
|
||||
if (!primaryOriginURL) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_address'],
|
||||
message: '请输入完整的源站信息',
|
||||
});
|
||||
}
|
||||
|
||||
const upstreams = parseUpstreamsText(
|
||||
value.origin_url,
|
||||
primaryOriginURL,
|
||||
value.upstreams_text,
|
||||
);
|
||||
if (upstreams.length === 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_url'],
|
||||
path: ['origin_address'],
|
||||
message: '至少需要一个上游地址',
|
||||
});
|
||||
}
|
||||
@@ -213,7 +261,11 @@ type FeedbackState = {
|
||||
const defaultValues: ProxyRouteFormValues = {
|
||||
managed_domain_id: '',
|
||||
subdomain_label: '',
|
||||
origin_url: '',
|
||||
origin_id: '',
|
||||
origin_scheme: 'https',
|
||||
origin_address: '',
|
||||
origin_port: '',
|
||||
origin_uri: '',
|
||||
origin_host: '',
|
||||
upstreams_text: '',
|
||||
enabled: true,
|
||||
@@ -230,6 +282,7 @@ const defaultValues: ProxyRouteFormValues = {
|
||||
const routesQueryKey = ['proxy-routes'];
|
||||
const certificatesQueryKey = ['tls-certificates'];
|
||||
const managedDomainsQueryKey = ['managed-domains'];
|
||||
const originsQueryKey = ['origins'];
|
||||
const versionsQueryKey = ['config-versions'];
|
||||
|
||||
function hasConfigChanges(diff: {
|
||||
@@ -338,13 +391,66 @@ function buildCertificateLabel(certificate: TlsCertificateItem) {
|
||||
: certificate.name;
|
||||
}
|
||||
|
||||
function buildOriginUrl(
|
||||
scheme: 'http' | 'https',
|
||||
address: string,
|
||||
port: string,
|
||||
uri: string,
|
||||
) {
|
||||
const normalizedAddress = address.trim();
|
||||
const normalizedPort = port.trim();
|
||||
if (!normalizedAddress || !normalizedPort) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const host =
|
||||
normalizedAddress.includes(':') && !normalizedAddress.startsWith('[')
|
||||
? `[${normalizedAddress}]`
|
||||
: normalizedAddress;
|
||||
const normalizedURI = uri.trim();
|
||||
|
||||
return `${scheme}://${host}:${normalizedPort}${normalizedURI}`;
|
||||
}
|
||||
|
||||
function parseOriginUrl(rawValue: string) {
|
||||
try {
|
||||
const parsed = new URL(rawValue);
|
||||
const uri = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||
return {
|
||||
scheme: (parsed.protocol.replace(':', '') || 'https') as 'http' | 'https',
|
||||
address: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'http:' ? '80' : '443'),
|
||||
uri: parsed.search ? `${uri}${parsed.search}` || parsed.search : uri,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
scheme: 'https' as const,
|
||||
address: '',
|
||||
port: '',
|
||||
uri: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload {
|
||||
const primaryOriginUrl = buildOriginUrl(
|
||||
values.origin_scheme,
|
||||
values.origin_address,
|
||||
values.origin_port,
|
||||
values.origin_uri,
|
||||
);
|
||||
|
||||
return {
|
||||
domain: buildRouteDomain(values.managed_domain_id, values.subdomain_label),
|
||||
origin_url: values.origin_url.trim(),
|
||||
origin_id: values.origin_id ? Number(values.origin_id) : null,
|
||||
origin_url: primaryOriginUrl,
|
||||
origin_scheme: values.origin_scheme,
|
||||
origin_address: values.origin_address.trim(),
|
||||
origin_port: values.origin_port.trim(),
|
||||
origin_uri: values.origin_uri.trim(),
|
||||
origin_host: values.origin_host.trim(),
|
||||
upstreams: parseUpstreamsText(
|
||||
values.origin_url,
|
||||
primaryOriginUrl,
|
||||
values.upstreams_text,
|
||||
).slice(1),
|
||||
enabled: values.enabled,
|
||||
@@ -382,10 +488,16 @@ function toFormValues(
|
||||
);
|
||||
}
|
||||
|
||||
const parsedOrigin = parseOriginUrl(route.origin_url);
|
||||
|
||||
return {
|
||||
managed_domain_id: managedDomainMatch.managedDomainId,
|
||||
subdomain_label: managedDomainMatch.subdomainLabel,
|
||||
origin_url: route.origin_url,
|
||||
origin_id: route.origin_id ? String(route.origin_id) : '',
|
||||
origin_scheme: parsedOrigin.scheme,
|
||||
origin_address: parsedOrigin.address,
|
||||
origin_port: parsedOrigin.port,
|
||||
origin_uri: parsedOrigin.uri,
|
||||
origin_host: route.origin_host || '',
|
||||
upstreams_text: upstreams.slice(1).join('\n'),
|
||||
enabled: route.enabled,
|
||||
@@ -453,6 +565,22 @@ export function ProxyRoutesPage() {
|
||||
control: form.control,
|
||||
name: 'subdomain_label',
|
||||
});
|
||||
const watchedOriginAddress = useWatch({
|
||||
control: form.control,
|
||||
name: 'origin_address',
|
||||
});
|
||||
const watchedOriginScheme = useWatch({
|
||||
control: form.control,
|
||||
name: 'origin_scheme',
|
||||
});
|
||||
const watchedOriginPort = useWatch({
|
||||
control: form.control,
|
||||
name: 'origin_port',
|
||||
});
|
||||
const watchedOriginURI = useWatch({
|
||||
control: form.control,
|
||||
name: 'origin_uri',
|
||||
});
|
||||
const watchedEnabled = useWatch({ control: form.control, name: 'enabled' });
|
||||
const watchedEnableHttps = useWatch({
|
||||
control: form.control,
|
||||
@@ -487,6 +615,11 @@ export function ProxyRoutesPage() {
|
||||
queryFn: getManagedDomains,
|
||||
});
|
||||
|
||||
const originsQuery = useQuery({
|
||||
queryKey: originsQueryKey,
|
||||
queryFn: getOrigins,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: ProxyRouteFormValues) => {
|
||||
const payload = toPayload(values);
|
||||
@@ -506,6 +639,7 @@ export function ProxyRoutesPage() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: routesQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: originsQueryKey }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -561,6 +695,17 @@ export function ProxyRoutesPage() {
|
||||
() => managedDomainsQuery.data ?? [],
|
||||
[managedDomainsQuery.data],
|
||||
);
|
||||
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]);
|
||||
|
||||
const matchedOrigin = useMemo(
|
||||
() =>
|
||||
origins.find(
|
||||
(item) =>
|
||||
item.address.toLowerCase() ===
|
||||
watchedOriginAddress.trim().toLowerCase(),
|
||||
) ?? null,
|
||||
[origins, watchedOriginAddress],
|
||||
);
|
||||
|
||||
const selectedManagedDomain = useMemo(
|
||||
() =>
|
||||
@@ -578,6 +723,19 @@ export function ProxyRoutesPage() {
|
||||
selectedManagedDomainValue,
|
||||
watchedSubdomainLabel,
|
||||
);
|
||||
const primaryOriginPreview = buildOriginUrl(
|
||||
watchedOriginScheme,
|
||||
watchedOriginAddress,
|
||||
watchedOriginPort,
|
||||
watchedOriginURI,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue('origin_id', matchedOrigin ? String(matchedOrigin.id) : '', {
|
||||
shouldDirty: true,
|
||||
shouldValidate: false,
|
||||
});
|
||||
}, [form, matchedOrigin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!watchedEnableHttps) {
|
||||
@@ -911,13 +1069,57 @@ export function ProxyRoutesPage() {
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="源站地址"
|
||||
hint="示例:https://origin.internal"
|
||||
error={form.formState.errors.origin_url?.message}
|
||||
label="源站协议"
|
||||
hint="先选择回源协议,再选择或输入源站地址。"
|
||||
>
|
||||
<ResourceSelect
|
||||
value={watchedOriginScheme}
|
||||
onChange={(event) =>
|
||||
form.setValue(
|
||||
'origin_scheme',
|
||||
event.target.value as ProxyRouteFormValues['origin_scheme'],
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="https">https</option>
|
||||
<option value="http">http</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1.2fr_0.8fr]">
|
||||
<ResourceField
|
||||
label="源站"
|
||||
hint="可从已有源站中选择,也可以直接输入新地址;保存规则后会自动创建不存在的源站。"
|
||||
error={form.formState.errors.origin_address?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="https://origin.internal"
|
||||
{...form.register('origin_url')}
|
||||
list="proxy-route-origin-addresses"
|
||||
placeholder="origin.internal"
|
||||
{...form.register('origin_address')}
|
||||
/>
|
||||
<datalist id="proxy-route-origin-addresses">
|
||||
{origins.map((origin) => (
|
||||
<option
|
||||
key={origin.id}
|
||||
value={origin.address}
|
||||
label={origin.name}
|
||||
/>
|
||||
))}
|
||||
</datalist>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="端口"
|
||||
hint="规则主源站的访问端口。"
|
||||
error={form.formState.errors.origin_port?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="443"
|
||||
{...form.register('origin_port')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
@@ -958,6 +1160,32 @@ export function ProxyRoutesPage() {
|
||||
)
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_1fr]">
|
||||
<ResourceField
|
||||
label="源站路径"
|
||||
hint="可选,用于保留已有带路径或查询参数的回源能力,例如 /api 或 ?token=demo。"
|
||||
error={form.formState.errors.origin_uri?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="/api"
|
||||
{...form.register('origin_uri')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<AppCard
|
||||
title="主源站预览"
|
||||
description={
|
||||
matchedOrigin
|
||||
? `当前会复用已存在的源站 ${matchedOrigin.name}。`
|
||||
: '如果这里的地址尚未收录,保存规则时会自动创建一个新源站。'
|
||||
}
|
||||
>
|
||||
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{primaryOriginPreview ||
|
||||
'选择协议、输入源站和端口后,这里会显示最终主源站地址。'}
|
||||
</p>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<ResourceField
|
||||
label="回源主机名"
|
||||
hint="可选。填写后将覆盖回源请求的 Host,留空则默认使用访问域名 $host"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export interface ProxyRouteCustomHeader {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProxyRouteCustomHeader {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProxyRouteItem {
|
||||
id: number;
|
||||
domain: string;
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_host: string;
|
||||
upstreams: string;
|
||||
@@ -21,10 +22,15 @@ export interface ProxyRouteItem {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProxyRouteMutationPayload {
|
||||
domain: string;
|
||||
|
||||
export interface ProxyRouteMutationPayload {
|
||||
domain: string;
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_scheme: 'http' | 'https';
|
||||
origin_address: string;
|
||||
origin_port: string;
|
||||
origin_uri: string;
|
||||
origin_host: string;
|
||||
upstreams: string[];
|
||||
enabled: boolean;
|
||||
@@ -37,24 +43,24 @@ export interface ProxyRouteMutationPayload {
|
||||
custom_headers: ProxyRouteCustomHeader[];
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface TlsCertificateItem {
|
||||
id: number;
|
||||
name: string;
|
||||
not_after?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchCandidate {
|
||||
managed_domain_id: number;
|
||||
domain: string;
|
||||
match_type: 'exact' | 'wildcard';
|
||||
certificate_id: number;
|
||||
certificate_name: string;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchResult {
|
||||
domain: string;
|
||||
matched: boolean;
|
||||
candidate?: ManagedDomainMatchCandidate;
|
||||
candidates: ManagedDomainMatchCandidate[];
|
||||
}
|
||||
|
||||
export interface TlsCertificateItem {
|
||||
id: number;
|
||||
name: string;
|
||||
not_after?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchCandidate {
|
||||
managed_domain_id: number;
|
||||
domain: string;
|
||||
match_type: 'exact' | 'wildcard';
|
||||
certificate_id: number;
|
||||
certificate_name: string;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchResult {
|
||||
domain: string;
|
||||
matched: boolean;
|
||||
candidate?: ManagedDomainMatchCandidate;
|
||||
candidates: ManagedDomainMatchCandidate[];
|
||||
}
|
||||
|
||||
@@ -1,51 +1,56 @@
|
||||
import type {NavigationItem} from '@/types/navigation';
|
||||
import type { NavigationItem } from '@/types/navigation';
|
||||
|
||||
export const dashboardNavigation: NavigationItem[] = [
|
||||
{
|
||||
href: '/',
|
||||
label: '总览',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
href: '/node',
|
||||
label: '节点',
|
||||
icon: 'node',
|
||||
},
|
||||
{
|
||||
href: '/website',
|
||||
label: '网站',
|
||||
icon: 'website',
|
||||
},
|
||||
{
|
||||
href: '/proxy-route',
|
||||
label: '规则',
|
||||
icon: 'proxy',
|
||||
},
|
||||
{
|
||||
href: '/config-version',
|
||||
label: '发布',
|
||||
icon: 'release',
|
||||
},
|
||||
{
|
||||
href: '/access-log',
|
||||
label: '日志',
|
||||
icon: 'log',
|
||||
},
|
||||
{
|
||||
href: '/',
|
||||
label: '总览',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
href: '/node',
|
||||
label: '节点',
|
||||
icon: 'node',
|
||||
},
|
||||
{
|
||||
href: '/website',
|
||||
label: '网站',
|
||||
icon: 'website',
|
||||
},
|
||||
{
|
||||
href: '/origin',
|
||||
label: '源站',
|
||||
icon: 'origin',
|
||||
},
|
||||
{
|
||||
href: '/proxy-route',
|
||||
label: '规则',
|
||||
icon: 'proxy',
|
||||
},
|
||||
{
|
||||
href: '/config-version',
|
||||
label: '发布',
|
||||
icon: 'release',
|
||||
},
|
||||
{
|
||||
href: '/access-log',
|
||||
label: '日志',
|
||||
icon: 'log',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/performance',
|
||||
label: '性能',
|
||||
icon: 'performance',
|
||||
},
|
||||
{
|
||||
href: '/user',
|
||||
label: '用户',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
href: '/performance',
|
||||
label: '性能',
|
||||
icon: 'performance',
|
||||
},
|
||||
{
|
||||
href: '/user',
|
||||
label: '用户',
|
||||
icon: 'user',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/setting',
|
||||
label: '设置',
|
||||
icon: 'setting',
|
||||
},
|
||||
{
|
||||
href: '/setting',
|
||||
label: '设置',
|
||||
icon: 'setting',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -87,6 +87,18 @@ describe('ProxyRoutesPage', () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/origins/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/tls-certificates/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
@@ -166,6 +178,18 @@ describe('ProxyRoutesPage', () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/origins/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/tls-certificates/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
|
||||
@@ -2,6 +2,7 @@ export type NavigationIconKey =
|
||||
| 'home'
|
||||
| 'node'
|
||||
| 'website'
|
||||
| 'origin'
|
||||
| 'domain'
|
||||
| 'certificate'
|
||||
| 'proxy'
|
||||
|
||||
Reference in New Issue
Block a user