mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 14:56:38 +08:00
[优化] go 引用调整
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
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, 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}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : 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,168 @@
|
||||
'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}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : 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;
|
||||
}
|
||||
Reference in New Issue
Block a user