[优化] go 引用调整

This commit is contained in:
ryan
2026-06-06 10:26:20 +08:00
parent ee1110b752
commit 3cfefb4367
552 changed files with 1642 additions and 2185 deletions
@@ -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>
);
}
@@ -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}&section=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}&section=domains`);
}}
/>
</>
);
}