[功能] 添加源站管理功能,包括源站的创建、更新、删除及列表展示

This commit is contained in:
ryan
2026-03-20 20:01:42 +08:00
parent edd31da527
commit afd891f0f6
26 changed files with 2046 additions and 105 deletions
@@ -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(
+1
View File
@@ -2,6 +2,7 @@ export type NavigationIconKey =
| 'home'
| 'node'
| 'website'
| 'origin'
| 'domain'
| 'certificate'
| 'proxy'