mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 06:36:38 +08:00
[优化] 格式化
This commit is contained in:
@@ -1,103 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import { getAboutContent } from '@/features/settings/api/settings';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
const aboutQuery = useQuery({
|
||||
queryKey: ['about'],
|
||||
queryFn: getAboutContent,
|
||||
});
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
if (aboutQuery.isLoading || statusQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (aboutQuery.isError) {
|
||||
return <ErrorState title='关于页加载失败' description={getErrorMessage(aboutQuery.error)} />;
|
||||
}
|
||||
|
||||
if (statusQuery.isError) {
|
||||
return <ErrorState title='系统状态加载失败' description={getErrorMessage(statusQuery.error)} />;
|
||||
}
|
||||
|
||||
const aboutContent = aboutQuery.data?.trim() ?? '';
|
||||
const status = statusQuery.data;
|
||||
|
||||
if (!status) {
|
||||
return <EmptyState title='暂无系统信息' description='未能获取 OpenFlare 的公开状态信息。' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<AppCard
|
||||
title='关于 OpenFlare'
|
||||
description='公开展示当前系统简介、版本信息与项目入口。'
|
||||
action={<StatusBadge label={status.version || 'dev'} variant='info' />}
|
||||
>
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>系统名称</p>
|
||||
<p className='mt-2 text-sm font-medium text-[var(--foreground-primary)]'>{status.system_name || 'OpenFlare'}</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>Server 启动时间</p>
|
||||
<p className='mt-2 text-sm font-medium text-[var(--foreground-primary)]'>
|
||||
{formatDateTime(new Date(status.start_time * 1000))}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>项目仓库</p>
|
||||
<a
|
||||
href='https://github.com/Rain-kl/OpenFlare'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80'
|
||||
>
|
||||
github.com/Rain-kl/OpenFlare
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
{aboutContent ? (
|
||||
<AppCard title='项目介绍' description='以下内容由系统设置中的“关于内容”维护。'>
|
||||
<div
|
||||
className='prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]'
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(aboutContent) as string }}
|
||||
/>
|
||||
</AppCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
title='尚未配置关于内容'
|
||||
description='可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。'
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='flex flex-wrap gap-3 text-sm'>
|
||||
<Link href='/login' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||
前往登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import { getAboutContent } from '@/features/settings/api/settings';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
const aboutQuery = useQuery({
|
||||
queryKey: ['about'],
|
||||
queryFn: getAboutContent,
|
||||
});
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
if (aboutQuery.isLoading || statusQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (aboutQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="关于页加载失败"
|
||||
description={getErrorMessage(aboutQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="系统状态加载失败"
|
||||
description={getErrorMessage(statusQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const aboutContent = aboutQuery.data?.trim() ?? '';
|
||||
const status = statusQuery.data;
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无系统信息"
|
||||
description="未能获取 OpenFlare 的公开状态信息。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AppCard
|
||||
title="关于 OpenFlare"
|
||||
description="公开展示当前系统简介、版本信息与项目入口。"
|
||||
action={<StatusBadge label={status.version || 'dev'} variant="info" />}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
系统名称
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{status.system_name || 'OpenFlare'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
Server 启动时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{formatDateTime(new Date(status.start_time * 1000))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
项目仓库
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/Rain-kl/OpenFlare"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
github.com/Rain-kl/OpenFlare
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
{aboutContent ? (
|
||||
<AppCard
|
||||
title="项目介绍"
|
||||
description="以下内容由系统设置中的“关于内容”维护。"
|
||||
>
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(aboutContent) as string,
|
||||
}}
|
||||
/>
|
||||
</AppCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="尚未配置关于内容"
|
||||
description="可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
前往登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,11 +29,8 @@ import {
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
|
||||
const applyLogsQueryKey = (
|
||||
nodeId: string,
|
||||
pageNo: number,
|
||||
pageSize: number,
|
||||
) => ['apply-logs', nodeId, pageNo, pageSize] as const;
|
||||
const applyLogsQueryKey = (nodeId: string, pageNo: number, pageSize: number) =>
|
||||
['apply-logs', nodeId, pageNo, pageSize] as const;
|
||||
|
||||
const pageSizeOptions = [20, 50, 100];
|
||||
const emptyApplyLogRows: ApplyLogItem[] = [];
|
||||
@@ -64,7 +61,12 @@ function truncateHash(value: string) {
|
||||
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
|
||||
}
|
||||
|
||||
function buildSummary(rows: ApplyLogItem[], total: number, current: number, totalPage: number) {
|
||||
function buildSummary(
|
||||
rows: ApplyLogItem[],
|
||||
total: number,
|
||||
current: number,
|
||||
totalPage: number,
|
||||
) {
|
||||
const nodeIds = new Set(rows.map((item) => item.node_id));
|
||||
return [
|
||||
{ label: '总记录数', value: total },
|
||||
@@ -209,14 +211,17 @@ export function ApplyLogsPage() {
|
||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||
) : null}
|
||||
|
||||
<AppCard title="日志摘要" description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。">
|
||||
<AppCard
|
||||
title="日志摘要"
|
||||
description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||
>
|
||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
@@ -348,13 +353,16 @@ export function ApplyLogsPage() {
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
|
||||
第 {current} / {Math.max(totalPage, 1)} 页,共 {total}{' '}
|
||||
条记录。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={current <= 1}
|
||||
onClick={() => setPageNo((previous) => Math.max(1, previous - 1))}
|
||||
onClick={() =>
|
||||
setPageNo((previous) => Math.max(1, previous - 1))
|
||||
}
|
||||
>
|
||||
上一页
|
||||
</SecondaryButton>
|
||||
@@ -363,7 +371,9 @@ export function ApplyLogsPage() {
|
||||
disabled={totalPage === 0 || current >= totalPage}
|
||||
onClick={() =>
|
||||
setPageNo((previous) =>
|
||||
totalPage > 0 ? Math.min(totalPage, previous + 1) : previous,
|
||||
totalPage > 0
|
||||
? Math.min(totalPage, previous + 1)
|
||||
: previous,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -387,14 +397,30 @@ export function ApplyLogsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge {...getResultMeta(selectedLog.result)} />
|
||||
<StatusBadge label={`Node:${selectedLog.node_id}`} variant="info" />
|
||||
<StatusBadge label={`版本:${selectedLog.version}`} variant="warning" />
|
||||
<StatusBadge
|
||||
label={`Node:${selectedLog.node_id}`}
|
||||
variant="info"
|
||||
/>
|
||||
<StatusBadge
|
||||
label={`版本:${selectedLog.version}`}
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<MetricCard label="创建时间" value={formatDateTime(selectedLog.created_at)} />
|
||||
<MetricCard label="相对时间" value={formatRelativeTime(selectedLog.created_at)} />
|
||||
<MetricCard label="目标 Checksum" value={selectedLog.checksum || '—'} breakAll />
|
||||
<MetricCard
|
||||
label="创建时间"
|
||||
value={formatDateTime(selectedLog.created_at)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="相对时间"
|
||||
value={formatRelativeTime(selectedLog.created_at)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="目标 Checksum"
|
||||
value={selectedLog.checksum || '—'}
|
||||
breakAll
|
||||
/>
|
||||
<MetricCard
|
||||
label="支持文件数"
|
||||
value={String(selectedLog.support_file_count)}
|
||||
@@ -412,10 +438,10 @@ export function ApplyLogsPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
消息
|
||||
</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--foreground-primary)]">
|
||||
<pre className="mt-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]">
|
||||
{selectedLog.message || '—'}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -430,7 +456,10 @@ export function ApplyLogsPage() {
|
||||
onClose={() => setCleanupModalOpen(false)}
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<SecondaryButton type="button" onClick={() => setCleanupModalOpen(false)}>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setCleanupModalOpen(false)}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
@@ -505,7 +534,7 @@ function MetricCard({
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import type { PublicStatus } from '@/types/public-status';
|
||||
|
||||
export function getPublicStatus() {
|
||||
return apiRequest<PublicStatus>('/status');
|
||||
}
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import type { PublicStatus } from '@/types/public-status';
|
||||
|
||||
export function getPublicStatus() {
|
||||
return apiRequest<PublicStatus>('/status');
|
||||
}
|
||||
|
||||
@@ -1,63 +1,73 @@
|
||||
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export function AuthFormField({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className='block space-y-2'>
|
||||
<span className='text-sm font-medium text-[var(--foreground-primary)]'>{label}</span>
|
||||
{children}
|
||||
{hint ? <span className='block text-xs text-[var(--foreground-secondary)]'>{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex w-full items-center justify-center rounded-2xl bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-[var(--foreground-inverse)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecondaryButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'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)] disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export function AuthFormField({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
{hint ? (
|
||||
<span className="block text-xs text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex w-full items-center justify-center rounded-2xl bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-[var(--foreground-inverse)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecondaryButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'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)] disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
|
||||
interface DashboardAuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
const redirect = pathname && pathname !== '/' ? `?redirect=${encodeURIComponent(pathname)}` : '';
|
||||
router.replace(`/login${redirect}`);
|
||||
}
|
||||
}, [isAuthenticated, isLoading, pathname, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated) {
|
||||
return (
|
||||
<main className='flex min-h-screen items-center justify-center px-4 py-8'>
|
||||
<LoadingState className='w-full max-w-md' />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
|
||||
interface DashboardAuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
const redirect =
|
||||
pathname && pathname !== '/'
|
||||
? `?redirect=${encodeURIComponent(pathname)}`
|
||||
: '';
|
||||
router.replace(`/login${redirect}`);
|
||||
}
|
||||
}, [isAuthenticated, isLoading, pathname, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<LoadingState className="w-full max-w-md" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -1,44 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { exchangeGitHubCode } from '@/features/auth/api/auth';
|
||||
|
||||
export function GitHubOAuthCallback() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { exchangeGitHubCode } from '@/features/auth/api/auth';
|
||||
|
||||
export function GitHubOAuthCallback() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const handledCodeRef = useRef<string | null>(null);
|
||||
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
|
||||
const [message, setMessage] = useState<{ tone: 'danger' | 'success'; text: string } | null>(null);
|
||||
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'danger' | 'success';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const code = searchParams?.get('code')?.trim() ?? '';
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: exchangeGitHubCode,
|
||||
onSuccess: (user) => {
|
||||
setUser(user);
|
||||
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
||||
router.replace('/');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({ tone: 'danger', text: error.message || 'GitHub 授权失败,请稍后重试。' });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: exchangeGitHubCode,
|
||||
onSuccess: (user) => {
|
||||
setUser(user);
|
||||
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
||||
router.replace('/');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || 'GitHub 授权失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
setPrompt('缺少授权 code');
|
||||
setMessage({ tone: 'danger', text: '未收到 GitHub 授权参数,请返回登录页重试。' });
|
||||
return;
|
||||
}
|
||||
|
||||
setPrompt('缺少授权 code');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到 GitHub 授权参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledCodeRef.current === code) {
|
||||
return;
|
||||
}
|
||||
@@ -46,13 +55,15 @@ export function GitHubOAuthCallback() {
|
||||
handledCodeRef.current = code;
|
||||
mutation.mutate(code);
|
||||
}, [code, mutation]);
|
||||
|
||||
return (
|
||||
<AppCard title='GitHub OAuth 回调' description={prompt}>
|
||||
<div className='space-y-4'>
|
||||
{mutation.isPending ? <LoadingState /> : null}
|
||||
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppCard title="GitHub OAuth 回调" description={prompt}>
|
||||
<div className="space-y-4">
|
||||
{mutation.isPending ? <LoadingState /> : null}
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { resetPassword } from '@/features/auth/api/auth';
|
||||
import {
|
||||
AuthButton,
|
||||
AuthFormField,
|
||||
AuthInput,
|
||||
SecondaryButton,
|
||||
} from '@/features/auth/components/auth-form-primitives';
|
||||
|
||||
export function PasswordResetConfirmForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams?.get('email') || '';
|
||||
const token = searchParams?.get('token') || '';
|
||||
const [message, setMessage] = useState<{ tone: 'success' | 'danger'; text: string } | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => resetPassword({ email, token }),
|
||||
onSuccess: async (password) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
setMessage({ tone: 'success', text: `密码已重置,新密码已复制到剪贴板:${password}` });
|
||||
} catch {
|
||||
setMessage({ tone: 'success', text: `密码已重置:${password}` });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({ tone: 'danger', text: error.message || '密码重置失败,请重新获取链接。' });
|
||||
},
|
||||
});
|
||||
|
||||
const missingParams = !email || !token;
|
||||
|
||||
return (
|
||||
<AppCard title='密码重置确认' description='确认后,系统会生成新的随机密码。'>
|
||||
<div className='space-y-4'>
|
||||
<AuthFormField label='邮箱地址'>
|
||||
<AuthInput value={email} readOnly />
|
||||
</AuthFormField>
|
||||
|
||||
{missingParams ? (
|
||||
<InlineMessage tone='danger' message='重置链接缺少必要参数,请重新发起密码重置。' />
|
||||
) : null}
|
||||
|
||||
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
|
||||
|
||||
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||
<AuthButton type='button' disabled={missingParams || mutation.isPending} onClick={() => mutation.mutate()}>
|
||||
{mutation.isPending ? '处理中...' : '确认重置密码'}
|
||||
</AuthButton>
|
||||
{message?.tone === 'success' ? (
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={async () => {
|
||||
const password = message.text.split(':').pop() || '';
|
||||
if (password) {
|
||||
await navigator.clipboard.writeText(password);
|
||||
}
|
||||
}}
|
||||
>
|
||||
再次复制密码
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='text-sm text-[var(--foreground-secondary)]'>
|
||||
处理完成后可返回
|
||||
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||
登录页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { resetPassword } from '@/features/auth/api/auth';
|
||||
import {
|
||||
AuthButton,
|
||||
AuthFormField,
|
||||
AuthInput,
|
||||
SecondaryButton,
|
||||
} from '@/features/auth/components/auth-form-primitives';
|
||||
|
||||
export function PasswordResetConfirmForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams?.get('email') || '';
|
||||
const token = searchParams?.get('token') || '';
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => resetPassword({ email, token }),
|
||||
onSuccess: async (password) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
setMessage({
|
||||
tone: 'success',
|
||||
text: `密码已重置,新密码已复制到剪贴板:${password}`,
|
||||
});
|
||||
} catch {
|
||||
setMessage({ tone: 'success', text: `密码已重置:${password}` });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '密码重置失败,请重新获取链接。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const missingParams = !email || !token;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="密码重置确认"
|
||||
description="确认后,系统会生成新的随机密码。"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<AuthFormField label="邮箱地址">
|
||||
<AuthInput value={email} readOnly />
|
||||
</AuthFormField>
|
||||
|
||||
{missingParams ? (
|
||||
<InlineMessage
|
||||
tone="danger"
|
||||
message="重置链接缺少必要参数,请重新发起密码重置。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<AuthButton
|
||||
type="button"
|
||||
disabled={missingParams || mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
{mutation.isPending ? '处理中...' : '确认重置密码'}
|
||||
</AuthButton>
|
||||
{message?.tone === 'success' ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const password = message.text.split(':').pop() || '';
|
||||
if (password) {
|
||||
await navigator.clipboard.writeText(password);
|
||||
}
|
||||
}}
|
||||
>
|
||||
再次复制密码
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-[var(--foreground-secondary)]">
|
||||
处理完成后可返回
|
||||
<Link
|
||||
href="/login"
|
||||
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
登录页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { PasswordResetConfirmForm } from '@/features/auth/components/password-reset-confirm-form';
|
||||
import { PasswordResetRequestForm } from '@/features/auth/components/password-reset-request-form';
|
||||
|
||||
export function PasswordResetFlow() {
|
||||
const searchParams = useSearchParams();
|
||||
const hasConfirmationParams = Boolean(searchParams?.get('email') && searchParams?.get('token'));
|
||||
|
||||
return hasConfirmationParams ? <PasswordResetConfirmForm /> : <PasswordResetRequestForm />;
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { PasswordResetConfirmForm } from '@/features/auth/components/password-reset-confirm-form';
|
||||
import { PasswordResetRequestForm } from '@/features/auth/components/password-reset-request-form';
|
||||
|
||||
export function PasswordResetFlow() {
|
||||
const searchParams = useSearchParams();
|
||||
const hasConfirmationParams = Boolean(
|
||||
searchParams?.get('email') && searchParams?.get('token'),
|
||||
);
|
||||
|
||||
return hasConfirmationParams ? (
|
||||
<PasswordResetConfirmForm />
|
||||
) : (
|
||||
<PasswordResetRequestForm />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { sendPasswordResetEmail } from '@/features/auth/api/auth';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import {
|
||||
AuthButton,
|
||||
AuthFormField,
|
||||
AuthInput,
|
||||
} from '@/features/auth/components/auth-form-primitives';
|
||||
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
|
||||
|
||||
const resetRequestSchema = z.object({
|
||||
email: z.string().email('请输入有效邮箱地址'),
|
||||
});
|
||||
|
||||
type ResetRequestFormValues = z.infer<typeof resetRequestSchema>;
|
||||
|
||||
export function PasswordResetRequestForm() {
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [message, setMessage] = useState<{ tone: 'success' | 'danger' | 'info'; text: string } | null>(null);
|
||||
|
||||
const form = useForm<ResetRequestFormValues>({
|
||||
resolver: zodResolver(resetRequestSchema),
|
||||
defaultValues: { email: '' },
|
||||
});
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: ResetRequestFormValues) =>
|
||||
sendPasswordResetEmail(values.email, turnstileToken || undefined),
|
||||
onSuccess: () => {
|
||||
setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' });
|
||||
form.reset();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({ tone: 'danger', text: error.message || '重置邮件发送失败,请稍后重试。' });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setMessage(null);
|
||||
if (statusQuery.data?.turnstile_check && !turnstileToken) {
|
||||
setMessage({ tone: 'info', text: '请先完成人机验证。' });
|
||||
return;
|
||||
}
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard title='密码重置' description='提交后,系统会向你的注册邮箱发送重置链接。'>
|
||||
<form className='space-y-4' onSubmit={handleSubmit}>
|
||||
<AuthFormField label='邮箱地址'>
|
||||
<AuthInput type='email' placeholder='请输入邮箱地址' {...form.register('email')} />
|
||||
{form.formState.errors.email ? (
|
||||
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||
{form.formState.errors.email.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{statusQuery.data?.turnstile_check && statusQuery.data.turnstile_site_key ? (
|
||||
<TurnstileWidget
|
||||
siteKey={statusQuery.data.turnstile_site_key}
|
||||
onVerify={(token) => setTurnstileToken(token)}
|
||||
onExpire={() => setTurnstileToken('')}
|
||||
onError={() => setTurnstileToken('')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
|
||||
|
||||
<AuthButton type='submit' disabled={mutation.isPending}>
|
||||
{mutation.isPending ? '提交中...' : '发送重置邮件'}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'>
|
||||
想起密码了?
|
||||
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
</PublicAuthGuard>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { sendPasswordResetEmail } from '@/features/auth/api/auth';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import {
|
||||
AuthButton,
|
||||
AuthFormField,
|
||||
AuthInput,
|
||||
} from '@/features/auth/components/auth-form-primitives';
|
||||
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
|
||||
|
||||
const resetRequestSchema = z.object({
|
||||
email: z.string().email('请输入有效邮箱地址'),
|
||||
});
|
||||
|
||||
type ResetRequestFormValues = z.infer<typeof resetRequestSchema>;
|
||||
|
||||
export function PasswordResetRequestForm() {
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const form = useForm<ResetRequestFormValues>({
|
||||
resolver: zodResolver(resetRequestSchema),
|
||||
defaultValues: { email: '' },
|
||||
});
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: ResetRequestFormValues) =>
|
||||
sendPasswordResetEmail(values.email, turnstileToken || undefined),
|
||||
onSuccess: () => {
|
||||
setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' });
|
||||
form.reset();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '重置邮件发送失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setMessage(null);
|
||||
if (statusQuery.data?.turnstile_check && !turnstileToken) {
|
||||
setMessage({ tone: 'info', text: '请先完成人机验证。' });
|
||||
return;
|
||||
}
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard
|
||||
title="密码重置"
|
||||
description="提交后,系统会向你的注册邮箱发送重置链接。"
|
||||
>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label="邮箱地址">
|
||||
<AuthInput
|
||||
type="email"
|
||||
placeholder="请输入邮箱地址"
|
||||
{...form.register('email')}
|
||||
/>
|
||||
{form.formState.errors.email ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.email.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{statusQuery.data?.turnstile_check &&
|
||||
statusQuery.data.turnstile_site_key ? (
|
||||
<TurnstileWidget
|
||||
siteKey={statusQuery.data.turnstile_site_key}
|
||||
onVerify={(token) => setTurnstileToken(token)}
|
||||
onExpire={() => setTurnstileToken('')}
|
||||
onError={() => setTurnstileToken('')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
|
||||
<AuthButton type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? '提交中...' : '发送重置邮件'}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-sm text-[var(--foreground-secondary)]">
|
||||
想起密码了?
|
||||
<Link
|
||||
href="/login"
|
||||
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
</PublicAuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
|
||||
interface PublicAuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PublicAuthGuard({ children }: PublicAuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
const redirect = searchParams?.get('redirect');
|
||||
router.replace(redirect || '/');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router, searchParams]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
|
||||
interface PublicAuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PublicAuthGuard({ children }: PublicAuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
const redirect = searchParams?.get('redirect');
|
||||
router.replace(redirect || '/');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router, searchParams]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
|
||||
|
||||
interface PublicRoutePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function PublicRoutePlaceholder({
|
||||
title,
|
||||
description,
|
||||
}: PublicRoutePlaceholderProps) {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title={title}
|
||||
description={description}
|
||||
milestones={[
|
||||
'认证页路由入口已建立,便于阶段 2 接入真实表单。',
|
||||
'公共布局已统一,可复用品牌区、说明区与回跳入口。',
|
||||
'后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。',
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
|
||||
|
||||
interface PublicRoutePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function PublicRoutePlaceholder({
|
||||
title,
|
||||
description,
|
||||
}: PublicRoutePlaceholderProps) {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title={title}
|
||||
description={description}
|
||||
milestones={[
|
||||
'认证页路由入口已建立,便于阶段 2 接入真实表单。',
|
||||
'公共布局已统一,可复用品牌区、说明区与回跳入口。',
|
||||
'后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。',
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,15 +18,15 @@ export function getConfigVersion(id: number) {
|
||||
export function getActiveConfigVersion() {
|
||||
return apiRequest<ConfigVersionDetail>('/config-versions/active');
|
||||
}
|
||||
|
||||
export function getConfigVersionPreview() {
|
||||
return apiRequest<ConfigPreviewResult>('/config-versions/preview');
|
||||
}
|
||||
|
||||
export function getConfigVersionDiff() {
|
||||
return apiRequest<ConfigDiffResult>('/config-versions/diff');
|
||||
}
|
||||
|
||||
|
||||
export function getConfigVersionPreview() {
|
||||
return apiRequest<ConfigPreviewResult>('/config-versions/preview');
|
||||
}
|
||||
|
||||
export function getConfigVersionDiff() {
|
||||
return apiRequest<ConfigDiffResult>('/config-versions/diff');
|
||||
}
|
||||
|
||||
export function publishConfigVersion(force?: boolean) {
|
||||
const query = force ? '?force=true' : '';
|
||||
return apiRequest<ConfigVersionDetail>(`/config-versions/publish${query}`, {
|
||||
|
||||
+6
-1
@@ -45,7 +45,12 @@ export function CleanupConfigVersionsModal({
|
||||
<SecondaryButton type="button" onClick={onClose} disabled={isPending}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="submit" form="cleanup-form" disabled={isPending} className="bg-[var(--status-danger-foreground)] hover:bg-[var(--status-danger-foreground)] hover:opacity-90 border-[var(--status-danger-foreground)]">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="cleanup-form"
|
||||
disabled={isPending}
|
||||
className="border-[var(--status-danger-foreground)] bg-[var(--status-danger-foreground)] hover:bg-[var(--status-danger-foreground)] hover:opacity-90"
|
||||
>
|
||||
{isPending ? '清理中...' : '确认清理'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
@@ -285,7 +285,10 @@ function PublishPreviewCard({
|
||||
{isActiveVersionDetailLoading ? (
|
||||
<LoadingState />
|
||||
) : activeVersionDetailError ? (
|
||||
<InlineMessage tone="danger" message={activeVersionDetailError} />
|
||||
<InlineMessage
|
||||
tone="danger"
|
||||
message={activeVersionDetailError}
|
||||
/>
|
||||
) : activeVersionDetail ? (
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{activeVersionDetail.main_config}
|
||||
@@ -520,7 +523,11 @@ export function ConfigVersionsPage() {
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm('确认强制重新发布吗?这会忽略配置变化检查并立即生成一个新版本。')) {
|
||||
if (
|
||||
window.confirm(
|
||||
'确认强制重新发布吗?这会忽略配置变化检查并立即生成一个新版本。',
|
||||
)
|
||||
) {
|
||||
publishMutation.mutate(true);
|
||||
}
|
||||
}}
|
||||
@@ -672,7 +679,9 @@ export function ConfigVersionsPage() {
|
||||
<CleanupConfigVersionsModal
|
||||
isOpen={isCleanupModalOpen}
|
||||
onClose={() => setIsCleanupModalOpen(false)}
|
||||
onConfirm={(keepCount) => cleanupMutation.mutate({ keep_count: keepCount })}
|
||||
onConfirm={(keepCount) =>
|
||||
cleanupMutation.mutate({ keep_count: keepCount })
|
||||
}
|
||||
isPending={cleanupMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -27,7 +27,9 @@ function isCompactDistributionItem(
|
||||
}
|
||||
|
||||
function isCompactTrafficTrendPoint(
|
||||
value: DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint,
|
||||
value:
|
||||
| DashboardOverview['trends']['traffic_24h'][number]
|
||||
| CompactTrafficTrendPoint,
|
||||
): value is CompactTrafficTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
@@ -74,7 +76,10 @@ function normalizeDistributionItems(
|
||||
|
||||
function normalizeTrafficTrendPoints(
|
||||
items:
|
||||
| Array<DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint>
|
||||
| Array<
|
||||
| DashboardOverview['trends']['traffic_24h'][number]
|
||||
| CompactTrafficTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
@@ -114,7 +119,8 @@ function normalizeCapacityTrendPoints(
|
||||
function normalizeNetworkTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
DashboardOverview['trends']['network_24h'][number] | CompactNetworkTrendPoint
|
||||
| DashboardOverview['trends']['network_24h'][number]
|
||||
| CompactNetworkTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
@@ -136,7 +142,8 @@ function normalizeNetworkTrendPoints(
|
||||
function normalizeDiskIOTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
DashboardOverview['trends']['disk_io_24h'][number] | CompactDiskIOTrendPoint
|
||||
| DashboardOverview['trends']['disk_io_24h'][number]
|
||||
| CompactDiskIOTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
@@ -154,7 +161,10 @@ function normalizeDiskIOTrendPoints(
|
||||
}
|
||||
|
||||
function normalizeDashboardNodes(
|
||||
items: Array<DashboardNodeHealth | CompactDashboardNodeHealth> | null | undefined,
|
||||
items:
|
||||
| Array<DashboardNodeHealth | CompactDashboardNodeHealth>
|
||||
| null
|
||||
| undefined,
|
||||
): DashboardNodeHealth[] {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDashboardNode(item)
|
||||
@@ -185,11 +195,7 @@ function normalizeDashboardNodes(
|
||||
}
|
||||
|
||||
function normalizeDashboardOverview(
|
||||
overview:
|
||||
| DashboardOverview
|
||||
| DashboardOverviewCompact
|
||||
| null
|
||||
| undefined,
|
||||
overview: DashboardOverview | DashboardOverviewCompact | null | undefined,
|
||||
): DashboardOverview | null {
|
||||
if (!overview) {
|
||||
return null;
|
||||
@@ -209,8 +215,12 @@ function normalizeDashboardOverview(
|
||||
source_countries: normalizeDistributionItems(
|
||||
overview.distributions?.source_countries,
|
||||
),
|
||||
status_codes: normalizeDistributionItems(overview.distributions?.status_codes),
|
||||
top_domains: normalizeDistributionItems(overview.distributions?.top_domains),
|
||||
status_codes: normalizeDistributionItems(
|
||||
overview.distributions?.status_codes,
|
||||
),
|
||||
top_domains: normalizeDistributionItems(
|
||||
overview.distributions?.top_domains,
|
||||
),
|
||||
},
|
||||
trends: {
|
||||
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
|
||||
@@ -222,8 +232,8 @@ function normalizeDashboardOverview(
|
||||
}
|
||||
|
||||
export async function getDashboardOverview() {
|
||||
const overview = await apiRequest<DashboardOverview | DashboardOverviewCompact>(
|
||||
'/dashboard/overview',
|
||||
);
|
||||
const overview = await apiRequest<
|
||||
DashboardOverview | DashboardOverviewCompact
|
||||
>('/dashboard/overview');
|
||||
return normalizeDashboardOverview(overview);
|
||||
}
|
||||
|
||||
@@ -137,8 +137,8 @@ function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? ' WS 已连接'
|
||||
: node.last_seen_at
|
||||
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: ' 暂无'}
|
||||
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: ' 暂无'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -249,9 +249,7 @@ export function DashboardOverview() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<AppCard
|
||||
title="24 小时网络与磁盘趋势"
|
||||
>
|
||||
<AppCard title="24 小时网络与磁盘趋势">
|
||||
<div className="space-y-6">
|
||||
<TrendChart
|
||||
labels={overview.trends.network_24h.map((point) =>
|
||||
@@ -311,9 +309,7 @@ export function DashboardOverview() {
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="Top 节点榜单"
|
||||
>
|
||||
<AppCard title="Top 节点榜单">
|
||||
<div className="grid gap-6 xl:grid-cols-1">
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
@@ -352,10 +348,7 @@ export function DashboardOverview() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<AppCard
|
||||
title="来源分布"
|
||||
description="聚合最近 24 小时主要来源国家。"
|
||||
>
|
||||
<AppCard title="来源分布" description="聚合最近 24 小时主要来源国家。">
|
||||
<RankChart
|
||||
items={overview.distributions.source_countries.map((item) => ({
|
||||
label: item.key,
|
||||
@@ -380,10 +373,7 @@ export function DashboardOverview() {
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="Top Domain"
|
||||
description="观察主要流量集中在哪些域名。"
|
||||
>
|
||||
<AppCard title="Top Domain" description="观察主要流量集中在哪些域名。">
|
||||
<RankChart
|
||||
items={overview.distributions.top_domains.map((item) => ({
|
||||
label: item.key,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,10 @@ export function createDnsAccount(payload: DnsAccountMutationPayload) {
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDnsAccount(id: number, payload: DnsAccountMutationPayload) {
|
||||
export function updateDnsAccount(
|
||||
id: number,
|
||||
payload: DnsAccountMutationPayload,
|
||||
) {
|
||||
return apiRequest<DnsAccountItem>(`/dns-accounts/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
|
||||
@@ -30,7 +30,10 @@ import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
export function DnsAccountsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{ tone: 'info' | 'success' | 'danger'; message: string } | null>(null);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
const dnsAccountsQuery = useQuery({
|
||||
@@ -57,7 +60,10 @@ export function DnsAccountsPage() {
|
||||
deleteMutation.mutate(account.id);
|
||||
};
|
||||
|
||||
const accounts = useMemo(() => dnsAccountsQuery.data ?? [], [dnsAccountsQuery.data]);
|
||||
const accounts = useMemo(
|
||||
() => dnsAccountsQuery.data ?? [],
|
||||
[dnsAccountsQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -73,30 +79,47 @@ export function DnsAccountsPage() {
|
||||
>
|
||||
返回
|
||||
</Link>
|
||||
<PrimaryButton type="button" onClick={() => setIsCreateOpen(true)}>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
>
|
||||
添加账号
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
{feedback ? (
|
||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||
) : null}
|
||||
|
||||
<AppCard title="DNS 账号列表">
|
||||
{dnsAccountsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : dnsAccountsQuery.isError ? (
|
||||
<ErrorState title="加载失败" description={getErrorMessage(dnsAccountsQuery.error)} />
|
||||
<ErrorState
|
||||
title="加载失败"
|
||||
description={getErrorMessage(dnsAccountsQuery.error)}
|
||||
/>
|
||||
) : accounts.length === 0 ? (
|
||||
<EmptyState title="暂无 DNS 账号" description="点击右上角“添加账号”开始录入。" />
|
||||
<EmptyState
|
||||
title="暂无 DNS 账号"
|
||||
description="点击右上角“添加账号”开始录入。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<div key={account.id} className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<div
|
||||
key={account.id}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{account.name} <span className="ml-2 text-xs font-normal text-[var(--foreground-secondary)]">({account.type})</span>
|
||||
{account.name}{' '}
|
||||
<span className="ml-2 text-xs font-normal text-[var(--foreground-secondary)]">
|
||||
({account.type})
|
||||
</span>
|
||||
</p>
|
||||
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
<p>创建于:{formatDateTime(account.created_at)}</p>
|
||||
@@ -119,19 +142,31 @@ export function DnsAccountsPage() {
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
|
||||
{isCreateOpen && (
|
||||
<DnsAccountCreateModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} onCreated={() => {
|
||||
setFeedback({ tone: 'success', message: 'DNS 账号已添加。' });
|
||||
setIsCreateOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
|
||||
}} />
|
||||
<DnsAccountCreateModal
|
||||
isOpen={isCreateOpen}
|
||||
onClose={() => setIsCreateOpen(false)}
|
||||
onCreated={() => {
|
||||
setFeedback({ tone: 'success', message: 'DNS 账号已添加。' });
|
||||
setIsCreateOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean; onClose: () => void; onCreated: () => void }) {
|
||||
function DnsAccountCreateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [error, setError] = useState('');
|
||||
const { register, handleSubmit, formState } = useForm({
|
||||
defaultValues: { name: '', type: 'cloudflare', authorization: '' },
|
||||
@@ -157,8 +192,14 @@ function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean
|
||||
<AppModal isOpen={isOpen} onClose={onClose} title="添加 DNS 账号">
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
{error && <InlineMessage tone="danger" message={error} />}
|
||||
<ResourceField label="账号名称" error={formState.errors.name?.message as string}>
|
||||
<ResourceInput placeholder="Cloudlfare 邮箱账号" {...register('name', { required: '请输入名称' })} />
|
||||
<ResourceField
|
||||
label="账号名称"
|
||||
error={formState.errors.name?.message as string}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="Cloudlfare 邮箱账号"
|
||||
{...register('name', { required: '请输入名称' })}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="DNS 服务商">
|
||||
<ResourceSelect {...register('type')}>
|
||||
@@ -166,7 +207,9 @@ function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField label="API Token" hint="请勿使用 Global API Key">
|
||||
<ResourceInput {...register('authorization', { required: '请输入 Token' })} />
|
||||
<ResourceInput
|
||||
{...register('authorization', { required: '请输入 Token' })}
|
||||
/>
|
||||
</ResourceField>
|
||||
<PrimaryButton type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? '提交中...' : '提交'}
|
||||
|
||||
@@ -17,7 +17,10 @@ export function createManagedDomain(payload: ManagedDomainMutationPayload) {
|
||||
});
|
||||
}
|
||||
|
||||
export function updateManagedDomain(id: number, payload: ManagedDomainMutationPayload) {
|
||||
export function updateManagedDomain(
|
||||
id: number,
|
||||
payload: ManagedDomainMutationPayload,
|
||||
) {
|
||||
return apiRequest<ManagedDomainItem>(`/managed-domains/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
|
||||
@@ -64,9 +64,9 @@ export function requestNodeAgentUpdate(
|
||||
}
|
||||
|
||||
export function requestNodeForceSync(id: number) {
|
||||
return apiRequest<NodeItem>(`/nodes/${id}/force-sync`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return apiRequest<NodeItem>(`/nodes/${id}/force-sync`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function requestNodeOpenrestyRestart(id: number) {
|
||||
|
||||
@@ -404,7 +404,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
});
|
||||
setHealthEventCleanupModalOpen(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['node-observability', nodeId] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['node-observability', nodeId],
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard', 'overview'] }),
|
||||
]);
|
||||
},
|
||||
@@ -869,9 +871,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="实时资源"
|
||||
>
|
||||
<AppCard title="实时资源">
|
||||
{observabilityQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : observabilityQuery.isError ? (
|
||||
@@ -930,9 +930,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="网络流量"
|
||||
>
|
||||
<AppCard title="网络流量">
|
||||
{observabilityQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : observabilityQuery.isError ? (
|
||||
@@ -1628,7 +1626,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
|
||||
{nodeInstallCommand ? (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">脚本部署 (Linux / macOS)</p>
|
||||
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
脚本部署 (Linux / macOS)
|
||||
</p>
|
||||
<CodeBlock className="whitespace-pre-wrap">
|
||||
{nodeInstallCommand}
|
||||
</CodeBlock>
|
||||
@@ -1637,7 +1637,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
|
||||
{nodeDockerInstallCommand ? (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">Docker 部署</p>
|
||||
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
Docker 部署
|
||||
</p>
|
||||
<CodeBlock className="whitespace-pre-wrap">
|
||||
{nodeDockerInstallCommand}
|
||||
</CodeBlock>
|
||||
@@ -1835,8 +1837,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<p>该操作会删除当前节点已记录的全部健康事件,包括活动中与已恢复事件。</p>
|
||||
<p>这不会影响节点后续继续上报新的健康事件,但现有时间线与相关摘要会立即刷新。</p>
|
||||
<p>
|
||||
该操作会删除当前节点已记录的全部健康事件,包括活动中与已恢复事件。
|
||||
</p>
|
||||
<p>
|
||||
这不会影响节点后续继续上报新的健康事件,但现有时间线与相关摘要会立即刷新。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AppModal>
|
||||
|
||||
@@ -151,8 +151,8 @@ function getRegionCenter(feature: GeoJsonFeature) {
|
||||
}
|
||||
|
||||
function buildRegionOptions() {
|
||||
const features = ((worldGeoJson as { features?: GeoJsonFeature[] }).features ??
|
||||
[]) as GeoJsonFeature[];
|
||||
const features = ((worldGeoJson as { features?: GeoJsonFeature[] })
|
||||
.features ?? []) as GeoJsonFeature[];
|
||||
const options = new Map<string, RegionOption>();
|
||||
|
||||
for (const feature of features) {
|
||||
@@ -186,7 +186,7 @@ function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
|
||||
ip: node.ip ?? '',
|
||||
auto_update_enabled: node.auto_update_enabled ?? false,
|
||||
geo_manual_override: node.geo_manual_override ?? false,
|
||||
geo_region: node.geo_manual_override ? node.geo_name ?? '' : '',
|
||||
geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '',
|
||||
geo_name: node.geo_name ?? '',
|
||||
geo_latitude:
|
||||
node.geo_latitude === undefined || node.geo_latitude === null
|
||||
|
||||
@@ -354,10 +354,10 @@ export function NodesPage() {
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(
|
||||
node.last_seen_at,
|
||||
)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
? `${formatRelativeTime(
|
||||
node.last_seen_at,
|
||||
)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,188 +1,188 @@
|
||||
import type { ReleaseChannel } from '@/features/update/types';
|
||||
|
||||
export interface NodeItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
geo_manual_override: boolean;
|
||||
agent_token: string;
|
||||
auto_update_enabled: boolean;
|
||||
update_requested: boolean;
|
||||
update_channel: ReleaseChannel;
|
||||
update_tag: string;
|
||||
restart_openresty_requested: boolean;
|
||||
agent_version: string;
|
||||
nginx_version: string;
|
||||
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
openresty_message: string;
|
||||
status: 'online' | 'offline' | 'pending';
|
||||
current_version: string;
|
||||
last_seen_at: string;
|
||||
last_error: string;
|
||||
latest_apply_result: 'success' | 'warning' | 'failed' | '';
|
||||
latest_apply_message: string;
|
||||
latest_apply_checksum: string;
|
||||
latest_main_config_checksum: string;
|
||||
latest_route_config_checksum: string;
|
||||
latest_support_file_count: number;
|
||||
latest_apply_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NodeBootstrapToken {
|
||||
discovery_token: string;
|
||||
}
|
||||
|
||||
export interface NodeMutationPayload {
|
||||
name: string;
|
||||
ip: string;
|
||||
auto_update_enabled: boolean;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
geo_manual_override: boolean;
|
||||
}
|
||||
|
||||
export interface NodeAgentReleaseInfo {
|
||||
tag_name: string;
|
||||
body: string;
|
||||
html_url: string;
|
||||
published_at: string;
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
channel: ReleaseChannel;
|
||||
prerelease: boolean;
|
||||
update_requested: boolean;
|
||||
requested_channel: ReleaseChannel;
|
||||
requested_tag: string;
|
||||
}
|
||||
|
||||
export interface NodeAgentUpdatePayload {
|
||||
channel?: ReleaseChannel;
|
||||
tag_name?: string;
|
||||
}
|
||||
|
||||
export interface NodeSystemProfile {
|
||||
hostname: string;
|
||||
os_name: string;
|
||||
os_version: string;
|
||||
kernel_version: string;
|
||||
architecture: string;
|
||||
cpu_model: string;
|
||||
cpu_cores: number;
|
||||
total_memory_bytes: number;
|
||||
total_disk_bytes: number;
|
||||
uptime_seconds: number;
|
||||
reported_at: string;
|
||||
}
|
||||
|
||||
export interface NodeMetricSnapshot {
|
||||
captured_at: string;
|
||||
cpu_usage_percent: number;
|
||||
memory_used_bytes: number;
|
||||
memory_total_bytes: number;
|
||||
storage_used_bytes: number;
|
||||
storage_total_bytes: number;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
openresty_connections: number;
|
||||
}
|
||||
|
||||
export interface NodeTrafficReport {
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
status_codes_json: string;
|
||||
top_domains_json: string;
|
||||
source_countries_json: string;
|
||||
}
|
||||
|
||||
export interface NodeTrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface NodeCapacityTrendPoint {
|
||||
bucket_started_at: string;
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeNetworkTrendPoint {
|
||||
bucket_started_at: string;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeDiskIOTrendPoint {
|
||||
bucket_started_at: string;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeDistributionItem {
|
||||
key: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface NodeTrafficDistributions {
|
||||
status_codes: NodeDistributionItem[];
|
||||
top_domains: NodeDistributionItem[];
|
||||
source_countries: NodeDistributionItem[];
|
||||
}
|
||||
|
||||
export interface NodeTrafficSummary {
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
request_count: number;
|
||||
unique_visitor_count: number;
|
||||
error_count: number;
|
||||
estimated_qps: number;
|
||||
error_rate_percent: number;
|
||||
}
|
||||
|
||||
export interface NodeHealthSummary {
|
||||
active_alerts: number;
|
||||
critical_alerts: number;
|
||||
warning_alerts: number;
|
||||
info_alerts: number;
|
||||
resolved_alerts: number;
|
||||
has_capacity_risk: boolean;
|
||||
has_traffic_risk: boolean;
|
||||
has_runtime_risk: boolean;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityAnalytics {
|
||||
traffic: NodeTrafficSummary;
|
||||
distributions: NodeTrafficDistributions;
|
||||
health: NodeHealthSummary;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityTrends {
|
||||
traffic_24h: NodeTrafficTrendPoint[];
|
||||
capacity_24h: NodeCapacityTrendPoint[];
|
||||
network_24h: NodeNetworkTrendPoint[];
|
||||
disk_io_24h: NodeDiskIOTrendPoint[];
|
||||
}
|
||||
|
||||
import type { ReleaseChannel } from '@/features/update/types';
|
||||
|
||||
export interface NodeItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
geo_manual_override: boolean;
|
||||
agent_token: string;
|
||||
auto_update_enabled: boolean;
|
||||
update_requested: boolean;
|
||||
update_channel: ReleaseChannel;
|
||||
update_tag: string;
|
||||
restart_openresty_requested: boolean;
|
||||
agent_version: string;
|
||||
nginx_version: string;
|
||||
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
openresty_message: string;
|
||||
status: 'online' | 'offline' | 'pending';
|
||||
current_version: string;
|
||||
last_seen_at: string;
|
||||
last_error: string;
|
||||
latest_apply_result: 'success' | 'warning' | 'failed' | '';
|
||||
latest_apply_message: string;
|
||||
latest_apply_checksum: string;
|
||||
latest_main_config_checksum: string;
|
||||
latest_route_config_checksum: string;
|
||||
latest_support_file_count: number;
|
||||
latest_apply_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NodeBootstrapToken {
|
||||
discovery_token: string;
|
||||
}
|
||||
|
||||
export interface NodeMutationPayload {
|
||||
name: string;
|
||||
ip: string;
|
||||
auto_update_enabled: boolean;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
geo_manual_override: boolean;
|
||||
}
|
||||
|
||||
export interface NodeAgentReleaseInfo {
|
||||
tag_name: string;
|
||||
body: string;
|
||||
html_url: string;
|
||||
published_at: string;
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
channel: ReleaseChannel;
|
||||
prerelease: boolean;
|
||||
update_requested: boolean;
|
||||
requested_channel: ReleaseChannel;
|
||||
requested_tag: string;
|
||||
}
|
||||
|
||||
export interface NodeAgentUpdatePayload {
|
||||
channel?: ReleaseChannel;
|
||||
tag_name?: string;
|
||||
}
|
||||
|
||||
export interface NodeSystemProfile {
|
||||
hostname: string;
|
||||
os_name: string;
|
||||
os_version: string;
|
||||
kernel_version: string;
|
||||
architecture: string;
|
||||
cpu_model: string;
|
||||
cpu_cores: number;
|
||||
total_memory_bytes: number;
|
||||
total_disk_bytes: number;
|
||||
uptime_seconds: number;
|
||||
reported_at: string;
|
||||
}
|
||||
|
||||
export interface NodeMetricSnapshot {
|
||||
captured_at: string;
|
||||
cpu_usage_percent: number;
|
||||
memory_used_bytes: number;
|
||||
memory_total_bytes: number;
|
||||
storage_used_bytes: number;
|
||||
storage_total_bytes: number;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
openresty_connections: number;
|
||||
}
|
||||
|
||||
export interface NodeTrafficReport {
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
status_codes_json: string;
|
||||
top_domains_json: string;
|
||||
source_countries_json: string;
|
||||
}
|
||||
|
||||
export interface NodeTrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface NodeCapacityTrendPoint {
|
||||
bucket_started_at: string;
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeNetworkTrendPoint {
|
||||
bucket_started_at: string;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeDiskIOTrendPoint {
|
||||
bucket_started_at: string;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeDistributionItem {
|
||||
key: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface NodeTrafficDistributions {
|
||||
status_codes: NodeDistributionItem[];
|
||||
top_domains: NodeDistributionItem[];
|
||||
source_countries: NodeDistributionItem[];
|
||||
}
|
||||
|
||||
export interface NodeTrafficSummary {
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
request_count: number;
|
||||
unique_visitor_count: number;
|
||||
error_count: number;
|
||||
estimated_qps: number;
|
||||
error_rate_percent: number;
|
||||
}
|
||||
|
||||
export interface NodeHealthSummary {
|
||||
active_alerts: number;
|
||||
critical_alerts: number;
|
||||
warning_alerts: number;
|
||||
info_alerts: number;
|
||||
resolved_alerts: number;
|
||||
has_capacity_risk: boolean;
|
||||
has_traffic_risk: boolean;
|
||||
has_runtime_risk: boolean;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityAnalytics {
|
||||
traffic: NodeTrafficSummary;
|
||||
distributions: NodeTrafficDistributions;
|
||||
health: NodeHealthSummary;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityTrends {
|
||||
traffic_24h: NodeTrafficTrendPoint[];
|
||||
capacity_24h: NodeCapacityTrendPoint[];
|
||||
network_24h: NodeNetworkTrendPoint[];
|
||||
disk_io_24h: NodeDiskIOTrendPoint[];
|
||||
}
|
||||
|
||||
export interface NodeHealthEvent {
|
||||
event_type: string;
|
||||
severity: string;
|
||||
@@ -194,13 +194,13 @@ export interface NodeHealthEvent {
|
||||
reported_at: string;
|
||||
resolved_at?: string | null;
|
||||
}
|
||||
|
||||
export interface NodeObservability {
|
||||
node_id: string;
|
||||
profile: NodeSystemProfile | null;
|
||||
metric_snapshots: NodeMetricSnapshot[];
|
||||
traffic_reports: NodeTrafficReport[];
|
||||
health_events: NodeHealthEvent[];
|
||||
analytics: NodeObservabilityAnalytics;
|
||||
trends: NodeObservabilityTrends;
|
||||
}
|
||||
|
||||
export interface NodeObservability {
|
||||
node_id: string;
|
||||
profile: NodeSystemProfile | null;
|
||||
metric_snapshots: NodeMetricSnapshot[];
|
||||
traffic_reports: NodeTrafficReport[];
|
||||
health_events: NodeHealthEvent[];
|
||||
analytics: NodeObservabilityAnalytics;
|
||||
trends: NodeObservabilityTrends;
|
||||
}
|
||||
|
||||
@@ -1,189 +1,194 @@
|
||||
import type { NodeItem } from '@/features/nodes/types';
|
||||
|
||||
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
|
||||
|
||||
export function isWSConnectedLastSeen(value: string | null | undefined) {
|
||||
return value === WS_CONNECTED_LAST_SEEN;
|
||||
}
|
||||
|
||||
export function isMeaningfulTime(value: string | null | undefined) {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
!isWSConnectedLastSeen(value) &&
|
||||
!String(value).startsWith('0001-01-01')
|
||||
);
|
||||
}
|
||||
|
||||
export function getNodeStatusVariant(status: NodeItem['status']) {
|
||||
if (status === 'online') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
export function getNodeStatusLabel(status: NodeItem['status']) {
|
||||
if (status === 'online') {
|
||||
return '在线';
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return '待接入';
|
||||
}
|
||||
|
||||
return '离线';
|
||||
}
|
||||
|
||||
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
|
||||
if (result === 'success') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (result === 'warning') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (result === 'failed') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
|
||||
if (result === 'success') {
|
||||
return '成功';
|
||||
}
|
||||
|
||||
if (result === 'warning') {
|
||||
return '警告';
|
||||
}
|
||||
|
||||
if (result === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
|
||||
return '暂无';
|
||||
}
|
||||
|
||||
export function getUpdateMode(node: NodeItem) {
|
||||
if (node.update_requested) {
|
||||
if (node.update_channel === 'preview') {
|
||||
return { label: '等待预览更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
return { label: '等待更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
if (node.auto_update_enabled) {
|
||||
return { label: '自动', variant: 'success' as const };
|
||||
}
|
||||
|
||||
return { label: '手动', variant: 'info' as const };
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) {
|
||||
if (status === 'healthy') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
|
||||
if (status === 'healthy') {
|
||||
return '健康';
|
||||
}
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
return '异常';
|
||||
}
|
||||
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function parseVersionParts(version: string) {
|
||||
const normalized = version.trim().replace(/^v/i, '');
|
||||
if (!normalized || normalized.toLowerCase() === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.split('.').map((segment) => {
|
||||
const matched = segment.trim().match(/^\d+/);
|
||||
return matched ? Number.parseInt(matched[0], 10) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function isOlderVersion(current: string, target: string) {
|
||||
const currentParts = parseVersionParts(current);
|
||||
const targetParts = parseVersionParts(target);
|
||||
if (!currentParts || !targetParts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxLength = Math.max(currentParts.length, targetParts.length);
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const currentPart = currentParts[index] ?? 0;
|
||||
const targetPart = targetParts[index] ?? 0;
|
||||
if (currentPart < targetPart) {
|
||||
return true;
|
||||
}
|
||||
if (currentPart > targetPart) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldShowManualUpdate(
|
||||
agentVersion: string,
|
||||
serverVersion: string,
|
||||
) {
|
||||
const normalizedServerVersion = serverVersion.trim();
|
||||
const normalizedAgentVersion = agentVersion.trim();
|
||||
|
||||
if (
|
||||
!normalizedServerVersion ||
|
||||
normalizedServerVersion.toLowerCase() === 'dev' ||
|
||||
!normalizedAgentVersion ||
|
||||
normalizedAgentVersion.toLowerCase() === 'unknown'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
|
||||
}
|
||||
|
||||
export function getServerUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const installerScriptUrl =
|
||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
||||
|
||||
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
|
||||
return [
|
||||
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
|
||||
` --server-url ${serverUrl} \\`,
|
||||
` --agent-token ${agentToken}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildNodeDockerInstallCommand(serverUrl: string, agentToken: string) {
|
||||
return [
|
||||
`docker run -d --name openflare-agent --restart unless-stopped \\`,
|
||||
` -p 80:80 -p 443:443 \\`,
|
||||
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
|
||||
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
|
||||
` ghcr.io/rain-kl/openflare-agent:latest`
|
||||
].join('\n');
|
||||
}
|
||||
import type { NodeItem } from '@/features/nodes/types';
|
||||
|
||||
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
|
||||
|
||||
export function isWSConnectedLastSeen(value: string | null | undefined) {
|
||||
return value === WS_CONNECTED_LAST_SEEN;
|
||||
}
|
||||
|
||||
export function isMeaningfulTime(value: string | null | undefined) {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
!isWSConnectedLastSeen(value) &&
|
||||
!String(value).startsWith('0001-01-01')
|
||||
);
|
||||
}
|
||||
|
||||
export function getNodeStatusVariant(status: NodeItem['status']) {
|
||||
if (status === 'online') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
export function getNodeStatusLabel(status: NodeItem['status']) {
|
||||
if (status === 'online') {
|
||||
return '在线';
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return '待接入';
|
||||
}
|
||||
|
||||
return '离线';
|
||||
}
|
||||
|
||||
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
|
||||
if (result === 'success') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (result === 'warning') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (result === 'failed') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
|
||||
if (result === 'success') {
|
||||
return '成功';
|
||||
}
|
||||
|
||||
if (result === 'warning') {
|
||||
return '警告';
|
||||
}
|
||||
|
||||
if (result === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
|
||||
return '暂无';
|
||||
}
|
||||
|
||||
export function getUpdateMode(node: NodeItem) {
|
||||
if (node.update_requested) {
|
||||
if (node.update_channel === 'preview') {
|
||||
return { label: '等待预览更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
return { label: '等待更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
if (node.auto_update_enabled) {
|
||||
return { label: '自动', variant: 'success' as const };
|
||||
}
|
||||
|
||||
return { label: '手动', variant: 'info' as const };
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusVariant(
|
||||
status: NodeItem['openresty_status'],
|
||||
) {
|
||||
if (status === 'healthy') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
|
||||
if (status === 'healthy') {
|
||||
return '健康';
|
||||
}
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
return '异常';
|
||||
}
|
||||
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function parseVersionParts(version: string) {
|
||||
const normalized = version.trim().replace(/^v/i, '');
|
||||
if (!normalized || normalized.toLowerCase() === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.split('.').map((segment) => {
|
||||
const matched = segment.trim().match(/^\d+/);
|
||||
return matched ? Number.parseInt(matched[0], 10) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function isOlderVersion(current: string, target: string) {
|
||||
const currentParts = parseVersionParts(current);
|
||||
const targetParts = parseVersionParts(target);
|
||||
if (!currentParts || !targetParts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxLength = Math.max(currentParts.length, targetParts.length);
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const currentPart = currentParts[index] ?? 0;
|
||||
const targetPart = targetParts[index] ?? 0;
|
||||
if (currentPart < targetPart) {
|
||||
return true;
|
||||
}
|
||||
if (currentPart > targetPart) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldShowManualUpdate(
|
||||
agentVersion: string,
|
||||
serverVersion: string,
|
||||
) {
|
||||
const normalizedServerVersion = serverVersion.trim();
|
||||
const normalizedAgentVersion = agentVersion.trim();
|
||||
|
||||
if (
|
||||
!normalizedServerVersion ||
|
||||
normalizedServerVersion.toLowerCase() === 'dev' ||
|
||||
!normalizedAgentVersion ||
|
||||
normalizedAgentVersion.toLowerCase() === 'unknown'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
|
||||
}
|
||||
|
||||
export function getServerUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const installerScriptUrl =
|
||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
||||
|
||||
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
|
||||
return [
|
||||
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
|
||||
` --server-url ${serverUrl} \\`,
|
||||
` --agent-token ${agentToken}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildNodeDockerInstallCommand(
|
||||
serverUrl: string,
|
||||
agentToken: string,
|
||||
) {
|
||||
return [
|
||||
`docker run -d --name openflare-agent --restart unless-stopped \\`,
|
||||
` -p 80:80 -p 443:443 \\`,
|
||||
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
|
||||
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
|
||||
` ghcr.io/rain-kl/openflare-agent:latest`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ManagedDomainMatchResult,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
TlsCertificateItem,
|
||||
} from '@/features/proxy-routes/types';
|
||||
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ManagedDomainMatchResult,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
TlsCertificateItem,
|
||||
} from '@/features/proxy-routes/types';
|
||||
|
||||
export function getProxyRoutes() {
|
||||
return apiRequest<ProxyRouteItem[]>('/proxy-routes/');
|
||||
}
|
||||
@@ -17,12 +17,15 @@ export function getProxyRoute(id: number) {
|
||||
|
||||
export function createProxyRoute(payload: ProxyRouteMutationPayload) {
|
||||
return apiRequest<ProxyRouteItem>('/proxy-routes/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProxyRoute(id: number, payload: ProxyRouteMutationPayload) {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProxyRoute(
|
||||
id: number,
|
||||
payload: ProxyRouteMutationPayload,
|
||||
) {
|
||||
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
@@ -34,12 +37,14 @@ export function deleteProxyRoute(id: number) {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getTlsCertificates() {
|
||||
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
|
||||
}
|
||||
|
||||
export function matchManagedDomainCertificate(domain: string) {
|
||||
const searchParams = new URLSearchParams({ domain });
|
||||
return apiRequest<ManagedDomainMatchResult>(`/managed-domains/match?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function getTlsCertificates() {
|
||||
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
|
||||
}
|
||||
|
||||
export function matchManagedDomainCertificate(domain: string) {
|
||||
const searchParams = new URLSearchParams({ domain });
|
||||
return apiRequest<ManagedDomainMatchResult>(
|
||||
`/managed-domains/match?${searchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,9 +100,7 @@ export function buildDomainRowsFromRoute(
|
||||
if (domainCertIDs.length === domains.length) {
|
||||
return domains.map((domain, index) => ({
|
||||
domain,
|
||||
certificateId: domainCertIDs[index]
|
||||
? String(domainCertIDs[index])
|
||||
: '',
|
||||
certificateId: domainCertIDs[index] ? String(domainCertIDs[index]) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -185,7 +183,9 @@ export function DomainListInput({
|
||||
value={row.domain}
|
||||
list={`${listId}-${index}`}
|
||||
aria-label={`域名 ${index + 1}`}
|
||||
placeholder={index === 0 ? domainPlaceholder : 'www.example.com'}
|
||||
placeholder={
|
||||
index === 0 ? domainPlaceholder : 'www.example.com'
|
||||
}
|
||||
onBlur={onBlur}
|
||||
onChange={(event) => {
|
||||
const nextRows = safeRows.slice();
|
||||
|
||||
@@ -123,7 +123,10 @@ const rateLimitSchema = z
|
||||
limit_rate: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
for (const field of ['limit_conn_per_server', 'limit_conn_per_ip'] as const) {
|
||||
for (const field of [
|
||||
'limit_conn_per_server',
|
||||
'limit_conn_per_ip',
|
||||
] as const) {
|
||||
const rawValue = value[field].trim();
|
||||
if (!rawValue) {
|
||||
continue;
|
||||
@@ -369,7 +372,9 @@ function DomainSettingsSection({
|
||||
<ResourceField
|
||||
label="域名列表"
|
||||
hint="每行配置一个域名。可为不同域名选择不同证书,相同证书也可以重复选择。"
|
||||
error={form.formState.errors.domain_rows?.message as string | undefined}
|
||||
error={
|
||||
form.formState.errors.domain_rows?.message as string | undefined
|
||||
}
|
||||
container="div"
|
||||
>
|
||||
<Controller
|
||||
@@ -490,7 +495,10 @@ function RateLimitSection({
|
||||
error={form.formState.errors.limit_rate?.message}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<ResourceInput placeholder="512k/1m" {...form.register('limit_rate')} />
|
||||
<ResourceInput
|
||||
placeholder="512k/1m"
|
||||
{...form.register('limit_rate')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</form>
|
||||
</ConfigSectionShell>
|
||||
@@ -538,7 +546,9 @@ function ReverseProxySection({
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||
const { headers } = parseCustomHeadersText(values.custom_headers_text);
|
||||
const { headers } = parseCustomHeadersText(
|
||||
values.custom_headers_text,
|
||||
);
|
||||
|
||||
onSave(
|
||||
buildPayloadFromRoute(route, {
|
||||
@@ -565,7 +575,9 @@ function ReverseProxySection({
|
||||
<ResourceTextarea
|
||||
aria-label="上游地址"
|
||||
className="min-h-40"
|
||||
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
|
||||
placeholder={
|
||||
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
|
||||
}
|
||||
{...form.register('origin_urls_text')}
|
||||
/>
|
||||
</ResourceField>
|
||||
@@ -593,7 +605,10 @@ function ReverseProxySection({
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label="备注" error={form.formState.errors.remark?.message}>
|
||||
<ResourceField
|
||||
label="备注"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
placeholder="例如:多活回源,优先使用上海入口"
|
||||
{...form.register('remark')}
|
||||
@@ -617,7 +632,8 @@ function CacheSection({
|
||||
resolver: zodResolver(cacheSchema),
|
||||
defaultValues: {
|
||||
cache_enabled: route.cache_enabled,
|
||||
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
|
||||
cache_policy: (route.cache_policy ||
|
||||
'url') as CacheValues['cache_policy'],
|
||||
cache_rules_text: route.cache_rule_list.join('\n'),
|
||||
},
|
||||
});
|
||||
@@ -625,7 +641,8 @@ function CacheSection({
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
cache_enabled: route.cache_enabled,
|
||||
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
|
||||
cache_policy: (route.cache_policy ||
|
||||
'url') as CacheValues['cache_policy'],
|
||||
cache_rules_text: route.cache_rule_list.join('\n'),
|
||||
});
|
||||
}, [form, route]);
|
||||
@@ -650,7 +667,9 @@ function CacheSection({
|
||||
cache_enabled: values.cache_enabled,
|
||||
cache_policy: values.cache_enabled ? values.cache_policy : 'url',
|
||||
cache_rules:
|
||||
values.cache_enabled && values.cache_policy !== 'url' ? rules : [],
|
||||
values.cache_enabled && values.cache_policy !== 'url'
|
||||
? rules
|
||||
: [],
|
||||
}),
|
||||
{ message: '缓存设置已保存。' },
|
||||
);
|
||||
@@ -770,7 +789,13 @@ type PowValues = z.infer<typeof powSchema>;
|
||||
|
||||
function buildPowListFromConfig(
|
||||
list:
|
||||
| { ips?: string[]; ip_cidrs?: string[]; paths?: string[]; path_regexes?: string[]; user_agents?: string[] }
|
||||
| {
|
||||
ips?: string[];
|
||||
ip_cidrs?: string[];
|
||||
paths?: string[];
|
||||
path_regexes?: string[];
|
||||
user_agents?: string[];
|
||||
}
|
||||
| undefined,
|
||||
): PowListValues {
|
||||
return {
|
||||
@@ -923,7 +948,7 @@ function PowSection({
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
||||
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
|
||||
<legend className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
白名单(匹配的请求跳过 PoW)
|
||||
</legend>
|
||||
<ResourceField label="IP" hint="每行一个 IP 地址">
|
||||
@@ -964,7 +989,7 @@ function PowSection({
|
||||
</fieldset>
|
||||
|
||||
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
||||
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
|
||||
<legend className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
黑名单(匹配的请求必须 PoW)
|
||||
</legend>
|
||||
<ResourceField label="IP" hint="每行一个 IP 地址">
|
||||
@@ -1178,7 +1203,9 @@ export function ProxyRouteConfigPage({
|
||||
setFeedback({ tone: 'success', message: context.message });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['config-versions', 'diff'],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
+11
-4
@@ -156,7 +156,9 @@ export function ProxyRouteCreateDrawer({
|
||||
.map((item) => item.domain.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
|
||||
const selectedCertIDs = normalizeSelectedCertificateIDs(values.domain_rows);
|
||||
const selectedCertIDs = normalizeSelectedCertificateIDs(
|
||||
values.domain_rows,
|
||||
);
|
||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||
|
||||
@@ -182,7 +184,8 @@ export function ProxyRouteCreateDrawer({
|
||||
cert_id: selectedCertIDs[0] ?? null,
|
||||
cert_ids: selectedCertIDs,
|
||||
domain_cert_ids: domainCertIDs,
|
||||
redirect_http: selectedCertIDs.length > 0 ? values.redirect_http : false,
|
||||
redirect_http:
|
||||
selectedCertIDs.length > 0 ? values.redirect_http : false,
|
||||
limit_conn_per_server: 0,
|
||||
limit_conn_per_ip: 0,
|
||||
limit_rate: '',
|
||||
@@ -248,7 +251,9 @@ export function ProxyRouteCreateDrawer({
|
||||
<ResourceField
|
||||
label="域名列表"
|
||||
hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。"
|
||||
error={form.formState.errors.domain_rows?.message as string | undefined}
|
||||
error={
|
||||
form.formState.errors.domain_rows?.message as string | undefined
|
||||
}
|
||||
container="div"
|
||||
>
|
||||
<Controller
|
||||
@@ -287,7 +292,9 @@ export function ProxyRouteCreateDrawer({
|
||||
>
|
||||
<ResourceTextarea
|
||||
aria-label="上游地址"
|
||||
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
|
||||
placeholder={
|
||||
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
|
||||
}
|
||||
{...form.register('origin_urls_text')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
@@ -87,7 +87,9 @@ export function ProxyRoutesPage() {
|
||||
setFeedback({ tone: 'success', message: '网站已删除。' });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['config-versions', 'diff'],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -104,7 +106,9 @@ export function ProxyRoutesPage() {
|
||||
});
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['config-versions', 'diff'],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -135,7 +139,10 @@ export function ProxyRoutesPage() {
|
||||
}, [keyword, routes]);
|
||||
|
||||
const diff = diffQuery.data;
|
||||
const totalDomains = routes.reduce((sum, route) => sum + route.domain_count, 0);
|
||||
const totalDomains = routes.reduce(
|
||||
(sum, route) => sum + route.domain_count,
|
||||
0,
|
||||
);
|
||||
const enabledCount = routes.filter((route) => route.enabled).length;
|
||||
|
||||
const handleDelete = (route: ProxyRouteItem) => {
|
||||
@@ -243,11 +250,11 @@ export function ProxyRoutesPage() {
|
||||
<div className="space-y-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
diff && hasConfigChanges(diff) ? '有待发布变更' : '已与线上一致'
|
||||
}
|
||||
variant={
|
||||
diff && hasConfigChanges(diff) ? 'warning' : 'success'
|
||||
diff && hasConfigChanges(diff)
|
||||
? '有待发布变更'
|
||||
: '已与线上一致'
|
||||
}
|
||||
variant={diff && hasConfigChanges(diff) ? 'warning' : 'success'}
|
||||
/>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{diff
|
||||
@@ -266,9 +273,7 @@ export function ProxyRoutesPage() {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard
|
||||
title="规则列表"
|
||||
>
|
||||
<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">
|
||||
@@ -356,7 +361,7 @@ export function ProxyRoutesPage() {
|
||||
|
||||
<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 uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
域名列表
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
@@ -365,7 +370,7 @@ export function ProxyRoutesPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
上游摘要
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
@@ -393,7 +398,9 @@ export function ProxyRoutesPage() {
|
||||
onCreated={async (route) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['config-versions', 'diff'],
|
||||
}),
|
||||
]);
|
||||
router.push(`/proxy-route/detail?id=${route.id}§ion=domains`);
|
||||
}}
|
||||
|
||||
@@ -386,7 +386,7 @@ export function AuthSourceModal({
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<div className="md:col-span-2 rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-3 text-sm leading-6 text-[var(--status-info-foreground)]">
|
||||
<div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-3 text-sm leading-6 text-[var(--status-info-foreground)] md:col-span-2">
|
||||
第三方平台的 Redirect URI / Callback URL 请填写:
|
||||
<span className="font-medium">
|
||||
{' '}
|
||||
|
||||
@@ -927,7 +927,9 @@ export function SettingsPage() {
|
||||
) : (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => handleBindAuthSource(source.name)}
|
||||
onClick={() =>
|
||||
handleBindAuthSource(source.name)
|
||||
}
|
||||
disabled={
|
||||
busyKey === `auth-source-bind-${source.name}`
|
||||
}
|
||||
@@ -1591,7 +1593,6 @@ export function SettingsPage() {
|
||||
}
|
||||
disabled={busyKey === 'toggle-EmailVerificationEnabled'}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className="mt-5 text-sm text-[var(--foreground-secondary)]">
|
||||
当前已配置 {authSourcesQuery.data?.length ?? 0} 个认证源。
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
|
||||
interface ModulePageSkeletonProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function ModulePageSkeleton({
|
||||
title,
|
||||
description,
|
||||
}: ModulePageSkeletonProps) {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<PageHeader title={title} description={description} />
|
||||
|
||||
<FeaturePlaceholder
|
||||
title={`${title} 页面骨架`}
|
||||
description='当前阶段先完成页面入口、统一头部和反馈组件接线,真实列表、表单与联动能力将在后续业务迁移阶段接入。'
|
||||
milestones={[
|
||||
'路由入口已创建,可纳入统一导航。',
|
||||
'页面标题区、说明区与内容容器已统一。',
|
||||
'加载态、空态、错误态组件已建立,可在真实查询接入后直接复用。',
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-3'>
|
||||
<LoadingState />
|
||||
<EmptyState title='空态骨架' description='后续接入查询后,可将资源为空时的提示统一沉淀为此组件。' />
|
||||
<ErrorState title='错误态骨架' description='后续请求失败、鉴权失效与重试提示可在此基础上扩展。' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
|
||||
interface ModulePageSkeletonProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function ModulePageSkeleton({
|
||||
title,
|
||||
description,
|
||||
}: ModulePageSkeletonProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={title} description={description} />
|
||||
|
||||
<FeaturePlaceholder
|
||||
title={`${title} 页面骨架`}
|
||||
description="当前阶段先完成页面入口、统一头部和反馈组件接线,真实列表、表单与联动能力将在后续业务迁移阶段接入。"
|
||||
milestones={[
|
||||
'路由入口已创建,可纳入统一导航。',
|
||||
'页面标题区、说明区与内容容器已统一。',
|
||||
'加载态、空态、错误态组件已建立,可在真实查询接入后直接复用。',
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<LoadingState />
|
||||
<EmptyState
|
||||
title="空态骨架"
|
||||
description="后续接入查询后,可将资源为空时的提示统一沉淀为此组件。"
|
||||
/>
|
||||
<ErrorState
|
||||
title="错误态骨架"
|
||||
description="后续请求失败、鉴权失效与重试提示可在此基础上扩展。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ export function ToggleField({
|
||||
onChange,
|
||||
}: ToggleFieldProps) {
|
||||
return (
|
||||
<label className="flex self-start cursor-pointer items-start gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||
<label className="flex cursor-pointer items-start gap-3 self-start rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
|
||||
@@ -1,489 +1,477 @@
|
||||
'use client';
|
||||
|
||||
import {marked} from 'marked';
|
||||
import {useEffect, useState} from 'react';
|
||||
import { marked } from 'marked';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {EmptyState} from '@/components/feedback/empty-state';
|
||||
import {ErrorState} from '@/components/feedback/error-state';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {AppModal} from '@/components/ui/app-modal';
|
||||
import {StatusBadge} from '@/components/ui/status-badge';
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import type {
|
||||
LatestReleaseInfo,
|
||||
ReleaseChannel,
|
||||
UploadedServerBinaryInfo,
|
||||
LatestReleaseInfo,
|
||||
ReleaseChannel,
|
||||
UploadedServerBinaryInfo,
|
||||
} from '@/features/update/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import {formatDateTime, formatRelativeTime} from '@/lib/utils/date';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
|
||||
interface VersionUpgradeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentVersion: string;
|
||||
release: LatestReleaseInfo | null | undefined;
|
||||
selectedChannel: ReleaseChannel;
|
||||
uploadedBinary: UploadedServerBinaryInfo | null;
|
||||
isLoading: boolean;
|
||||
releaseErrorMessage?: string;
|
||||
manualStatusMessage?: string;
|
||||
manualErrorMessage?: string;
|
||||
canUpgrade: boolean;
|
||||
isChecking: boolean;
|
||||
isUpgrading: boolean;
|
||||
isUploadingBinary: boolean;
|
||||
uploadProgress: number;
|
||||
isConfirmingManualUpgrade: boolean;
|
||||
onChannelChange: (channel: ReleaseChannel) => void;
|
||||
onCheck: () => void;
|
||||
onUpgrade: () => void;
|
||||
onUploadBinary: (file: File) => void;
|
||||
onConfirmManualUpgrade: () => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentVersion: string;
|
||||
release: LatestReleaseInfo | null | undefined;
|
||||
selectedChannel: ReleaseChannel;
|
||||
uploadedBinary: UploadedServerBinaryInfo | null;
|
||||
isLoading: boolean;
|
||||
releaseErrorMessage?: string;
|
||||
manualStatusMessage?: string;
|
||||
manualErrorMessage?: string;
|
||||
canUpgrade: boolean;
|
||||
isChecking: boolean;
|
||||
isUpgrading: boolean;
|
||||
isUploadingBinary: boolean;
|
||||
uploadProgress: number;
|
||||
isConfirmingManualUpgrade: boolean;
|
||||
onChannelChange: (channel: ReleaseChannel) => void;
|
||||
onCheck: () => void;
|
||||
onUpgrade: () => void;
|
||||
onUploadBinary: (file: File) => void;
|
||||
onConfirmManualUpgrade: () => void;
|
||||
}
|
||||
|
||||
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
|
||||
if (!release) {
|
||||
return {label: '未检查', variant: 'info' as const};
|
||||
}
|
||||
if (release.in_progress) {
|
||||
return {label: '升级中', variant: 'warning' as const};
|
||||
}
|
||||
if (release.has_update) {
|
||||
return {label: '可升级', variant: 'warning' as const};
|
||||
}
|
||||
return {label: '最新', variant: 'success' as const};
|
||||
if (!release) {
|
||||
return { label: '未检查', variant: 'info' as const };
|
||||
}
|
||||
if (release.in_progress) {
|
||||
return { label: '升级中', variant: 'warning' as const };
|
||||
}
|
||||
if (release.has_update) {
|
||||
return { label: '可升级', variant: 'warning' as const };
|
||||
}
|
||||
return { label: '最新', variant: 'success' as const };
|
||||
}
|
||||
|
||||
function formatLogTimestamp(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '0000-00-00 00:00:00.000';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '0000-00-00 00:00:00.000';
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
||||
const second = `${date.getSeconds()}`.padStart(2, '0');
|
||||
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
||||
const second = `${date.getSeconds()}`.padStart(2, '0');
|
||||
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
|
||||
}
|
||||
|
||||
function formatLogLevel(level: string) {
|
||||
const normalized = (level || 'info').toUpperCase();
|
||||
return normalized.padEnd(8, ' ');
|
||||
const normalized = (level || 'info').toUpperCase();
|
||||
return normalized.padEnd(8, ' ');
|
||||
}
|
||||
|
||||
export function VersionUpgradeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentVersion,
|
||||
release,
|
||||
selectedChannel,
|
||||
uploadedBinary,
|
||||
isLoading,
|
||||
releaseErrorMessage,
|
||||
manualStatusMessage,
|
||||
manualErrorMessage,
|
||||
canUpgrade,
|
||||
isChecking,
|
||||
isUpgrading,
|
||||
isUploadingBinary,
|
||||
uploadProgress,
|
||||
isConfirmingManualUpgrade,
|
||||
onChannelChange,
|
||||
onCheck,
|
||||
onUpgrade,
|
||||
onUploadBinary,
|
||||
onConfirmManualUpgrade,
|
||||
}: VersionUpgradeModalProps) {
|
||||
const upgradeBadge = getUpgradeBadge(release);
|
||||
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
|
||||
const uploadPhaseLabel =
|
||||
uploadProgress >= 100
|
||||
? '已上传,正在服务端校验版本...'
|
||||
: '上传中...';
|
||||
const selectedChannelLabel =
|
||||
selectedChannel === 'preview' ? '预览版' : '正式版';
|
||||
const toggleChannel = () => {
|
||||
onChannelChange(selectedChannel === 'preview' ? 'stable' : 'preview');
|
||||
};
|
||||
const canConfirmManualUpgrade = Boolean(
|
||||
uploadedBinary?.ready_to_upgrade && uploadedBinary.upload_token,
|
||||
);
|
||||
const showConfirmManualUpgradeAction = canConfirmManualUpgrade;
|
||||
const upgradeLogs = release?.upgrade_logs ?? [];
|
||||
isOpen,
|
||||
onClose,
|
||||
currentVersion,
|
||||
release,
|
||||
selectedChannel,
|
||||
uploadedBinary,
|
||||
isLoading,
|
||||
releaseErrorMessage,
|
||||
manualStatusMessage,
|
||||
manualErrorMessage,
|
||||
canUpgrade,
|
||||
isChecking,
|
||||
isUpgrading,
|
||||
isUploadingBinary,
|
||||
uploadProgress,
|
||||
isConfirmingManualUpgrade,
|
||||
onChannelChange,
|
||||
onCheck,
|
||||
onUpgrade,
|
||||
onUploadBinary,
|
||||
onConfirmManualUpgrade,
|
||||
}: VersionUpgradeModalProps) {
|
||||
const upgradeBadge = getUpgradeBadge(release);
|
||||
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
|
||||
const uploadPhaseLabel =
|
||||
uploadProgress >= 100 ? '已上传,正在服务端校验版本...' : '上传中...';
|
||||
const selectedChannelLabel =
|
||||
selectedChannel === 'preview' ? '预览版' : '正式版';
|
||||
const toggleChannel = () => {
|
||||
onChannelChange(selectedChannel === 'preview' ? 'stable' : 'preview');
|
||||
};
|
||||
const canConfirmManualUpgrade = Boolean(
|
||||
uploadedBinary?.ready_to_upgrade && uploadedBinary.upload_token,
|
||||
);
|
||||
const showConfirmManualUpgradeAction = canConfirmManualUpgrade;
|
||||
const upgradeLogs = release?.upgrade_logs ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedBinary(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedBinary(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="版本"
|
||||
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级,或上传 Server 二进制确认升级。升级开始后服务会短暂重启。"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
|
||||
|
||||
<AppCard title="服务端版本">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{currentVersion || 'unknown'}
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={upgradeBadge.label}
|
||||
variant={upgradeBadge.variant}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="最新版本">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{release?.tag_name || '未检查'}
|
||||
</p>
|
||||
{canUpgrade ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
label={selectedChannelLabel}
|
||||
variant={
|
||||
selectedChannel === 'preview' ? 'warning' : 'info'
|
||||
}
|
||||
onClick={toggleChannel}
|
||||
disabled={isChecking || isUpgrading || isUploadingBinary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onCheck}
|
||||
disabled={isChecking || isUpgrading || isUploadingBinary}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{isChecking ? '检查中...' : '检查'}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isLoading ? <LoadingState/> : null}
|
||||
{!isLoading && releaseErrorMessage ? (
|
||||
<ErrorState title="版本检查失败" description={releaseErrorMessage}/>
|
||||
) : null}
|
||||
{!isLoading && !releaseErrorMessage && !release ? (
|
||||
<EmptyState
|
||||
title={`尚未检查${selectedChannelLabel}`}
|
||||
description={`点击“检查${selectedChannelLabel}”后会在这里展示对应 GitHub Release 信息。`}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !releaseErrorMessage && release ? (
|
||||
<AppCard
|
||||
title={`GitHub ${selectedChannelLabel} · ${release.tag_name}`}
|
||||
description={
|
||||
release.published_at
|
||||
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
||||
: '未提供发布时间'
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={release.has_update ? '发现新版本' : '已经是最新版本'}
|
||||
variant={release.has_update ? 'warning' : 'success'}
|
||||
/>
|
||||
{release.prerelease ? (
|
||||
<StatusBadge label="Preview 发布" variant="warning"/>
|
||||
) : (
|
||||
<StatusBadge label="正式发布" variant="info"/>
|
||||
)}
|
||||
{!release.upgrade_supported ? (
|
||||
<StatusBadge
|
||||
label="当前平台不支持自动升级"
|
||||
variant="danger"
|
||||
/>
|
||||
) : null}
|
||||
{release.in_progress ? (
|
||||
<StatusBadge label="升级任务执行中" variant="warning"/>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(
|
||||
release.body || '暂无更新说明',
|
||||
) as string,
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href={release.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
查看发布详情
|
||||
</a>
|
||||
{canUpgrade ? (
|
||||
<div className="flex justify-end">
|
||||
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onUpgrade}
|
||||
disabled={
|
||||
!release.has_update ||
|
||||
release.in_progress ||
|
||||
isUpgrading ||
|
||||
!release.upgrade_supported ||
|
||||
isUploadingBinary ||
|
||||
isConfirmingManualUpgrade
|
||||
}
|
||||
>
|
||||
{isUpgrading
|
||||
? '升级中...'
|
||||
: release.in_progress
|
||||
? '升级中...'
|
||||
: selectedChannel === 'preview'
|
||||
? '升级预览版'
|
||||
: '升级正式版'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
{release ? (
|
||||
<AppCard
|
||||
title="升级日志"
|
||||
>
|
||||
{upgradeLogs.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
release.upgrade_status === 'failed'
|
||||
? '升级失败'
|
||||
: release.upgrade_status === 'succeeded'
|
||||
? '准备重启'
|
||||
: release.in_progress
|
||||
? '升级中'
|
||||
: '空闲'
|
||||
}
|
||||
variant={
|
||||
release.upgrade_status === 'failed'
|
||||
? 'danger'
|
||||
: release.in_progress
|
||||
? 'warning'
|
||||
: release.upgrade_status === 'succeeded'
|
||||
? 'success'
|
||||
: 'info'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[#0f172a] px-4 py-3">
|
||||
{upgradeLogs.map((log, index) => (
|
||||
<pre
|
||||
key={`${log.created_at}-${index}`}
|
||||
className="overflow-x-auto border-b border-white/8 py-2 font-mono text-[12px] leading-6 text-slate-200 last:border-b-0"
|
||||
>
|
||||
<span className="text-slate-400">
|
||||
{formatLogTimestamp(log.created_at)}
|
||||
</span>
|
||||
<span className="text-slate-500"> | </span>
|
||||
<span className="text-cyan-300">
|
||||
{formatLogLevel(log.level)}
|
||||
</span>
|
||||
<span className="text-slate-500"> | </span>
|
||||
<span className="whitespace-pre-wrap break-all text-slate-100">
|
||||
{log.message}
|
||||
</span>
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无升级日志"
|
||||
/>
|
||||
)}
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
{canUpgrade ? (
|
||||
<AppCard
|
||||
title="手动升级"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<ResourceField
|
||||
label="服务端二进制"
|
||||
hint="支持上传已编译好的当前平台 Server 可执行文件。"
|
||||
>
|
||||
<ResourceInput
|
||||
type="file"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
setSelectedBinary(file);
|
||||
}}
|
||||
disabled={isUploadingBinary || isConfirmingManualUpgrade}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{showConfirmManualUpgradeAction ? (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onConfirmManualUpgrade}
|
||||
disabled={
|
||||
!canConfirmManualUpgrade ||
|
||||
isConfirmingManualUpgrade ||
|
||||
isUploadingBinary ||
|
||||
isUpgrading
|
||||
}
|
||||
>
|
||||
{isConfirmingManualUpgrade ? '升级中...' : '升级'}
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedBinary) {
|
||||
onUploadBinary(selectedBinary);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!selectedBinary ||
|
||||
isUploadingBinary ||
|
||||
isConfirmingManualUpgrade
|
||||
}
|
||||
>
|
||||
{isUploadingBinary
|
||||
? uploadProgress >= 100
|
||||
? '服务端校验中...'
|
||||
: '上传中...'
|
||||
: '上传并检查'}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUploadingBinary ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-[var(--foreground-secondary)]">
|
||||
<span>{uploadPhaseLabel}</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-200"
|
||||
style={{width: `${uploadProgress}%`}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{manualErrorMessage ? (
|
||||
<ErrorState
|
||||
title="手动升级检查失败"
|
||||
description={manualErrorMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!manualErrorMessage && manualStatusMessage ? (
|
||||
<div
|
||||
className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-3 text-sm text-[var(--status-warning-foreground)]">
|
||||
{manualStatusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{uploadedBinary ? (
|
||||
<div
|
||||
className="space-y-4 rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
uploadedBinary.ready_to_upgrade
|
||||
? '可确认升级'
|
||||
: uploadedBinary.has_update
|
||||
? '待确认'
|
||||
: '不可升级'
|
||||
}
|
||||
variant={
|
||||
uploadedBinary.ready_to_upgrade ? 'warning' : 'info'
|
||||
}
|
||||
/>
|
||||
{!uploadedBinary.upgrade_supported ? (
|
||||
<StatusBadge
|
||||
label="当前版本不支持手动升级"
|
||||
variant="danger"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
文件名
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.file_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
上传时间
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.uploaded_at
|
||||
? formatDateTime(uploadedBinary.uploaded_at)
|
||||
: '未知'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
当前版本
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.current_version}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
上传版本
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.detected_version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{uploadedBinary.comparison_message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="尚未上传升级包"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="版本"
|
||||
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级,或上传 Server 二进制确认升级。升级开始后服务会短暂重启。"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
|
||||
<AppCard title="服务端版本">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{currentVersion || 'unknown'}
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={upgradeBadge.label}
|
||||
variant={upgradeBadge.variant}
|
||||
/>
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="最新版本">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{release?.tag_name || '未检查'}
|
||||
</p>
|
||||
{canUpgrade ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
label={selectedChannelLabel}
|
||||
variant={
|
||||
selectedChannel === 'preview' ? 'warning' : 'info'
|
||||
}
|
||||
onClick={toggleChannel}
|
||||
disabled={
|
||||
isChecking || isUpgrading || isUploadingBinary
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onCheck}
|
||||
disabled={isChecking || isUpgrading || isUploadingBinary}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{isChecking ? '检查中...' : '检查'}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isLoading ? <LoadingState /> : null}
|
||||
{!isLoading && releaseErrorMessage ? (
|
||||
<ErrorState title="版本检查失败" description={releaseErrorMessage} />
|
||||
) : null}
|
||||
{!isLoading && !releaseErrorMessage && !release ? (
|
||||
<EmptyState
|
||||
title={`尚未检查${selectedChannelLabel}`}
|
||||
description={`点击“检查${selectedChannelLabel}”后会在这里展示对应 GitHub Release 信息。`}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !releaseErrorMessage && release ? (
|
||||
<AppCard
|
||||
title={`GitHub ${selectedChannelLabel} · ${release.tag_name}`}
|
||||
description={
|
||||
release.published_at
|
||||
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
||||
: '未提供发布时间'
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={release.has_update ? '发现新版本' : '已经是最新版本'}
|
||||
variant={release.has_update ? 'warning' : 'success'}
|
||||
/>
|
||||
{release.prerelease ? (
|
||||
<StatusBadge label="Preview 发布" variant="warning" />
|
||||
) : (
|
||||
<StatusBadge label="正式发布" variant="info" />
|
||||
)}
|
||||
{!release.upgrade_supported ? (
|
||||
<StatusBadge
|
||||
label="当前平台不支持自动升级"
|
||||
variant="danger"
|
||||
/>
|
||||
) : null}
|
||||
{release.in_progress ? (
|
||||
<StatusBadge label="升级任务执行中" variant="warning" />
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(
|
||||
release.body || '暂无更新说明',
|
||||
) as string,
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href={release.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
查看发布详情
|
||||
</a>
|
||||
{canUpgrade ? (
|
||||
<div className="flex justify-end">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onUpgrade}
|
||||
disabled={
|
||||
!release.has_update ||
|
||||
release.in_progress ||
|
||||
isUpgrading ||
|
||||
!release.upgrade_supported ||
|
||||
isUploadingBinary ||
|
||||
isConfirmingManualUpgrade
|
||||
}
|
||||
>
|
||||
{isUpgrading
|
||||
? '升级中...'
|
||||
: release.in_progress
|
||||
? '升级中...'
|
||||
: selectedChannel === 'preview'
|
||||
? '升级预览版'
|
||||
: '升级正式版'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
{release ? (
|
||||
<AppCard title="升级日志">
|
||||
{upgradeLogs.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
release.upgrade_status === 'failed'
|
||||
? '升级失败'
|
||||
: release.upgrade_status === 'succeeded'
|
||||
? '准备重启'
|
||||
: release.in_progress
|
||||
? '升级中'
|
||||
: '空闲'
|
||||
}
|
||||
variant={
|
||||
release.upgrade_status === 'failed'
|
||||
? 'danger'
|
||||
: release.in_progress
|
||||
? 'warning'
|
||||
: release.upgrade_status === 'succeeded'
|
||||
? 'success'
|
||||
: 'info'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[#0f172a] px-4 py-3">
|
||||
{upgradeLogs.map((log, index) => (
|
||||
<pre
|
||||
key={`${log.created_at}-${index}`}
|
||||
className="overflow-x-auto border-b border-white/8 py-2 font-mono text-[12px] leading-6 text-slate-200 last:border-b-0"
|
||||
>
|
||||
<span className="text-slate-400">
|
||||
{formatLogTimestamp(log.created_at)}
|
||||
</span>
|
||||
<span className="text-slate-500"> | </span>
|
||||
<span className="text-cyan-300">
|
||||
{formatLogLevel(log.level)}
|
||||
</span>
|
||||
<span className="text-slate-500"> | </span>
|
||||
<span className="break-all whitespace-pre-wrap text-slate-100">
|
||||
{log.message}
|
||||
</span>
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="暂无升级日志" />
|
||||
)}
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
{canUpgrade ? (
|
||||
<AppCard title="手动升级">
|
||||
<div className="space-y-4">
|
||||
<ResourceField
|
||||
label="服务端二进制"
|
||||
hint="支持上传已编译好的当前平台 Server 可执行文件。"
|
||||
>
|
||||
<ResourceInput
|
||||
type="file"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
setSelectedBinary(file);
|
||||
}}
|
||||
disabled={isUploadingBinary || isConfirmingManualUpgrade}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{showConfirmManualUpgradeAction ? (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onConfirmManualUpgrade}
|
||||
disabled={
|
||||
!canConfirmManualUpgrade ||
|
||||
isConfirmingManualUpgrade ||
|
||||
isUploadingBinary ||
|
||||
isUpgrading
|
||||
}
|
||||
>
|
||||
{isConfirmingManualUpgrade ? '升级中...' : '升级'}
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedBinary) {
|
||||
onUploadBinary(selectedBinary);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!selectedBinary ||
|
||||
isUploadingBinary ||
|
||||
isConfirmingManualUpgrade
|
||||
}
|
||||
>
|
||||
{isUploadingBinary
|
||||
? uploadProgress >= 100
|
||||
? '服务端校验中...'
|
||||
: '上传中...'
|
||||
: '上传并检查'}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUploadingBinary ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-[var(--foreground-secondary)]">
|
||||
<span>{uploadPhaseLabel}</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-200"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{manualErrorMessage ? (
|
||||
<ErrorState
|
||||
title="手动升级检查失败"
|
||||
description={manualErrorMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!manualErrorMessage && manualStatusMessage ? (
|
||||
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-3 text-sm text-[var(--status-warning-foreground)]">
|
||||
{manualStatusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{uploadedBinary ? (
|
||||
<div className="space-y-4 rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
uploadedBinary.ready_to_upgrade
|
||||
? '可确认升级'
|
||||
: uploadedBinary.has_update
|
||||
? '待确认'
|
||||
: '不可升级'
|
||||
}
|
||||
variant={
|
||||
uploadedBinary.ready_to_upgrade ? 'warning' : 'info'
|
||||
}
|
||||
/>
|
||||
{!uploadedBinary.upgrade_supported ? (
|
||||
<StatusBadge
|
||||
label="当前版本不支持手动升级"
|
||||
variant="danger"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
文件名
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.file_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
上传时间
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.uploaded_at
|
||||
? formatDateTime(uploadedBinary.uploaded_at)
|
||||
: '未知'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
当前版本
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.current_version}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
上传版本
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.detected_version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{uploadedBinary.comparison_message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="尚未上传升级包" />
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ManageUserAction,
|
||||
ManageUserResult,
|
||||
UserItem,
|
||||
UserMutationPayload,
|
||||
} from '@/features/users/types';
|
||||
|
||||
export function getUsers(page: number) {
|
||||
return apiRequest<UserItem[]>(`/user/?p=${page}`);
|
||||
}
|
||||
|
||||
export function searchUsers(keyword: string) {
|
||||
return apiRequest<UserItem[]>(`/user/search?keyword=${encodeURIComponent(keyword)}`);
|
||||
}
|
||||
|
||||
export function getUser(id: number) {
|
||||
return apiRequest<UserItem>(`/user/${id}`);
|
||||
}
|
||||
|
||||
export function createUser(payload: UserMutationPayload) {
|
||||
return apiRequest<void>('/user/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ManageUserAction,
|
||||
ManageUserResult,
|
||||
UserItem,
|
||||
UserMutationPayload,
|
||||
} from '@/features/users/types';
|
||||
|
||||
export function getUsers(page: number) {
|
||||
return apiRequest<UserItem[]>(`/user/?p=${page}`);
|
||||
}
|
||||
|
||||
export function searchUsers(keyword: string) {
|
||||
return apiRequest<UserItem[]>(
|
||||
`/user/search?keyword=${encodeURIComponent(keyword)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getUser(id: number) {
|
||||
return apiRequest<UserItem>(`/user/${id}`);
|
||||
}
|
||||
|
||||
export function createUser(payload: UserMutationPayload) {
|
||||
return apiRequest<void>('/user/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUser(payload: UserMutationPayload & { id: number }) {
|
||||
return apiRequest<void>('/user/update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function manageUser(username: string, action: ManageUserAction) {
|
||||
return apiRequest<ManageUserResult>('/user/manage', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, action }),
|
||||
});
|
||||
}
|
||||
|
||||
export function manageUser(username: string, action: ManageUserAction) {
|
||||
return apiRequest<ManageUserResult>('/user/manage', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, action }),
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,29 @@
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: number;
|
||||
status: number;
|
||||
email?: string;
|
||||
github_id?: string;
|
||||
wechat_id?: string;
|
||||
}
|
||||
|
||||
export interface UserMutationPayload {
|
||||
id?: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type ManageUserAction = 'promote' | 'demote' | 'delete' | 'disable' | 'enable';
|
||||
|
||||
export interface ManageUserResult {
|
||||
role: number;
|
||||
status: number;
|
||||
}
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: number;
|
||||
status: number;
|
||||
email?: string;
|
||||
github_id?: string;
|
||||
wechat_id?: string;
|
||||
}
|
||||
|
||||
export interface UserMutationPayload {
|
||||
id?: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type ManageUserAction =
|
||||
| 'promote'
|
||||
| 'demote'
|
||||
| 'delete'
|
||||
| 'disable'
|
||||
| 'enable';
|
||||
|
||||
export interface ManageUserResult {
|
||||
role: number;
|
||||
status: number;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,10 @@ import {
|
||||
getTlsCertificate,
|
||||
getTlsCertificateContent,
|
||||
} from '@/features/tls-certificates/api/tls-certificates';
|
||||
import { getCertificateStatus, getErrorMessage } from '@/features/websites/utils';
|
||||
import {
|
||||
getCertificateStatus,
|
||||
getErrorMessage,
|
||||
} from '@/features/websites/utils';
|
||||
import {
|
||||
CodeBlock,
|
||||
DangerButton,
|
||||
@@ -81,11 +84,7 @@ export function CertificateDetailModal({
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
关闭
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
disabled={!certificate}
|
||||
>
|
||||
<PrimaryButton type="button" onClick={onEdit} disabled={!certificate}>
|
||||
编辑证书
|
||||
</PrimaryButton>
|
||||
<DangerButton
|
||||
@@ -112,15 +111,12 @@ export function CertificateDetailModal({
|
||||
)}
|
||||
/>
|
||||
) : !certificate || !content ? (
|
||||
<EmptyState
|
||||
title="证书不存在"
|
||||
description="当前证书可能已被删除。"
|
||||
/>
|
||||
<EmptyState title="证书不存在" description="当前证书可能已被删除。" />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
证书名称
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
@@ -128,7 +124,7 @@ export function CertificateDetailModal({
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
状态
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
@@ -138,7 +134,7 @@ export function CertificateDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
生效时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
@@ -146,7 +142,7 @@ export function CertificateDetailModal({
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
到期时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
@@ -156,7 +152,7 @@ export function CertificateDetailModal({
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
备注
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[var(--foreground-primary)]">
|
||||
@@ -180,7 +176,7 @@ export function CertificateDetailModal({
|
||||
复制
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<CodeBlock className="max-h-56 overflow-y-auto whitespace-pre-wrap break-all">
|
||||
<CodeBlock className="max-h-56 overflow-y-auto break-all whitespace-pre-wrap">
|
||||
{content.cert_pem}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
@@ -199,7 +195,7 @@ export function CertificateDetailModal({
|
||||
复制
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<CodeBlock className="max-h-56 overflow-y-auto whitespace-pre-wrap break-all">
|
||||
<CodeBlock className="max-h-56 overflow-y-auto break-all whitespace-pre-wrap">
|
||||
{content.key_pem}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
@@ -52,8 +52,9 @@ export function CertificateImportModal({
|
||||
const queryClient = useQueryClient();
|
||||
const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [fileForm, setFileForm] =
|
||||
useState<FileImportFormValues>(defaultFileImportValues);
|
||||
const [fileForm, setFileForm] = useState<FileImportFormValues>(
|
||||
defaultFileImportValues,
|
||||
);
|
||||
const [certFile, setCertFile] = useState<File | null>(null);
|
||||
const [keyFile, setKeyFile] = useState<File | null>(null);
|
||||
const [fileInputNonce, setFileInputNonce] = useState(0);
|
||||
@@ -280,9 +281,7 @@ export function CertificateImportModal({
|
||||
<ResourceField
|
||||
label="私钥文件"
|
||||
hint={
|
||||
keyFile
|
||||
? `已选择:${keyFile.name}`
|
||||
: '请选择 KEY/PEM 文件'
|
||||
keyFile ? `已选择:${keyFile.name}` : '请选择 KEY/PEM 文件'
|
||||
}
|
||||
>
|
||||
<ResourceInput
|
||||
|
||||
@@ -143,13 +143,16 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
|
||||
enabled: Boolean(wafRouteID),
|
||||
});
|
||||
const wafMutation = useMutation({
|
||||
mutationFn: (ids: number[]) => replaceWAFSiteRuleGroups(wafRouteID ?? 0, ids),
|
||||
mutationFn: (ids: number[]) =>
|
||||
replaceWAFSiteRuleGroups(wafRouteID ?? 0, ids),
|
||||
onSuccess: async (view) => {
|
||||
setWafSelectedIDs(view.applied_ids);
|
||||
setFeedback({ tone: 'success', message: '网站 WAF 规则组已更新。' });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['waf'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['config-versions', 'diff'],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -90,7 +90,7 @@ export function WebsiteEditorModal({
|
||||
});
|
||||
|
||||
const currentCertificate = watchedCertId
|
||||
? certificates.find((item) => item.id === Number(watchedCertId)) ?? null
|
||||
? (certificates.find((item) => item.id === Number(watchedCertId)) ?? null)
|
||||
: null;
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
@@ -215,7 +215,7 @@ export function WebsiteEditorModal({
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
当前域名
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
@@ -223,7 +223,7 @@ export function WebsiteEditorModal({
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
匹配类型
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
@@ -235,12 +235,14 @@ export function WebsiteEditorModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
当前证书
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<StatusBadge
|
||||
label={currentCertificate ? currentCertificate.name : '未绑定证书'}
|
||||
label={
|
||||
currentCertificate ? currentCertificate.name : '未绑定证书'
|
||||
}
|
||||
variant={currentCertificate ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +136,7 @@ export function WebsitesPage() {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{domains.map((domain) => {
|
||||
const certificate = domain.cert_id
|
||||
? certificateMap.get(domain.cert_id) ?? null
|
||||
? (certificateMap.get(domain.cert_id) ?? null)
|
||||
: null;
|
||||
const matchType = getMatchTypeMeta(domain.domain);
|
||||
|
||||
@@ -168,7 +168,7 @@ export function WebsitesPage() {
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-1">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
绑定证书
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
|
||||
@@ -97,4 +97,3 @@ export const defaultAcmeApplyValues: AcmeApplyFormValues = {
|
||||
dns2: '',
|
||||
remark: '',
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user