refactor: 优化设置页面布局与样式

- 设置页全宽对齐其他页面,左侧分类导航去卡片化并支持桌面端吸顶
- 通知渠道改为「渠道列表 + 配置区」布局,支持未保存提醒与校验失败自动跳转
- 系统配置/公网IP采集/告警规则页去嵌套卡片,改为平铺分区(SettingsSection)
- 主题管理页上传按钮与警告提示合并为一行
- 调整全部页面的小布局问题
This commit is contained in:
dushixiang committed 2026-08-23 16:35:19 +08:00
1 parent 45284d31c6
commit c730873be3
15 files changed
+1059 -1117

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ const AdminLayout = () => {
'/admin/alert-records',
'/admin/agents-install/one-click',
'/admin/agents-install/manual',
'/admin/settings',
].includes(location.pathname);
const [userInfo, setUserInfo] = useState<User | null>(null);
const themeButtonRef = useRef<HTMLButtonElement>(null);
+1 -1
View File
@@ -134,7 +134,7 @@ const AgentDetail = () => {
];
return (
<div className="space-y-4 lg:space-y-6">
<div className="space-y-4">
<PageHeader
title={agent.name || agent.hostname}
actions={[{
+1 -1
View File
@@ -459,7 +459,7 @@ const AgentList = () => {
];
return (
<div className="space-y-6">
<div className="space-y-4">
<PageHeader
title="探针管理"
extra={(
+1 -1
View File
@@ -258,7 +258,7 @@ const AlertRecordList = () => {
];
return (
<div className={'space-y-6'}>
<div className={'space-y-4'}>
<PageHeader
title="告警记录"
extra={(
+1 -1
View File
@@ -240,7 +240,7 @@ const ApiKeyList = () => {
];
return (
<div className="space-y-6">
<div className="space-y-4">
<PageHeader
title="通信密钥管理"
extra={(
+1 -1
View File
@@ -268,7 +268,7 @@ const DDNSPage = () => {
];
return (
<div className="space-y-6">
<div className="space-y-4">
<PageHeader
title="DDNS 配置管理"
extra={(
@@ -212,7 +212,7 @@ const ManageApiKeyList = () => {
];
return (
<div className="space-y-6">
<div className="space-y-4">
<PageHeader
title="API密钥管理"
extra={(
+1 -1
View File
@@ -230,7 +230,7 @@ const MonitorList = () => {
const total = monitorPaging?.total || 0;
return (
<div className="space-y-6">
<div className="space-y-4">
<PageHeader
title="服务监控"
extra={(
+110 -180
View File
@@ -1,9 +1,38 @@
import { useEffect } from 'react';
import { App, Button, Card, Form, InputNumber, Switch } from 'antd';
import { App, Button, Form, InputNumber, Switch } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { AlertConfig } from '@/api/property';
import { getAlertConfig, saveAlertConfig } from '@/api/property';
import { getErrorMessage } from '@/lib/utils';
import {SettingsActions, SettingsSection, SettingsSwitchRow} from './SettingsSection';
interface RuleDefinition {
key: string;
name: string;
threshold?: {
label: string;
min: number;
max: number;
tooltip?: string;
};
duration?: {
tooltip?: string;
};
}
const RULES: RuleDefinition[] = [
{ key: 'cpu', name: 'CPU 使用率', threshold: { label: '阈值 (%)', min: 0, max: 100 }, duration: {} },
{ key: 'memory', name: '内存使用率', threshold: { label: '阈值 (%)', min: 0, max: 100 }, duration: {} },
{ key: 'disk', name: '磁盘使用率', threshold: { label: '阈值 (%)', min: 0, max: 100 }, duration: {} },
{ key: 'network', name: '网速', threshold: { label: '阈值 (MB/s)', min: 0, max: 10000 }, duration: {} },
{
key: 'cert',
name: 'HTTPS 证书',
threshold: { label: '剩余天数阈值(天)', min: 1, max: 365, tooltip: '当证书剩余天数低于此阈值时触发告警' },
},
{ key: 'service', name: '服务下线', duration: { tooltip: '服务持续离线多久后触发告警' } },
{ key: 'agentOffline', name: '探针离线', duration: { tooltip: '探针持续离线多久后触发告警' } },
];
const AlertSettings = () => {
const [form] = Form.useForm();
@@ -42,202 +71,103 @@ const AlertSettings = () => {
return (
<div>
<Form form={form}>
<div className="flex w-full min-w-0 flex-col gap-4">
<Card title="基本信息" type="inner">
<Form.Item label="启用告警" name="enabled" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<SettingsSection title="基本信息" divided={false}>
<SettingsSwitchRow
title="启用告警"
description="总开关,关闭后不再产生和发送任何告警通知。"
>
<Form.Item noStyle name="enabled" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
</Form.Item>
<Form.Item
label="IP 打码"
name="maskIP"
valuePropName="checked"
tooltip="开启后,通知消息中的 IP 地址将显示为 192.168.*.* 格式"
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</SettingsSwitchRow>
<SettingsSwitchRow
title="IP 打码"
description="开启后,通知消息中的 IP 地址将显示为 192.168.*.* 格式。"
>
<Form.Item noStyle name="maskIP" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
</Form.Item>
</Card>
</SettingsSwitchRow>
</SettingsSection>
<Card title="通知开关" type="inner">
<Form.Item
label="流量告警通知"
name={['notifications', 'trafficEnabled']}
valuePropName="checked"
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
<SettingsSection title="通知开关" description="控制哪些事件会通过通知渠道推送。">
<SettingsSwitchRow title="流量告警通知">
<Form.Item noStyle name={['notifications', 'trafficEnabled']} valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
</Form.Item>
<Form.Item
label="SSH 登录成功通知"
name={['notifications', 'sshLoginSuccessEnabled']}
valuePropName="checked"
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</SettingsSwitchRow>
<SettingsSwitchRow title="SSH 登录成功通知">
<Form.Item noStyle name={['notifications', 'sshLoginSuccessEnabled']} valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
</Form.Item>
<Form.Item
label="防篡改事件通知"
name={['notifications', 'tamperEventEnabled']}
valuePropName="checked"
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</SettingsSwitchRow>
<SettingsSwitchRow title="防篡改事件通知">
<Form.Item noStyle name={['notifications', 'tamperEventEnabled']} valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
</Form.Item>
</Card>
</SettingsSwitchRow>
</SettingsSection>
{/*<Divider orientation="left">告警规则</Divider>*/}
{[
{ key: 'cpu', title: 'CPU 告警规则', thresholdLabel: 'CPU 使用率阈值 (%)', max: 100 },
{ key: 'memory', title: '内存告警规则', thresholdLabel: '内存使用率阈值 (%)', max: 100 },
{ key: 'disk', title: '磁盘告警规则', thresholdLabel: '磁盘使用率阈值 (%)', max: 100 },
{ key: 'network', title: '网速告警规则', thresholdLabel: '网速阈值 (MB/s)', max: 10000 },
].map((rule) => (
<Card key={rule.key} title={rule.title} type="inner">
<Form.Item noStyle shouldUpdate>
<SettingsSection title="告警规则" description="开启规则后,满足阈值条件并持续指定时间才会触发告警。">
<div>
{RULES.map((rule) => (
<Form.Item key={rule.key} noStyle shouldUpdate>
{({ getFieldValue }) => {
const enabled = getFieldValue(['rules', `${rule.key}Enabled`]);
return (
<div className="flex items-center gap-8">
<Form.Item
label="开关"
name={['rules', `${rule.key}Enabled`]}
valuePropName="checked"
className="mb-0"
>
<Switch />
</Form.Item>
<Form.Item
label={rule.thresholdLabel}
name={['rules', `${rule.key}Threshold`]}
className="mb-0"
>
<InputNumber
min={0}
max={rule.max}
style={{ width: '100%' }}
disabled={!enabled}
/>
</Form.Item>
<Form.Item
label="持续时间(秒)"
name={['rules', `${rule.key}Duration`]}
className="mb-0"
>
<InputNumber min={1} max={3600} style={{ width: '100%' }}
disabled={!enabled} />
</Form.Item>
<div className="flex flex-wrap items-center justify-between gap-x-8 gap-y-4 border-t border-[#e8ebf0] py-4 first:border-t-0 first:pt-0 dark:border-[#272b33]">
<div className="flex items-center gap-3">
<Form.Item
noStyle
name={['rules', `${rule.key}Enabled`]}
valuePropName="checked"
>
<Switch size="small"/>
</Form.Item>
<span className="text-[13px] font-medium text-[#1f2329] dark:text-[#e6e8ec]">
{rule.name}
</span>
</div>
<div className="flex flex-wrap items-start gap-x-8 gap-y-3">
{rule.threshold && (
<Form.Item
label={rule.threshold.label}
name={['rules', `${rule.key}Threshold`]}
tooltip={rule.threshold.tooltip}
style={{ marginBottom: 0 }}
>
<InputNumber
min={rule.threshold.min}
max={rule.threshold.max}
disabled={!enabled}
/>
</Form.Item>
)}
{rule.duration && (
<Form.Item
label="持续时间(秒)"
name={['rules', `${rule.key}Duration`]}
tooltip={rule.duration.tooltip}
style={{ marginBottom: 0 }}
>
<InputNumber min={1} max={3600} disabled={!enabled}/>
</Form.Item>
)}
</div>
</div>
);
}}
</Form.Item>
</Card>
))}
))}
</div>
</SettingsSection>
<Card title="HTTPS 证书告警规则" type="inner">
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) => {
const enabled = getFieldValue(['rules', 'certEnabled']);
return (
<div className="flex items-center gap-8">
<Form.Item
label="开关"
name={['rules', 'certEnabled']}
valuePropName="checked"
className="mb-0"
>
<Switch />
</Form.Item>
<Form.Item
label="证书剩余天数阈值(天)"
name={['rules', 'certThreshold']}
className="mb-0"
tooltip="当证书剩余天数低于此阈值时触发告警"
>
<InputNumber
min={1}
max={365}
style={{ width: '100%' }}
disabled={!enabled}
/>
</Form.Item>
</div>
);
}}
</Form.Item>
</Card>
<Card title="服务下线告警规则" type="inner">
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) => {
const enabled = getFieldValue(['rules', 'serviceEnabled']);
return (
<div className="flex items-center gap-8">
<Form.Item
label="开关"
name={['rules', 'serviceEnabled']}
valuePropName="checked"
className="mb-0"
>
<Switch />
</Form.Item>
<Form.Item
label="持续时间(秒)"
name={['rules', 'serviceDuration']}
className="mb-0"
tooltip="服务持续离线多久后触发告警"
>
<InputNumber
min={1}
max={3600}
style={{ width: '100%' }}
disabled={!enabled}
/>
</Form.Item>
</div>
);
}}
</Form.Item>
</Card>
<Card title="探针离线告警规则" type="inner">
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) => {
const enabled = getFieldValue(['rules', 'agentOfflineEnabled']);
return (
<div className="flex items-center gap-8">
<Form.Item
label="开关"
name={['rules', 'agentOfflineEnabled']}
valuePropName="checked"
className="mb-0"
>
<Switch />
</Form.Item>
<Form.Item
label="持续时间(秒)"
name={['rules', 'agentOfflineDuration']}
className="mb-0"
tooltip="探针持续离线多久后触发告警"
>
<InputNumber
min={1}
max={3600}
style={{ width: '100%' }}
disabled={!enabled}
/>
</Form.Item>
</div>
);
}}
</Form.Item>
</Card>
<Button
type="primary"
loading={saveMutation.isPending}
onClick={handleSubmit}
>
<SettingsActions>
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
保存配置
</Button>
</div>
</SettingsActions>
</Form>
</div>
);
File diff suppressed because it is too large. Load diff
+107 -110
View File
@@ -1,11 +1,12 @@
import {useEffect} from 'react';
import {App, Button, Card, Form, Input, InputNumber, Radio, Select, Space, Spin, Switch} from 'antd';
import {App, Button, Form, Input, InputNumber, Radio, Select, Spin, Switch} from 'antd';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import type {PublicIPConfig} from '@/api/property';
import {getPublicIPConfig, savePublicIPConfig} from '@/api/property';
import {listAgentsByAdmin} from '@/api/agent.ts';
import {getErrorMessage} from '@/lib/utils';
import type {Agent} from '@/types';
import {SettingsActions, SettingsSection} from './SettingsSection';
interface PublicIPConfigProps {
defaultIPv4APIs: string[];
@@ -136,122 +137,118 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
return (
<div>
<Form form={form} layout="vertical" onFinish={handleSave}>
<div className="flex w-full min-w-0 flex-col gap-4">
<Card title="采集设置" type="inner">
<div className="flex flex-wrap items-center gap-6">
<Form.Item label="启用采集" name="enabled" valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item
label="采集间隔(秒)"
name="intervalSeconds"
rules={[{type: 'number', min: 30, message: '采集间隔不能小于 30 秒'}]}
>
<InputNumber min={30} max={86400}/>
</Form.Item>
</div>
</Card>
<Card title="IPv4 配置" type="inner">
<Form.Item label="启用 IPv4" name="ipv4Enabled" valuePropName="checked">
<SettingsSection title="采集设置" divided={false} description="通过探针定期获取公网出口 IP 地址。">
<div className="flex flex-wrap items-start gap-x-10 gap-y-4">
<Form.Item label="启用采集" name="enabled" valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item label="IPv4 采集范围" name="ipv4Scope">
<Radio.Group>
<Radio.Button value="all">全部探针</Radio.Button>
<Radio.Button value="custom">自定义探针</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({getFieldValue}) => {
const enabled = getFieldValue('ipv4Enabled');
const scope = getFieldValue('ipv4Scope');
if (!enabled || scope !== 'custom') {
return null;
}
return (
<Form.Item
label="选择 IPv4 探针"
name="ipv4AgentIds"
rules={[{required: true, message: '请选择至少一个探针'}]}
>
<Select
mode="multiple"
placeholder="选择需要采集 IPv4 的探针"
options={agentOptions}
optionFilterProp="label"
showSearch
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label="IPv4 API 列表"
name="ipv4ApisText"
tooltip="每行一个 HTTP/HTTPS API 地址"
label="采集间隔(秒)"
name="intervalSeconds"
rules={[{type: 'number', min: 30, message: '采集间隔不能小于 30 秒'}]}
>
<Input.TextArea rows={6} placeholder="每行一个 IPv4 API"/>
<InputNumber min={30} max={86400}/>
</Form.Item>
</Card>
</div>
</SettingsSection>
<Card title="IPv6 配置" type="inner">
<Form.Item label="启用 IPv6" name="ipv6Enabled" valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item label="IPv6 采集范围" name="ipv6Scope">
<Radio.Group>
<Radio.Button value="all">全部探针</Radio.Button>
<Radio.Button value="custom">自定义探针</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({getFieldValue}) => {
const enabled = getFieldValue('ipv6Enabled');
const scope = getFieldValue('ipv6Scope');
if (!enabled || scope !== 'custom') {
return null;
}
return (
<Form.Item
label="选择 IPv6 探针"
name="ipv6AgentIds"
rules={[{required: true, message: '请选择至少一个探针'}]}
>
<Select
mode="multiple"
placeholder="选择需要采集 IPv6 的探针"
options={agentOptions}
optionFilterProp="label"
showSearch
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label="IPv6 API 列表"
name="ipv6ApisText"
tooltip="每行一个 HTTP/HTTPS API 地址"
>
<Input.TextArea rows={6} placeholder="每行一个 IPv6 API"/>
</Form.Item>
</Card>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
保存配置
</Button>
<Button onClick={handleReset}>
恢复当前配置
</Button>
<Button onClick={handleUseDefaults}>
使用默认 API
</Button>
</Space>
<SettingsSection title="IPv4 配置">
<Form.Item label="启用 IPv4" name="ipv4Enabled" valuePropName="checked">
<Switch/>
</Form.Item>
</div>
<Form.Item label="IPv4 采集范围" name="ipv4Scope">
<Radio.Group>
<Radio.Button value="all">全部探针</Radio.Button>
<Radio.Button value="custom">自定义探针</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({getFieldValue}) => {
const enabled = getFieldValue('ipv4Enabled');
const scope = getFieldValue('ipv4Scope');
if (!enabled || scope !== 'custom') {
return null;
}
return (
<Form.Item
label="选择 IPv4 探针"
name="ipv4AgentIds"
rules={[{required: true, message: '请选择至少一个探针'}]}
>
<Select
mode="multiple"
placeholder="选择需要采集 IPv4 的探针"
options={agentOptions}
optionFilterProp="label"
showSearch
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label="IPv4 API 列表"
name="ipv4ApisText"
tooltip="每行一个 HTTP/HTTPS API 地址"
>
<Input.TextArea rows={6} placeholder="每行一个 IPv4 API"/>
</Form.Item>
</SettingsSection>
<SettingsSection title="IPv6 配置">
<Form.Item label="启用 IPv6" name="ipv6Enabled" valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item label="IPv6 采集范围" name="ipv6Scope">
<Radio.Group>
<Radio.Button value="all">全部探针</Radio.Button>
<Radio.Button value="custom">自定义探针</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({getFieldValue}) => {
const enabled = getFieldValue('ipv6Enabled');
const scope = getFieldValue('ipv6Scope');
if (!enabled || scope !== 'custom') {
return null;
}
return (
<Form.Item
label="选择 IPv6 探针"
name="ipv6AgentIds"
rules={[{required: true, message: '请选择至少一个探针'}]}
>
<Select
mode="multiple"
placeholder="选择需要采集 IPv6 的探针"
options={agentOptions}
optionFilterProp="label"
showSearch
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label="IPv6 API 列表"
name="ipv6ApisText"
tooltip="每行一个 HTTP/HTTPS API 地址"
>
<Input.TextArea rows={6} placeholder="每行一个 IPv6 API"/>
</Form.Item>
</SettingsSection>
<SettingsActions>
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
保存配置
</Button>
<Button onClick={handleReset}>
恢复当前配置
</Button>
<Button onClick={handleUseDefaults}>
使用默认 API
</Button>
</SettingsActions>
</Form>
</div>
);
@@ -0,0 +1,49 @@
import type {PropsWithChildren, ReactNode} from 'react';
import {cn} from '@/lib/utils';
interface SettingsSectionProps {
title: string;
description?: ReactNode;
/** 渲染与上一区块的分隔线,首个区块传 false */
divided?: boolean;
className?: string;
children: ReactNode;
}
/** 设置页内部的平铺分区:标题 + 描述 + 内容,用分隔线取代嵌套卡片。 */
export const SettingsSection = ({title, description, divided = true, className, children}: SettingsSectionProps) => (
<section className={cn(divided && 'mt-6 border-t border-[#e8ebf0] pt-5 dark:border-[#272b33]', className)}>
<h3 className="m-0 text-sm font-semibold text-[#1f2329] dark:text-[#e6e8ec]">{title}</h3>
{description ? (
<p className="mt-1 mb-0 text-xs text-[#646a73] dark:text-[#9ba1ab]">{description}</p>
) : null}
<div className="mt-4">{children}</div>
</section>
);
/** 设置页底部的操作按钮行。 */
export const SettingsActions = ({children}: PropsWithChildren) => (
<div className="mt-6 flex flex-wrap items-center gap-3 border-t border-[#e8ebf0] pt-4 dark:border-[#272b33]">
{children}
</div>
);
interface SettingsSwitchRowProps {
title: string;
description?: ReactNode;
/** 右侧的开关(通常是 noStyle 的 Form.Item + Switch) */
children: ReactNode;
}
/** 带说明文字的开关行:左侧标题与描述,右侧开关。 */
export const SettingsSwitchRow = ({title, description, children}: SettingsSwitchRowProps) => (
<div className="flex items-center justify-between gap-4 border-t border-[#e8ebf0] py-3.5 first:border-t-0 first:pt-0 dark:border-[#272b33]">
<div className="min-w-0">
<div className="text-[13px] font-medium text-[#1f2329] dark:text-[#e6e8ec]">{title}</div>
{description ? (
<div className="mt-0.5 text-xs text-[#646a73] dark:text-[#9ba1ab]">{description}</div>
) : null}
</div>
<div className="shrink-0">{children}</div>
</div>
);
+142 -147
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react';
import { App, Button, Card, Form, Input, Radio, Space, Spin, Upload } from 'antd';
import { App, Button, Form, Input, Radio, Space, Spin, Upload } from 'antd';
import { Upload as UploadIcon, Grid3x3, List } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { SystemConfig } from '@/api/property.ts';
import { getSystemConfig, saveSystemConfig } from '@/api/property.ts';
import { getErrorMessage } from '@/lib/utils.ts';
import type { RcFile } from 'antd/es/upload/interface';
import {SettingsActions, SettingsSection} from './SettingsSection';
const SystemConfigComponent = () => {
const [form] = Form.useForm();
@@ -142,106 +143,105 @@ const SystemConfigComponent = () => {
return (
<div>
<Form form={form} layout="vertical" onFinish={handleSave}>
<div className="flex w-full min-w-0 flex-col gap-4">
<Card
title="系统基本信息"
type="inner"
>
<div className={'flex items-center gap-2'}>
<Form.Item
label="系统英文名称"
name="systemNameEn"
dependencies={['systemNameZh']}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
if (!value && !getFieldValue('systemNameZh')) {
return Promise.reject(new Error('系统英文名称和中文名称不能同时为空'));
}
return Promise.resolve();
},
}),
{ max: 50, message: '系统名称不能超过 50 个字符' },
]}
>
<Input placeholder="例如:Pika Monitor" />
</Form.Item>
<Form.Item
label="系统中文名称"
name="systemNameZh"
dependencies={['systemNameEn']}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
if (!value && !getFieldValue('systemNameEn')) {
return Promise.reject(new Error('系统英文名称和中文名称不能同时为空'));
}
return Promise.resolve();
},
}),
{ max: 50, message: '系统名称不能超过 50 个字符' },
]}
>
<Input placeholder="例如:皮卡监控" />
</Form.Item>
</div>
<SettingsSection
title="系统基本信息"
divided={false}
description="展示在公开页面顶部的系统名称、Logo 与底部备案信息。"
>
<div className="grid gap-x-4 sm:grid-cols-2">
<Form.Item
label="ICP 备案号"
name="icpCode"
label="系统英文名称"
name="systemNameEn"
dependencies={['systemNameZh']}
rules={[
{ max: 50, message: 'ICP 备案号不能超过 50 个字符' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value && !getFieldValue('systemNameZh')) {
return Promise.reject(new Error('系统英文名称和中文名称不能同时为空'));
}
return Promise.resolve();
},
}),
{ max: 50, message: '系统名称不能超过 50 个字符' },
]}
tooltip="ICP 备案号将显示在公共页面底部,例如:京ICP备12345678号"
>
<Input placeholder="例如:京ICP备12345678号" />
<Input placeholder="例如:Pika Monitor" />
</Form.Item>
<Form.Item
label="默认视图模式"
name="defaultView"
tooltip="选择公共页面默认显示的视图模式"
label="系统中文名称"
name="systemNameZh"
dependencies={['systemNameEn']}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
if (!value && !getFieldValue('systemNameEn')) {
return Promise.reject(new Error('系统英文名称和中文名称不能同时为空'));
}
return Promise.resolve();
},
}),
{ max: 50, message: '系统名称不能超过 50 个字符' },
]}
>
<Radio.Group>
<Radio.Button value="grid">
<Space size={4}>
<Grid3x3 size={16} />
<span>网格视图</span>
</Space>
</Radio.Button>
<Radio.Button value="list">
<Space size={4}>
<List size={16} />
<span>列表视图</span>
</Space>
</Radio.Button>
</Radio.Group>
<Input placeholder="例如:皮卡监控" />
</Form.Item>
</div>
<Form.Item
label="系统 Logo"
tooltip="上传系统 Logo,建议使用正方形图片,尺寸为 256x256 或更大,文件大小不超过 500KB"
>
<Space orientation="vertical" className="w-full">
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={beforeUpload}
disabled={uploading}
>
<Button icon={<UploadIcon size={16} />} loading={uploading}>
{uploading ? '处理中...' : '上传 Logo'}
</Button>
</Upload>
</Space>
</Form.Item>
</Card>
<Card
title="自定义代码"
type="inner"
<Form.Item
label="ICP 备案号"
name="icpCode"
rules={[
{ max: 50, message: 'ICP 备案号不能超过 50 个字符' },
]}
tooltip="ICP 备案号将显示在公共页面底部,例如:京ICP备12345678号"
>
<Input placeholder="例如:京ICP备12345678号" />
</Form.Item>
<Form.Item
label="默认视图模式"
name="defaultView"
tooltip="选择公共页面默认显示的视图模式"
>
<Radio.Group>
<Radio.Button value="grid">
<Space size={4}>
<Grid3x3 size={16} />
<span>网格视图</span>
</Space>
</Radio.Button>
<Radio.Button value="list">
<Space size={4}>
<List size={16} />
<span>列表视图</span>
</Space>
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item
label="系统 Logo"
tooltip="上传系统 Logo,建议使用正方形图片,尺寸为 256x256 或更大,文件大小不超过 500KB"
>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={beforeUpload}
disabled={uploading}
>
<Button icon={<UploadIcon size={16} />} loading={uploading}>
{uploading ? '处理中...' : '上传 Logo'}
</Button>
</Upload>
</Form.Item>
</SettingsSection>
<SettingsSection
title="自定义代码"
description="将注入到公开页面顶部的 style 与 script 标签中,修改后刷新公开页面生效。"
>
<div className="grid gap-x-4 lg:grid-cols-2">
<Form.Item
label="自定义 CSS"
name="customCSS"
@@ -263,70 +263,65 @@ const SystemConfigComponent = () => {
rows={6}
/>
</Form.Item>
</Card>
</div>
</SettingsSection>
<Card
title="预览效果"
type="inner"
>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) => {
const systemNameEn = getFieldValue('systemNameEn') || '';
const systemNameZh = getFieldValue('systemNameZh') || '';
<SettingsSection title="预览效果">
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) => {
const systemNameEn = getFieldValue('systemNameEn') || '';
const systemNameZh = getFieldValue('systemNameZh') || '';
// 与 PublicHeader 相同的分割逻辑
let leftName = '';
let rightName = '';
// 与 PublicHeader 相同的分割逻辑
let leftName = '';
let rightName = '';
if (systemNameEn) {
// 优先在空格处分割
const spaceIndex = systemNameEn.indexOf(' ');
if (spaceIndex > 0) {
leftName = systemNameEn.substring(0, spaceIndex);
rightName = systemNameEn.substring(spaceIndex); // 保留空格
} else {
// 如果没有空格,从中间分割
const mid = Math.floor(systemNameEn.length / 2);
leftName = systemNameEn.substring(0, mid);
rightName = systemNameEn.substring(mid);
}
if (systemNameEn) {
// 优先在空格处分割
const spaceIndex = systemNameEn.indexOf(' ');
if (spaceIndex > 0) {
leftName = systemNameEn.substring(0, spaceIndex);
rightName = systemNameEn.substring(spaceIndex); // 保留空格
} else {
// 如果没有空格,从中间分割
const mid = Math.floor(systemNameEn.length / 2);
leftName = systemNameEn.substring(0, mid);
rightName = systemNameEn.substring(mid);
}
}
return (
<div className="flex items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
<img
src={getLogoUrl()}
alt="Logo 预览"
className="h-8 w-8 sm:h-9 sm:w-9 object-contain rounded-md"
onError={(e) => {
e.currentTarget.src = '/logo.png';
}}
/>
<div>
<h1 className="text-2xl font-black tracking-widest text-transparent bg-clip-text bg-gradient-to-r from-cyan-500 via-blue-500 to-purple-500 dark:from-cyan-400 dark:via-blue-400 dark:to-purple-400 uppercase italic">
{leftName}<span className="text-slate-800 dark:text-white">{rightName}</span>
</h1>
<p className="text-xs text-slate-500 dark:text-cyan-500 font-mono tracking-[0.3em] uppercase">
{systemNameZh}
</p>
</div>
return (
<div className="flex items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
<img
src={getLogoUrl()}
alt="Logo 预览"
className="h-8 w-8 sm:h-9 sm:w-9 object-contain rounded-md"
onError={(e) => {
e.currentTarget.src = '/logo.png';
}}
/>
<div>
<h1 className="text-2xl font-black tracking-widest text-transparent bg-clip-text bg-gradient-to-r from-cyan-500 via-blue-500 to-purple-500 dark:from-cyan-400 dark:via-blue-400 dark:to-purple-400 uppercase italic">
{leftName}<span className="text-slate-800 dark:text-white">{rightName}</span>
</h1>
<p className="text-xs text-slate-500 dark:text-cyan-500 font-mono tracking-[0.3em] uppercase">
{systemNameZh}
</p>
</div>
);
}}
</Form.Item>
</Card>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
保存配置
</Button>
<Button onClick={handleReset}>
恢复默认
</Button>
</Space>
</div>
);
}}
</Form.Item>
</div>
</SettingsSection>
<SettingsActions>
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
保存配置
</Button>
<Button onClick={handleReset}>
恢复默认
</Button>
</SettingsActions>
</Form>
</div>
);
+8 -7
View File
@@ -123,7 +123,14 @@ const ThemeSettings = () => {
return (
<div className="flex w-full min-w-0 flex-col gap-4">
<div className="flex justify-end">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<Alert
type="warning"
showIcon
title="只安装你信任的主题:第三方主题会在 Pika 同一域名下执行 JavaScript,安装前请确认来源可信。"
/>
</div>
<div className="shrink-0">
<Upload
accept=".zip,application/zip"
@@ -139,12 +146,6 @@ const ThemeSettings = () => {
</div>
</div>
<Alert
type="warning"
showIcon
title="只安装你信任的主题:第三方主题会在 Pika 同一域名下执行 JavaScript,安装前请确认来源可信。"
/>
{themesQuery.isLoading ? (
<div className="flex min-h-72 items-center justify-center">
<Spin size="large" tip="正在加载主题...">
+88 -78
View File
@@ -1,10 +1,14 @@
import type {ReactNode} from 'react';
import {Bell, MessageSquare, Palette, Settings2, Wifi} from 'lucide-react';
import type {LucideIcon} from 'lucide-react';
import AlertSettings from './AlertSettings';
import NotificationChannels from './NotificationChannels';
import SystemConfig from './SystemConfig';
import PublicIPConfig from './PublicIPConfig';
import ThemeSettings from './ThemeSettings';
import {useSearchParams} from "react-router-dom";
import {PageHeader} from '@/components/PageHeader';
import {PagePanel} from '@/components/PagePanel';
import {useSearchParams} from 'react-router-dom';
// 默认 IPv4 API 列表
export const defaultIPv4APIs = [
@@ -23,88 +27,94 @@ export const defaultIPv6APIs = [
'https://v6.yinghualuo.cn/bejson',
];
interface SettingsTab {
key: string;
label: string;
icon: LucideIcon;
content: ReactNode;
}
const settingsTabs: SettingsTab[] = [
{
key: 'system',
label: '系统配置',
icon: Settings2,
content: <SystemConfig/>,
},
{
key: 'themes',
label: '主题管理',
icon: Palette,
content: <ThemeSettings/>,
},
{
key: 'channels',
label: '通知渠道',
icon: MessageSquare,
content: <NotificationChannels/>,
},
{
key: 'public-ip',
label: '公网 IP 采集',
icon: Wifi,
content: (
<PublicIPConfig
defaultIPv4APIs={defaultIPv4APIs}
defaultIPv6APIs={defaultIPv6APIs}
/>
),
},
{
key: 'alert',
label: '告警规则',
icon: Bell,
content: <AlertSettings/>,
},
];
const Settings = () => {
const [searchParams, setSearchParams] = useSearchParams({tab: 'system'});
const items = [
{
key: 'system',
label: (
<span className="flex items-center gap-2">
<Settings2 size={16}/>
系统配置
</span>
),
children: <SystemConfig/>,
},
{
key: 'themes',
label: (
<span className="flex items-center gap-2">
<Palette size={16}/>
主题管理
</span>
),
children: <ThemeSettings/>,
},
{
key: 'channels',
label: (
<span className="flex items-center gap-2">
<MessageSquare size={16}/>
通知渠道
</span>
),
children: <NotificationChannels/>,
},
{
key: 'public-ip',
label: (
<span className="flex items-center gap-2">
<Wifi size={16}/>
公网 IP 采集
</span>
),
children: (
<PublicIPConfig
defaultIPv4APIs={defaultIPv4APIs}
defaultIPv6APIs={defaultIPv6APIs}
/>
),
},
{
key: 'alert',
label: (
<span className="flex items-center gap-2">
<Bell size={16}/>
告警规则
</span>
),
children: <AlertSettings/>,
},
];
const activeKey = searchParams.get('tab') || 'system';
const activeItem = items.find((item) => item.key === activeKey) || items[0];
const activeKey = settingsTabs.some((tab) => tab.key === searchParams.get('tab'))
? searchParams.get('tab')!
: 'system';
const activeTab = settingsTabs.find((tab) => tab.key === activeKey) ?? settingsTabs[0];
return (
<div className="grid grid-cols-[208px_minmax(0,1fr)] items-start gap-4 max-md:block">
<nav className="flex flex-col gap-1 rounded-[10px] border border-[#e8ebf0] bg-white p-2 dark:border-[#272b33] dark:bg-[#171a21] max-md:mb-3.5 max-md:flex-row max-md:overflow-x-auto max-md:rounded-none max-md:border-0 max-md:bg-transparent max-md:p-0 max-md:pb-1.5 dark:max-md:bg-transparent" aria-label="设置分类">
{items.map((item) => (
<button
key={item.key}
type="button"
className={item.key === activeKey
? 'flex min-h-10 w-full cursor-pointer items-center rounded-[7px] border-0 bg-[#eaf2ff] px-2.5 py-2 text-left text-[13px] font-semibold text-[#145dcc] dark:bg-[#1677ff]/15 dark:text-[#75adff] max-md:w-auto max-md:shrink-0 max-md:whitespace-nowrap'
: 'flex min-h-10 w-full cursor-pointer items-center rounded-[7px] border-0 bg-transparent px-2.5 py-2 text-left text-[13px] text-[#646a73] hover:bg-[#f5f6f8] hover:text-[#1f2329] dark:text-[#9ba1ab] dark:hover:bg-[#20242c] dark:hover:text-[#e6e8ec] max-md:w-auto max-md:shrink-0 max-md:whitespace-nowrap'}
onClick={() => setSearchParams({tab: item.key})}
aria-current={item.key === activeKey ? 'page' : undefined}
>
{item.label}
</button>
))}
</nav>
<div className="min-w-0">{activeItem.children}</div>
<div className="flex min-w-0 flex-col gap-6">
<PageHeader title="系统设置"/>
<div className="grid grid-cols-[208px_minmax(0,1fr)] items-start gap-6 max-md:block">
<nav
className="flex flex-col gap-1 lg:sticky lg:top-[76px] max-md:mb-2 max-md:flex-row max-md:overflow-x-auto max-md:pb-1.5"
aria-label="设置分类"
>
{settingsTabs.map((tab) => {
const Icon = tab.icon;
const active = tab.key === activeKey;
return (
<button
key={tab.key}
type="button"
className={active
? 'flex min-h-10 w-full cursor-pointer items-center rounded-[7px] border-0 bg-[#eaf2ff] px-2.5 py-2 text-left text-[13px] font-semibold text-[#145dcc] dark:bg-[#1677ff]/15 dark:text-[#75adff] max-md:w-auto max-md:shrink-0 max-md:whitespace-nowrap'
: 'flex min-h-10 w-full cursor-pointer items-center rounded-[7px] border-0 bg-transparent px-2.5 py-2 text-left text-[13px] text-[#646a73] hover:bg-[#f5f6f8] hover:text-[#1f2329] dark:text-[#9ba1ab] dark:hover:bg-[#20242c] dark:hover:text-[#e6e8ec] max-md:w-auto max-md:shrink-0 max-md:whitespace-nowrap'}
onClick={() => setSearchParams({tab: tab.key})}
aria-current={active ? 'page' : undefined}
>
<span className="flex min-w-0 items-center gap-2">
<Icon size={16} className="shrink-0"/>
<span className="truncate">{tab.label}</span>
</span>
</button>
);
})}
</nav>
<PagePanel className="min-w-0">
{activeTab.content}
</PagePanel>
</div>
</div>
);
};