[优化] 界面优化

This commit is contained in:
ryan
2026-03-11 21:51:32 +08:00
parent 0edc024cbd
commit c0ec718563
8 changed files with 536 additions and 623 deletions
@@ -1,7 +1,7 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
@@ -28,7 +28,7 @@ import {
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
import { formatDateTime } from '@/lib/utils/date';
const versionsQueryKey = ['config-versions'];
@@ -249,7 +249,7 @@ export function ConfigVersionsPage() {
},
});
const handleOpenPublishPreview = async () => {
const handleOpenPublishPreview = async () => {
setFeedback(null);
setIsPreviewLoading(true);
@@ -280,20 +280,20 @@ export function ConfigVersionsPage() {
return (
<div className='space-y-6'>
<PageHeader
title='配置版本'
description='查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。'
action={
<PrimaryButton type='button' onClick={handleOpenPublishPreview} disabled={isPreviewLoading}>
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
</PrimaryButton>
}
/>
<PageHeader
title='配置版本'
description='查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。'
action={
<PrimaryButton type='button' onClick={handleOpenPublishPreview} disabled={isPreviewLoading}>
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
</PrimaryButton>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<div className='grid gap-6'>
{publishPreview ? (
<div className='grid gap-6'>
{publishPreview ? (
<PublishPreviewCard
preview={publishPreview.preview}
diff={publishPreview.diff}
@@ -301,18 +301,10 @@ export function ConfigVersionsPage() {
onConfirm={() => publishMutation.mutate()}
onCancel={() => setPublishPreview(null)}
/>
) : (
<AppCard title='发布建议' description='建议先查看变更摘要,再决定是否发布为新的激活版本。'>
<div className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
<p>1. 使用“预览并发布”查看新增、删除和修改的域名清单。</p>
<p>2. 确认渲染出的 Nginx 配置与支持文件内容符合预期。</p>
<p>3. 若需要回滚,可在下方历史版本列表中重新激活旧版本。</p>
</div>
</AppCard>
)}
) : null}
</div>
<AppCard title='历史版本' description='支持查看快照详情,并在必要时将旧版本重新设为当前激活版本。'>
<AppCard title='历史版本'>
{versionsQuery.isLoading ? (
<LoadingState />
) : versionsQuery.isError ? (
@@ -219,36 +219,6 @@ export function DashboardOverview() {
return (
<div className='space-y-6'>
<AppCard
title='控制台仪表盘'
description='首页统一承接节点、发布、域名、证书、反向代理和用户六个模块的核心总览,避免在各页面重复扫一遍摘要。'
action={<StatusBadge label='实时聚合概览' variant='success' />}
>
<div className='grid gap-4 md:grid-cols-3'>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>信息架构</p>
<p className='mt-2 text-base font-semibold text-[var(--foreground-primary)]'>首页负责总览</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
模块页保留列表、表单和具体操作,摘要统一回收到仪表盘。
</p>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>导航效率</p>
<p className='mt-2 text-base font-semibold text-[var(--foreground-primary)]'>顶栏与侧栏已精简</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
顶栏只保留版本、主题切换和用户入口,侧栏宽度与按钮高度同步压缩。
</p>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>响应式</p>
<p className='mt-2 text-base font-semibold text-[var(--foreground-primary)]'>小屏可正常展开侧栏</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
小于 1000px 时切换为抽屉侧栏,不再出现按钮可点但导航不可见的问题。
</p>
</div>
</div>
</AppCard>
<div className='grid gap-6 xl:grid-cols-2'>
{dashboardCards.map((card) => (
<SummaryCard key={card.title} {...card} />
@@ -228,7 +228,6 @@ export function ManagedDomainsPage() {
<AppCard
title='域名规则列表'
description='支持快速查看绑定证书、启用状态与最近更新时间。'
action={
<SecondaryButton
type='button'
@@ -1,20 +1,20 @@
'use client';
import Link from 'next/link';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
'use client';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import Link from 'next/link';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
createNode,
deleteNode,
@@ -157,14 +157,14 @@ async function copyToClipboard(value: string) {
await navigator.clipboard.writeText(value);
}
export function NodesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<NodeItem | null>(null);
const [serverUrl, setServerUrl] = useState('');
const [, setRefreshTick] = useState(0);
export function NodesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<NodeItem | null>(null);
const [serverUrl, setServerUrl] = useState('');
const [, setRefreshTick] = useState(0);
const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema),
@@ -209,15 +209,15 @@ export function NodesPage() {
return editingNodeId ? updateNode(editingNodeId, payload) : createNode(payload);
},
onSuccess: async (node) => {
setFeedback({
tone: 'success',
message: editingNodeId ? '节点已更新。' : '节点已创建。',
});
setEditingNodeId(null);
setIsEditorOpen(false);
setSelectedNode(node);
form.reset(defaultValues);
await Promise.all([
setFeedback({
tone: 'success',
message: editingNodeId ? '节点已更新。' : '节点已创建。',
});
setEditingNodeId(null);
setIsEditorOpen(false);
setSelectedNode(node);
form.reset(defaultValues);
await Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({ queryKey: bootstrapQueryKey }),
]);
@@ -276,30 +276,30 @@ export function NodesPage() {
return nodes.find((item) => item.id === selectedNode.id) ?? selectedNode;
}, [nodes, selectedNode]);
const handleReset = () => {
setFeedback(null);
setEditingNodeId(null);
setIsEditorOpen(false);
form.reset(defaultValues);
};
const handleCreate = () => {
setFeedback(null);
setEditingNodeId(null);
form.reset(defaultValues);
setIsEditorOpen(true);
};
const handleEdit = (node: NodeItem) => {
setFeedback(null);
setEditingNodeId(node.id);
setSelectedNode(node);
form.reset({
name: node.name,
auto_update_enabled: node.auto_update_enabled,
});
setIsEditorOpen(true);
};
const handleReset = () => {
setFeedback(null);
setEditingNodeId(null);
setIsEditorOpen(false);
form.reset(defaultValues);
};
const handleCreate = () => {
setFeedback(null);
setEditingNodeId(null);
form.reset(defaultValues);
setIsEditorOpen(true);
};
const handleEdit = (node: NodeItem) => {
setFeedback(null);
setEditingNodeId(node.id);
setSelectedNode(node);
form.reset({
name: node.name,
auto_update_enabled: node.auto_update_enabled,
});
setIsEditorOpen(true);
};
const handleDelete = (node: NodeItem) => {
if (
@@ -345,76 +345,30 @@ export function NodesPage() {
? buildNodeInstallCommand(normalizedServerUrl, selectedNodeFromList.agent_token)
: '';
return (
<>
<div className='space-y-6'>
<PageHeader
title='节点管理'
description='查看节点在线状态、最近心跳、部署命令与 Agent 更新动作,并支持预创建节点。'
action={
<>
<SecondaryButton type='button' onClick={handleCreate}>
新增节点
</SecondaryButton>
<Link
href='/apply-log'
className='inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
>
应用记录
</Link>
</>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
<AppCard
title='Discovery Token 部署'
description='适用于新节点首次接入,Agent 会自动注册并换取节点专属 Token。'
action={
<div className='flex flex-wrap gap-2'>
<SecondaryButton type='button' onClick={handleRotateToken} disabled={rotateMutation.isPending}>
{rotateMutation.isPending ? '生成中...' : '重新生成 Token'}
</SecondaryButton>
{discoveryCommand ? (
<PrimaryButton
type='button'
onClick={() => void handleCopy(discoveryCommand, 'Discovery 部署命令已复制。')}
>
复制命令
</PrimaryButton>
) : null}
</div>
}
>
{bootstrapQuery.isLoading ? (
<LoadingState />
) : bootstrapQuery.isError ? (
<ErrorState title='Discovery Token 加载失败' description={getErrorMessage(bootstrapQuery.error)} />
) : (
<div className='space-y-4'>
<ResourceField label='Server URL' hint='默认使用当前管理端来源,可按需改为对外访问地址。'>
<ResourceInput value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} />
</ResourceField>
return (
<>
<div className='space-y-6'>
<PageHeader
title='节点管理'
description='查看节点在线状态、最近心跳、部署命令与 Agent 更新动作,并支持预创建节点。'
action={
<>
<SecondaryButton type='button' onClick={handleCreate}>
新增节点
</SecondaryButton>
<Link
href='/apply-log'
className='inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
>
应用记录
</Link>
</>
}
/>
<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.2em] text-[var(--foreground-muted)]'>Discovery Token</p>
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
{bootstrapToken || '暂无'}
</p>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm leading-6 text-[var(--foreground-secondary)]'>
创建节点后也可以使用节点专属 Token 安装,这种方式适合预分配节点名与更新策略。
</div>
</div>
{discoveryCommand ? <CodeBlock className='whitespace-pre-wrap'>{discoveryCommand}</CodeBlock> : null}
</div>
)}
</AppCard>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
<AppCard
title='节点专属部署命令'
description='适用于已在管理端预创建节点的场景,可避免首次接入时匿名自动命名。'
@@ -458,18 +412,18 @@ export function NodesPage() {
</AppCard>
</div>
<AppCard
title='节点列表'
description='展示心跳、最近应用结果、版本信息与更新状态,可直接执行管理动作。'
action={
<SecondaryButton
type='button'
onClick={() => void queryClient.invalidateQueries({ queryKey: nodesQueryKey })}
>
刷新列表
</SecondaryButton>
}
>
<AppCard
title='节点列表'
description='展示心跳、最近应用结果、版本信息与更新状态,可直接执行管理动作。'
action={
<SecondaryButton
type='button'
onClick={() => void queryClient.invalidateQueries({ queryKey: nodesQueryKey })}
>
刷新列表
</SecondaryButton>
}
>
{nodesQuery.isLoading ? (
<LoadingState />
) : nodesQuery.isError ? (
@@ -594,41 +548,41 @@ export function NodesPage() {
</div>
)}
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleReset}
title={editingNodeId ? '编辑节点' : '新增节点'}
description='预创建节点后会立即生成节点专属 Token,可继续复制专属安装命令。'
footer={
<div className='flex flex-wrap justify-end gap-3'>
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
取消
</SecondaryButton>
<PrimaryButton type='submit' form='node-editor-form' disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'}
</PrimaryButton>
</div>
}
>
<form id='node-editor-form' className='space-y-5' onSubmit={handleSubmit}>
<ResourceField label='节点名' hint='示例:shanghai-edge-1' error={form.formState.errors.name?.message}>
<ResourceInput placeholder='shanghai-edge-1' {...form.register('name')} />
</ResourceField>
<ToggleField
label='启用自动更新'
description='开启后 Agent 心跳返回会提示节点自动执行自更新。'
checked={watchedAutoUpdate}
onChange={(checked) =>
form.setValue('auto_update_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</form>
</AppModal>
</>
);
}
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleReset}
title={editingNodeId ? '编辑节点' : '新增节点'}
description='预创建节点后会立即生成节点专属 Token,可继续复制专属安装命令。'
footer={
<div className='flex flex-wrap justify-end gap-3'>
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
取消
</SecondaryButton>
<PrimaryButton type='submit' form='node-editor-form' disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'}
</PrimaryButton>
</div>
}
>
<form id='node-editor-form' className='space-y-5' onSubmit={handleSubmit}>
<ResourceField label='节点名' hint='示例:shanghai-edge-1' error={form.formState.errors.name?.message}>
<ResourceInput placeholder='shanghai-edge-1' {...form.register('name')} />
</ResourceField>
<ToggleField
label='启用自动更新'
description='开启后 Agent 心跳返回会提示节点自动执行自更新。'
checked={watchedAutoUpdate}
onChange={(checked) =>
form.setValue('auto_update_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</form>
</AppModal>
</>
);
}
@@ -7,13 +7,13 @@ import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { publishConfigVersion } from '@/features/config-versions/api/config-versions';
import {
createProxyRoute,
@@ -212,13 +212,13 @@ function getMatchMessage(
return '未找到匹配证书,可继续手动选择。';
}
export function ProxyRoutesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [matchResult, setMatchResult] = useState<ManagedDomainMatchResult | null>(null);
const [isMatching, setIsMatching] = useState(false);
export function ProxyRoutesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [matchResult, setMatchResult] = useState<ManagedDomainMatchResult | null>(null);
const [isMatching, setIsMatching] = useState(false);
const form = useForm<ProxyRouteFormValues>({
resolver: zodResolver(proxyRouteSchema),
@@ -253,13 +253,13 @@ export function ProxyRoutesPage() {
? updateProxyRoute(editingRouteId, payload)
: createProxyRoute(payload);
},
onSuccess: async () => {
setFeedback({ tone: 'success', message: editingRouteId ? '规则已更新。' : '规则已创建。' });
setEditingRouteId(null);
setIsEditorOpen(false);
setMatchResult(null);
form.reset(defaultValues);
await queryClient.invalidateQueries({ queryKey: routesQueryKey });
onSuccess: async () => {
setFeedback({ tone: 'success', message: editingRouteId ? '规则已更新。' : '规则已创建。' });
setEditingRouteId(null);
setIsEditorOpen(false);
setMatchResult(null);
form.reset(defaultValues);
await queryClient.invalidateQueries({ queryKey: routesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
@@ -339,34 +339,34 @@ export function ProxyRoutesPage() {
[certificates],
);
const handleReset = () => {
setFeedback(null);
setEditingRouteId(null);
setIsEditorOpen(false);
setMatchResult(null);
form.reset(defaultValues);
};
const handleCreate = () => {
setFeedback(null);
setEditingRouteId(null);
setMatchResult(null);
form.reset(defaultValues);
setIsEditorOpen(true);
};
const handleReset = () => {
setFeedback(null);
setEditingRouteId(null);
setIsEditorOpen(false);
setMatchResult(null);
form.reset(defaultValues);
};
const handleCreate = () => {
setFeedback(null);
setEditingRouteId(null);
setMatchResult(null);
form.reset(defaultValues);
setIsEditorOpen(true);
};
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
const handleEdit = (route: ProxyRouteItem) => {
setFeedback(null);
setEditingRouteId(route.id);
setMatchResult(null);
form.reset(toFormValues(route));
setIsEditorOpen(true);
};
const handleEdit = (route: ProxyRouteItem) => {
setFeedback(null);
setEditingRouteId(route.id);
setMatchResult(null);
form.reset(toFormValues(route));
setIsEditorOpen(true);
};
const handleDelete = (route: ProxyRouteItem) => {
if (!window.confirm(`确认删除规则 ${route.domain} 吗?`)) {
@@ -388,27 +388,27 @@ export function ProxyRoutesPage() {
const routes = routesQuery.data || [];
return (
<>
<div className='space-y-6'>
<PageHeader
title='反代规则'
description='维护域名到源站的映射、HTTPS 证书绑定与自定义请求头,并可直接触发配置发布。'
action={
<>
<PrimaryButton type='button' onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
{publishMutation.isPending ? '发布中...' : '发布当前规则'}
</PrimaryButton>
<SecondaryButton type='button' onClick={handleCreate}>
新增规则
</SecondaryButton>
</>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<AppCard title='规则列表' description='支持查看启用状态、HTTPS 配置、自定义请求头数量及最近更新时间。'>
return (
<>
<div className='space-y-6'>
<PageHeader
title='反代规则'
description='维护域名到源站的映射、HTTPS 证书绑定与自定义请求头,并可直接触发配置发布。'
action={
<>
<PrimaryButton type='button' onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
{publishMutation.isPending ? '发布中...' : '发布当前规则'}
</PrimaryButton>
<SecondaryButton type='button' onClick={handleCreate}>
新增规则
</SecondaryButton>
</>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<AppCard title='规则列表'>
{routesQuery.isLoading ? (
<LoadingState />
) : routesQuery.isError ? (
@@ -494,184 +494,184 @@ export function ProxyRoutesPage() {
</div>
)}
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleReset}
title={editingRouteId ? '编辑规则' : '新增规则'}
description='新增或修改反代规则后,可直接回到列表页继续发布。'
size='xl'
footer={
<div className='flex flex-wrap justify-end gap-3'>
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
取消
</SecondaryButton>
<PrimaryButton type='submit' form='proxy-route-editor-form' disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : editingRouteId ? '保存修改' : '新增规则'}
</PrimaryButton>
</div>
}
>
<form id='proxy-route-editor-form' className='space-y-5' onSubmit={handleSubmit}>
<div className='grid gap-4 md:grid-cols-2'>
<ResourceField
label='域名'
hint='示例:example.com'
error={form.formState.errors.domain?.message}
>
<ResourceInput placeholder='example.com' {...form.register('domain')} />
</ResourceField>
<ResourceField
label='源站地址'
hint='示例:https://origin.internal'
error={form.formState.errors.origin_url?.message}
>
<ResourceInput placeholder='https://origin.internal' {...form.register('origin_url')} />
</ResourceField>
</div>
<div className='grid gap-4 lg:grid-cols-2'>
<ToggleField
label='启用规则'
description='关闭后该规则不会参与配置渲染与发布。'
checked={watchedEnabled}
onChange={(checked) => form.setValue('enabled', checked, { shouldDirty: true })}
/>
<ToggleField
label='启用 HTTPS'
description='启用后必须关联 TLS 证书,可选择是否将 HTTP 自动重定向到 HTTPS。'
checked={watchedEnableHttps}
onChange={(checked) => {
form.setValue('enable_https', checked, { shouldDirty: true, shouldValidate: true });
if (!checked) {
form.setValue('cert_id', '', { shouldDirty: true, shouldValidate: true });
form.setValue('redirect_http', false, { shouldDirty: true, shouldValidate: true });
}
}}
/>
</div>
<div className='grid gap-4 lg:grid-cols-[1.2fr_0.8fr]'>
<ResourceField
label='TLS 证书'
hint='启用 HTTPS 后可自动推荐匹配证书,也支持手动选择。'
error={form.formState.errors.cert_id?.message}
>
<ResourceSelect
value={watchedCertId}
disabled={!watchedEnableHttps || certificatesQuery.isLoading}
onChange={(event) =>
form.setValue('cert_id', event.target.value, {
shouldDirty: true,
shouldValidate: true,
})
}
>
<option value=''>请选择证书</option>
{certificates.map((certificate) => (
<option key={certificate.id} value={certificate.id}>
{buildCertificateLabel(certificate)}
</option>
))}
</ResourceSelect>
</ResourceField>
<ToggleField
label='HTTP 跳转 HTTPS'
description='仅在启用 HTTPS 后可开启。开启后会将 HTTP 请求重定向到 HTTPS。'
checked={watchedRedirectHttp}
disabled={!watchedEnableHttps}
onChange={(checked) =>
form.setValue('redirect_http', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>自定义请求头</p>
<p className='mt-1 text-xs leading-5 text-[var(--foreground-secondary)]'>
可为空。若填写,请保证 Header 名称合法且不包含换行。
</p>
</div>
<SecondaryButton
type='button'
onClick={() => append({ key: '', value: '' })}
className='px-3 py-2 text-xs'
>
添加请求头
</SecondaryButton>
</div>
<div className='mt-4 space-y-4'>
{fields.map((field, index) => (
<div key={field.id} className='grid gap-3 md:grid-cols-[1fr_1fr_auto]'>
<ResourceField
label={index === 0 ? 'Header 名称' : `Header 名称 ${index + 1}`}
error={form.formState.errors.custom_headers?.[index]?.key?.message}
>
<ResourceInput
placeholder='X-Trace-Id'
{...form.register(`custom_headers.${index}.key`)}
/>
</ResourceField>
<ResourceField
label={index === 0 ? 'Header 值' : `Header 值 ${index + 1}`}
error={form.formState.errors.custom_headers?.[index]?.value?.message}
>
<ResourceInput
placeholder='$request_id'
{...form.register(`custom_headers.${index}.value`)}
/>
</ResourceField>
<div className='flex items-end'>
<DangerButton
type='button'
onClick={() => handleRemoveHeader(index)}
className='w-full px-3 py-3 text-xs md:w-auto'
>
删除
</DangerButton>
</div>
</div>
))}
</div>
</div>
<AppCard title='证书匹配提示' description='根据域名自动匹配托管证书,优先使用精确匹配规则。'>
<div className='space-y-3'>
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
{getMatchMessage(matchResult, isMatching, watchedDomain, watchedEnableHttps)}
</p>
{matchResult?.matched && matchResult.candidates.length > 1 ? (
<div className='flex flex-wrap gap-2'>
{matchResult.candidates.map((candidate) => (
<StatusBadge
key={`${candidate.managed_domain_id}-${candidate.certificate_id}`}
label={`${candidate.domain} → ${candidate.certificate_name}`}
variant={candidate.match_type === 'exact' ? 'success' : 'info'}
/>
))}
</div>
) : null}
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm text-[var(--foreground-secondary)]'>
当前可选证书:{certificatesQuery.isLoading ? '加载中...' : `${certificates.length} 张`}
</div>
</div>
</AppCard>
<ResourceField
label='备注'
hint='可选,便于标记用途、责任人或特殊说明。'
error={form.formState.errors.remark?.message}
>
<ResourceTextarea placeholder='例如:主站生产流量入口' {...form.register('remark')} />
</ResourceField>
</form>
</AppModal>
</>
);
}
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleReset}
title={editingRouteId ? '编辑规则' : '新增规则'}
description='新增或修改反代规则后,可直接回到列表页继续发布。'
size='xl'
footer={
<div className='flex flex-wrap justify-end gap-3'>
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
取消
</SecondaryButton>
<PrimaryButton type='submit' form='proxy-route-editor-form' disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : editingRouteId ? '保存修改' : '新增规则'}
</PrimaryButton>
</div>
}
>
<form id='proxy-route-editor-form' className='space-y-5' onSubmit={handleSubmit}>
<div className='grid gap-4 md:grid-cols-2'>
<ResourceField
label='域名'
hint='示例:example.com'
error={form.formState.errors.domain?.message}
>
<ResourceInput placeholder='example.com' {...form.register('domain')} />
</ResourceField>
<ResourceField
label='源站地址'
hint='示例:https://origin.internal'
error={form.formState.errors.origin_url?.message}
>
<ResourceInput placeholder='https://origin.internal' {...form.register('origin_url')} />
</ResourceField>
</div>
<div className='grid gap-4 lg:grid-cols-2'>
<ToggleField
label='启用规则'
description='关闭后该规则不会参与配置渲染与发布。'
checked={watchedEnabled}
onChange={(checked) => form.setValue('enabled', checked, { shouldDirty: true })}
/>
<ToggleField
label='启用 HTTPS'
description='启用后必须关联 TLS 证书,可选择是否将 HTTP 自动重定向到 HTTPS。'
checked={watchedEnableHttps}
onChange={(checked) => {
form.setValue('enable_https', checked, { shouldDirty: true, shouldValidate: true });
if (!checked) {
form.setValue('cert_id', '', { shouldDirty: true, shouldValidate: true });
form.setValue('redirect_http', false, { shouldDirty: true, shouldValidate: true });
}
}}
/>
</div>
<div className='grid gap-4 lg:grid-cols-[1.2fr_0.8fr]'>
<ResourceField
label='TLS 证书'
hint='启用 HTTPS 后可自动推荐匹配证书,也支持手动选择。'
error={form.formState.errors.cert_id?.message}
>
<ResourceSelect
value={watchedCertId}
disabled={!watchedEnableHttps || certificatesQuery.isLoading}
onChange={(event) =>
form.setValue('cert_id', event.target.value, {
shouldDirty: true,
shouldValidate: true,
})
}
>
<option value=''>请选择证书</option>
{certificates.map((certificate) => (
<option key={certificate.id} value={certificate.id}>
{buildCertificateLabel(certificate)}
</option>
))}
</ResourceSelect>
</ResourceField>
<ToggleField
label='HTTP 跳转 HTTPS'
description='仅在启用 HTTPS 后可开启。开启后会将 HTTP 请求重定向到 HTTPS。'
checked={watchedRedirectHttp}
disabled={!watchedEnableHttps}
onChange={(checked) =>
form.setValue('redirect_http', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>自定义请求头</p>
<p className='mt-1 text-xs leading-5 text-[var(--foreground-secondary)]'>
可为空。若填写,请保证 Header 名称合法且不包含换行。
</p>
</div>
<SecondaryButton
type='button'
onClick={() => append({ key: '', value: '' })}
className='px-3 py-2 text-xs'
>
添加请求头
</SecondaryButton>
</div>
<div className='mt-4 space-y-4'>
{fields.map((field, index) => (
<div key={field.id} className='grid gap-3 md:grid-cols-[1fr_1fr_auto]'>
<ResourceField
label={index === 0 ? 'Header 名称' : `Header 名称 ${index + 1}`}
error={form.formState.errors.custom_headers?.[index]?.key?.message}
>
<ResourceInput
placeholder='X-Trace-Id'
{...form.register(`custom_headers.${index}.key`)}
/>
</ResourceField>
<ResourceField
label={index === 0 ? 'Header 值' : `Header 值 ${index + 1}`}
error={form.formState.errors.custom_headers?.[index]?.value?.message}
>
<ResourceInput
placeholder='$request_id'
{...form.register(`custom_headers.${index}.value`)}
/>
</ResourceField>
<div className='flex items-end'>
<DangerButton
type='button'
onClick={() => handleRemoveHeader(index)}
className='w-full px-3 py-3 text-xs md:w-auto'
>
删除
</DangerButton>
</div>
</div>
))}
</div>
</div>
<AppCard title='证书匹配提示' description='根据域名自动匹配托管证书,优先使用精确匹配规则。'>
<div className='space-y-3'>
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
{getMatchMessage(matchResult, isMatching, watchedDomain, watchedEnableHttps)}
</p>
{matchResult?.matched && matchResult.candidates.length > 1 ? (
<div className='flex flex-wrap gap-2'>
{matchResult.candidates.map((candidate) => (
<StatusBadge
key={`${candidate.managed_domain_id}-${candidate.certificate_id}`}
label={`${candidate.domain} → ${candidate.certificate_name}`}
variant={candidate.match_type === 'exact' ? 'success' : 'info'}
/>
))}
</div>
) : null}
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm text-[var(--foreground-secondary)]'>
当前可选证书:{certificatesQuery.isLoading ? '加载中...' : `${certificates.length} 张`}
</div>
</div>
</AppCard>
<ResourceField
label='备注'
hint='可选,便于标记用途、责任人或特殊说明。'
error={form.formState.errors.remark?.message}
>
<ResourceTextarea placeholder='例如:主站生产流量入口' {...form.register('remark')} />
</ResourceField>
</form>
</AppModal>
</>
);
}
@@ -221,7 +221,6 @@ export function TlsCertificatesPage() {
<AppCard
title='证书列表'
description='展示证书有效期、到期状态与最近更新时间。删除前会提示确认。'
action={
<SecondaryButton
type='button'
@@ -1,20 +1,20 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { useAuth } from '@/components/providers/auth-provider';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { useAuth } from '@/components/providers/auth-provider';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
createUser,
getUser,
@@ -93,15 +93,15 @@ function toPayload(values: UserFormValues): UserMutationPayload {
};
}
export function UsersPage() {
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const [page, setPage] = useState(0);
const [searchInput, setSearchInput] = useState('');
const [searchKeyword, setSearchKeyword] = useState('');
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
export function UsersPage() {
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const [page, setPage] = useState(0);
const [searchInput, setSearchInput] = useState('');
const [searchKeyword, setSearchKeyword] = useState('');
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
@@ -129,15 +129,15 @@ export function UsersPage() {
enabled: isAdmin && editingUserId !== null,
});
useEffect(() => {
if (editingUserQuery.data) {
form.reset({
username: editingUserQuery.data.username,
display_name: editingUserQuery.data.display_name || '',
password: '',
});
}
}, [editingUserQuery.data, form]);
useEffect(() => {
if (editingUserQuery.data) {
form.reset({
username: editingUserQuery.data.username,
display_name: editingUserQuery.data.display_name || '',
password: '',
});
}
}, [editingUserQuery.data, form]);
const saveMutation = useMutation({
mutationFn: async (values: UserFormValues) => {
@@ -155,13 +155,13 @@ export function UsersPage() {
await createUser(payload);
return '用户已创建。';
},
onSuccess: async (message) => {
setFeedback({ tone: 'success', message });
setEditingUserId(null);
setIsEditorOpen(false);
form.reset(defaultValues);
await Promise.all([
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
onSuccess: async (message) => {
setFeedback({ tone: 'success', message });
setEditingUserId(null);
setIsEditorOpen(false);
form.reset(defaultValues);
await Promise.all([
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
]);
},
@@ -198,7 +198,7 @@ export function UsersPage() {
const activeQuery = searchKeyword.length > 0 ? searchQuery : usersQuery;
const users = useMemo(() => activeQuery.data ?? [], [activeQuery.data]);
const handleSearchSubmit = () => {
const handleSearchSubmit = () => {
setFeedback(null);
setPage(0);
setSearchKeyword(searchInput.trim());
@@ -211,25 +211,25 @@ export function UsersPage() {
setFeedback(null);
};
const handleResetForm = () => {
setEditingUserId(null);
setIsEditorOpen(false);
setFeedback(null);
form.reset(defaultValues);
};
const handleCreate = () => {
setEditingUserId(null);
setFeedback(null);
form.reset(defaultValues);
setIsEditorOpen(true);
};
const handleEdit = (targetUser: UserItem) => {
setFeedback(null);
setEditingUserId(targetUser.id);
setIsEditorOpen(true);
};
const handleResetForm = () => {
setEditingUserId(null);
setIsEditorOpen(false);
setFeedback(null);
form.reset(defaultValues);
};
const handleCreate = () => {
setEditingUserId(null);
setFeedback(null);
form.reset(defaultValues);
setIsEditorOpen(true);
};
const handleEdit = (targetUser: UserItem) => {
setFeedback(null);
setEditingUserId(targetUser.id);
setIsEditorOpen(true);
};
const handleManage = (targetUser: UserItem, action: ManageUserAction) => {
const actionText = {
@@ -258,10 +258,10 @@ export function UsersPage() {
});
if (!isAdmin) {
return (
<div className='space-y-6'>
<PageHeader
title='用户管理'
return (
<div className='space-y-6'>
<PageHeader
title='用户管理'
description='阶段 4 已迁移用户管理页面,但当前账户没有管理员权限。'
/>
<EmptyState
@@ -272,24 +272,23 @@ export function UsersPage() {
);
}
return (
<>
<div className='space-y-6'>
<PageHeader
title='用户管理'
description='支持搜索、分页、创建、编辑以及启用、封禁、权限调整等常见账户管理动作。'
action={
<PrimaryButton type='button' onClick={handleCreate}>
新建用户
</PrimaryButton>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<AppCard
title='用户列表'
description='搜索会切换到全量匹配结果;分页模式下按服务器默认每页 10 条加载。'
return (
<>
<div className='space-y-6'>
<PageHeader
title='用户管理'
description='支持搜索、分页、创建、编辑以及启用、封禁、权限调整等常见账户管理动作。'
action={
<PrimaryButton type='button' onClick={handleCreate}>
新建用户
</PrimaryButton>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<AppCard
title='用户列表'
action={
<div className='flex flex-wrap gap-2'>
<SecondaryButton
@@ -420,67 +419,67 @@ export function UsersPage() {
)}
</div>
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleResetForm}
title={editingUserId ? '编辑用户' : '创建新用户'}
description={editingUserId ? '可更新用户名、显示名称与密码。留空密码表示不修改。' : '新建用户默认创建为普通用户。'}
footer={
<div className='flex flex-wrap justify-end gap-3'>
<SecondaryButton type='button' onClick={handleResetForm} disabled={saveMutation.isPending}>
取消
</SecondaryButton>
<PrimaryButton type='submit' form='user-editor-form' disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : editingUserId ? '保存修改' : '创建用户'}
</PrimaryButton>
</div>
}
>
{editingUserId && editingUserQuery.isLoading ? (
<LoadingState />
) : editingUserId && editingUserQuery.isError ? (
<ErrorState title='用户详情加载失败' description={getErrorMessage(editingUserQuery.error)} />
) : (
<form id='user-editor-form' className='space-y-5' onSubmit={handleSubmit}>
<ResourceField label='用户名' error={form.formState.errors.username?.message}>
<ResourceInput placeholder='请输入用户名' {...form.register('username')} />
</ResourceField>
<ResourceField label='显示名称' error={form.formState.errors.display_name?.message}>
<ResourceInput placeholder='请输入显示名称' {...form.register('display_name')} />
</ResourceField>
<ResourceField
label={editingUserId ? '新密码' : '密码'}
hint={editingUserId ? '留空表示保持原密码不变。' : '密码长度需为 8 到 20 个字符。'}
error={form.formState.errors.password?.message}
>
<ResourceInput type='password' placeholder='请输入密码' {...form.register('password')} />
</ResourceField>
{editingUserId && editingUserQuery.data ? (
<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.2em] text-[var(--foreground-muted)]'>绑定邮箱</p>
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
{editingUserQuery.data.email || '未绑定'}
</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>
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
GitHub:{editingUserQuery.data.github_id || '未绑定'}
</p>
<p className='mt-1 text-sm text-[var(--foreground-primary)]'>
微信:{editingUserQuery.data.wechat_id || '未绑定'}
</p>
</div>
</div>
) : null}
</form>
)}
</AppModal>
</>
);
}
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleResetForm}
title={editingUserId ? '编辑用户' : '创建新用户'}
description={editingUserId ? '可更新用户名、显示名称与密码。留空密码表示不修改。' : '新建用户默认创建为普通用户。'}
footer={
<div className='flex flex-wrap justify-end gap-3'>
<SecondaryButton type='button' onClick={handleResetForm} disabled={saveMutation.isPending}>
取消
</SecondaryButton>
<PrimaryButton type='submit' form='user-editor-form' disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : editingUserId ? '保存修改' : '创建用户'}
</PrimaryButton>
</div>
}
>
{editingUserId && editingUserQuery.isLoading ? (
<LoadingState />
) : editingUserId && editingUserQuery.isError ? (
<ErrorState title='用户详情加载失败' description={getErrorMessage(editingUserQuery.error)} />
) : (
<form id='user-editor-form' className='space-y-5' onSubmit={handleSubmit}>
<ResourceField label='用户名' error={form.formState.errors.username?.message}>
<ResourceInput placeholder='请输入用户名' {...form.register('username')} />
</ResourceField>
<ResourceField label='显示名称' error={form.formState.errors.display_name?.message}>
<ResourceInput placeholder='请输入显示名称' {...form.register('display_name')} />
</ResourceField>
<ResourceField
label={editingUserId ? '新密码' : '密码'}
hint={editingUserId ? '留空表示保持原密码不变。' : '密码长度需为 8 到 20 个字符。'}
error={form.formState.errors.password?.message}
>
<ResourceInput type='password' placeholder='请输入密码' {...form.register('password')} />
</ResourceField>
{editingUserId && editingUserQuery.data ? (
<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.2em] text-[var(--foreground-muted)]'>绑定邮箱</p>
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
{editingUserQuery.data.email || '未绑定'}
</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>
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
GitHub:{editingUserQuery.data.github_id || '未绑定'}
</p>
<p className='mt-1 text-sm text-[var(--foreground-primary)]'>
微信:{editingUserQuery.data.wechat_id || '未绑定'}
</p>
</div>
</div>
) : null}
</form>
)}
</AppModal>
</>
);
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const dashboardNavigation: NavigationItem[] = [
},
{
href: '/proxy-route',
label: '反向代理',
label: '规则',
icon: 'proxy',
},
{