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
+537 -595

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={(
+91 -161
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.Item>
<Form.Item
label="IP 打码"
name="maskIP"
valuePropName="checked"
tooltip="开启后,通知消息中的 IP 地址将显示为 192.168.*.* 格式"
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<SettingsSection title="基本信息" divided={false}>
<SettingsSwitchRow
title="启用告警"
description="总开关,关闭后不再产生和发送任何告警通知。"
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
<Form.Item noStyle name="enabled" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
</Form.Item>
</Card>
</SettingsSwitchRow>
<SettingsSwitchRow
title="IP 打码"
description="开启后,通知消息中的 IP 地址将显示为 192.168.*.* 格式。"
>
<Form.Item noStyle name="maskIP" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
</Form.Item>
</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">
<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
label="开关"
noStyle
name={['rules', `${rule.key}Enabled`]}
valuePropName="checked"
className="mb-0"
>
<Switch />
<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.thresholdLabel}
label={rule.threshold.label}
name={['rules', `${rule.key}Threshold`]}
className="mb-0"
tooltip={rule.threshold.tooltip}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
max={rule.max}
style={{ width: '100%' }}
min={rule.threshold.min}
max={rule.threshold.max}
disabled={!enabled}
/>
</Form.Item>
)}
{rule.duration && (
<Form.Item
label="持续时间(秒)"
name={['rules', `${rule.key}Duration`]}
className="mb-0"
tooltip={rule.duration.tooltip}
style={{ marginBottom: 0 }}
>
<InputNumber min={1} max={3600} style={{ width: '100%' }}
disabled={!enabled} />
<InputNumber min={1} max={3600} disabled={!enabled}/>
</Form.Item>
)}
</div>
</div>
);
}}
</Form.Item>
</Card>
))}
<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>
</SettingsSection>
<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>
);
+292 -333
View File
@@ -1,6 +1,6 @@
import {useEffect, useState} from 'react';
import {Alert, App, Button, Card, Form, Input, InputNumber, Select, Space, Spin, Switch} from 'antd';
import {TestTube} from 'lucide-react';
import {App, Button, Form, Input, InputNumber, Select, Space, Spin, Switch, Tag} from 'antd';
import {Bell, Bot, Building2, ExternalLink, Mail, MessageSquare, Smartphone, TestTube, Webhook, type LucideIcon} from 'lucide-react';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {
getNotificationChannels,
@@ -8,14 +8,91 @@ import {
saveNotificationChannels,
testNotificationChannel,
} from '@/api/property.ts';
import {getErrorMessage} from '@/lib/utils';
import {cn, getErrorMessage} from '@/lib/utils';
import NotificationCustomHelp from "@/pages/Settings/NotificationCustomHelp.tsx";
type ChannelType = 'dingtalk' | 'wecom' | 'wecomApp' | 'feishu' | 'telegram' | 'email' | 'webhook';
interface ChannelDefinition {
type: ChannelType;
name: string;
description: string;
enabledField: string;
fieldPrefix: string;
icon: LucideIcon;
docsUrl?: string;
}
const CHANNELS: ChannelDefinition[] = [
{
type: 'dingtalk',
name: '钉钉',
description: '自定义机器人通知',
enabledField: 'dingtalkEnabled',
fieldPrefix: 'dingtalk',
icon: Bell,
docsUrl: 'https://open.dingtalk.com/document/robots/custom-robot-access',
},
{
type: 'wecom',
name: '企业微信',
description: '群机器人通知',
enabledField: 'wecomEnabled',
fieldPrefix: 'wecomSecretKey',
icon: Building2,
docsUrl: 'https://work.weixin.qq.com/api/doc/90000/90136/91770',
},
{
type: 'wecomApp',
name: '企业微信应用',
description: '应用消息通知',
enabledField: 'wecomAppEnabled',
fieldPrefix: 'wecomApp',
icon: Smartphone,
docsUrl: 'https://developer.work.weixin.qq.com/document/path/90236',
},
{
type: 'feishu',
name: '飞书',
description: '自定义机器人通知',
enabledField: 'feishuEnabled',
fieldPrefix: 'feishu',
icon: MessageSquare,
docsUrl: 'https://www.feishu.cn/hc/zh-CN/articles/360024984973',
},
{
type: 'telegram',
name: 'Telegram',
description: 'Bot 消息通知',
enabledField: 'telegramEnabled',
fieldPrefix: 'telegram',
icon: Bot,
docsUrl: 'https://core.telegram.org/bots/api',
},
{
type: 'email',
name: '邮件',
description: 'SMTP 邮件通知',
enabledField: 'emailEnabled',
fieldPrefix: 'email',
icon: Mail,
},
{
type: 'webhook',
name: '自定义 Webhook',
description: '自定义 HTTP 请求',
enabledField: 'webhookEnabled',
fieldPrefix: 'webhook',
icon: Webhook,
},
];
const NotificationChannels = () => {
const [form] = Form.useForm();
const {message: messageApi} = App.useApp();
const queryClient = useQueryClient();
const [savedValues, setSavedValues] = useState<Record<string, any>>({});
const [selectedType, setSelectedType] = useState<ChannelType>('dingtalk');
const [dirty, setDirty] = useState(false);
// 验证 token 字段,检查是否误输入了完整的 URL
const validateToken = (_: any, value: string) => {
@@ -34,6 +111,16 @@ const NotificationChannels = () => {
const emailEnabled = Form.useWatch('emailEnabled', form);
const webhookEnabled = Form.useWatch('webhookEnabled', form);
const enabledMap: Record<ChannelType, boolean | undefined> = {
dingtalk: dingtalkEnabled,
wecom: wecomEnabled,
wecomApp: wecomAppEnabled,
feishu: feishuEnabled,
telegram: telegramEnabled,
email: emailEnabled,
webhook: webhookEnabled,
};
// 获取通知渠道列表
const {data: channels = [], isLoading} = useQuery({
queryKey: ['notificationChannels'],
@@ -45,6 +132,7 @@ const NotificationChannels = () => {
mutationFn: saveNotificationChannels,
onSuccess: () => {
messageApi.success('保存成功');
setDirty(false);
queryClient.invalidateQueries({queryKey: ['notificationChannels']});
},
onError: (error: unknown) => {
@@ -115,15 +203,11 @@ const NotificationChannels = () => {
});
form.setFieldsValue(formValues);
// 保存当前值作为"已保存"的值,用于检测是否有未保存的修改
setSavedValues(formValues);
}
}, [channels, form]);
// 将表单值转换回渠道数组
const handleSave = async () => {
try {
const values = await form.validateFields();
const handleSave = (values: any) => {
const newChannels: NotificationChannel[] = [];
// 钉钉
@@ -229,15 +313,26 @@ const NotificationChannels = () => {
}
saveMutation.mutate(newChannels);
} catch (error) {
// 表单验证失败
}
};
const handleTest = (type: string) => {
testMutation.mutate(type);
// 校验失败时跳转到第一个出错的渠道(错误可能位于未选中的渠道分组中)
const handleValidateFailed = (errorFields: { name: (string | number)[] }[]) => {
const firstField = errorFields?.[0]?.name?.[0];
if (typeof firstField === 'string') {
const matched = [...CHANNELS]
.sort((a, b) => b.fieldPrefix.length - a.fieldPrefix.length)
.find((channel) => firstField.startsWith(channel.fieldPrefix));
if (matched) {
setSelectedType(matched.type);
}
}
messageApi.warning('存在未填写的必填项,请检查对应渠道的配置');
};
const selectedChannel = CHANNELS.find((channel) => channel.type === selectedType) ?? CHANNELS[0];
const selectedEnabled = enabledMap[selectedChannel.type];
const testingSelected = testMutation.isPending && testMutation.variables === selectedChannel.type;
if (isLoading) {
return (
<div className="flex justify-center items-center py-20">
@@ -246,53 +341,13 @@ const NotificationChannels = () => {
);
}
const fieldGridClass = 'grid gap-x-4 sm:grid-cols-2';
const renderChannelFields = (channel: ChannelDefinition) => {
switch (channel.type) {
case 'dingtalk':
return (
<div>
<div className="mb-4">
<Alert title="更改配置后,请点击下方的保存后再进行测试。" type="info" showIcon/>
</div>
<Form form={form} layout="vertical" onFinish={handleSave}>
<div className="flex w-full min-w-0 flex-col gap-4">
{/* 钉钉通知 */}
<Card
title={
<div className={'flex items-center gap-2'}>
<div>钉钉通知</div>
<div className={'text-xs font-normal'}>
了解更多:<a href="https://open.dingtalk.com/document/robots/custom-robot-access"
target="_blank"
rel="noopener noreferrer">https://open.dingtalk.com/document/robots/custom-robot-access</a>
</div>
</div>
}
type="inner"
extra={
<Button
type="link"
size="small"
icon={<TestTube size={14}/>}
onClick={() => handleTest('dingtalk')}
loading={testMutation.isPending}
disabled={!dingtalkEnabled}
>
测试
</Button>
}
>
<Form.Item label="启用钉钉通知" name="dingtalkEnabled" valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.dingtalkEnabled !== currentValues.dingtalkEnabled
}
>
{({getFieldValue}) =>
getFieldValue('dingtalkEnabled') ? (
<>
<div className={fieldGridClass}>
<Form.Item
label="访问令牌 (Access Token)"
name="dingtalkSecretKey"
@@ -311,48 +366,10 @@ const NotificationChannels = () => {
>
<Input.Password placeholder="SEC 开头的加签密钥"/>
</Form.Item>
</>
) : null
}
</Form.Item>
</Card>
{/* 企业微信通知 */}
<Card
title={
<div className={'flex items-center gap-2'}>
<div>企业微信通知</div>
<div className={'text-xs font-normal'}>
了解更多:<a href="https://work.weixin.qq.com/api/doc/90000/90136/91770"
target="_blank"
rel="noopener noreferrer">https://work.weixin.qq.com/api/doc/90000/90136/91770</a>
</div>
</div>
}
type="inner"
extra={
<Button
type="link"
size="small"
icon={<TestTube size={14}/>}
onClick={() => handleTest('wecom')}
loading={testMutation.isPending}
disabled={!wecomEnabled}
>
测试
</Button>
}
>
<Form.Item label="启用企业微信通知" name="wecomEnabled" valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.wecomEnabled !== currentValues.wecomEnabled}
>
{({getFieldValue}) =>
getFieldValue('wecomEnabled') ? (
);
case 'wecom':
return (
<Form.Item
label="Webhook Key"
name="wecomSecretKey"
@@ -364,48 +381,10 @@ const NotificationChannels = () => {
>
<Input placeholder="输入 Webhook Key"/>
</Form.Item>
) : null
}
</Form.Item>
</Card>
{/* 企业微信应用通知 */}
<Card
title={
<div className={'flex items-center gap-2'}>
<div>企业微信应用通知</div>
<div className={'text-xs font-normal'}>
了解更多:<a href="https://developer.work.weixin.qq.com/document/path/90236"
target="_blank"
rel="noopener noreferrer">https://developer.work.weixin.qq.com/document/path/90236</a>
</div>
</div>
}
type="inner"
extra={
<Button
type="link"
size="small"
icon={<TestTube size={14}/>}
onClick={() => handleTest('wecomApp')}
loading={testMutation.isPending}
disabled={!wecomAppEnabled}
>
测试
</Button>
}
>
<Form.Item label="启用企业微信应用通知" name="wecomAppEnabled" valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.wecomAppEnabled !== currentValues.wecomAppEnabled}
>
{({getFieldValue}) =>
getFieldValue('wecomAppEnabled') ? (
<>
);
case 'wecomApp':
return (
<div className="space-y-0">
<Form.Item
label="origin"
name="wecomAppOrigin"
@@ -415,6 +394,7 @@ const NotificationChannels = () => {
>
<Input placeholder="https://qyapi.weixin.qq.com"/>
</Form.Item>
<div className={fieldGridClass}>
<Form.Item
label="corpid"
name="wecomAppCorpId"
@@ -423,14 +403,6 @@ const NotificationChannels = () => {
>
<Input placeholder="输入您的企业的corpid"/>
</Form.Item>
<Form.Item
label="corpsecret"
name="wecomAppCorpSecret"
rules={[{required: true, message: '请输入企业微信应用的corpsecret'}]}
tooltip="企业微信应用的corpsecret"
>
<Input.Password placeholder="输入您的企业应用的corpsecret"/>
</Form.Item>
<Form.Item
label="agentid"
name="wecomAppAgentId"
@@ -440,6 +412,14 @@ const NotificationChannels = () => {
<InputNumber style={{width: '100%'}}
placeholder="输入您的企业应用的agentid"/>
</Form.Item>
<Form.Item
label="corpsecret"
name="wecomAppCorpSecret"
rules={[{required: true, message: '请输入企业微信应用的corpsecret'}]}
tooltip="企业微信应用的corpsecret"
>
<Input.Password placeholder="输入您的企业应用的corpsecret"/>
</Form.Item>
<Form.Item
label="touser"
name="wecomAppToUser"
@@ -449,53 +429,12 @@ const NotificationChannels = () => {
>
<Input placeholder="输入接收告警消息的用户,全部可填@all"/>
</Form.Item>
</>
) : null
}
</Form.Item>
</Card>
{/* 飞书通知 */}
<Card
title={
<div className={'flex items-center gap-2'}>
<div>飞书通知</div>
<div className={'text-xs font-normal'}>
点击 <a
href="https://www.feishu.cn/hc/zh-CN/articles/360024984973-%E5%9C%A8%E7%BE%A4%E7%BB%84%E4%B8%AD%E4%BD%BF%E7%94%A8%E6%9C%BA%E5%99%A8%E4%BA%BA"
target="_blank"
rel="noopener noreferrer">这里</a>
了解如何获取 Webhook URL。
</div>
</div>
}
type="inner"
extra={
<Button
type="link"
size="small"
icon={<TestTube size={14}/>}
onClick={() => handleTest('feishu')}
loading={testMutation.isPending}
disabled={!feishuEnabled}
>
测试
</Button>
}
>
<Form.Item label="启用飞书通知" name="feishuEnabled" valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.feishuEnabled !== currentValues.feishuEnabled
}
>
{({getFieldValue}) =>
getFieldValue('feishuEnabled') ? (
<>
);
case 'feishu':
return (
<div className={fieldGridClass}>
<Form.Item
label="Webhook Token"
name="feishuSecretKey"
@@ -514,51 +453,11 @@ const NotificationChannels = () => {
>
<Input.Password placeholder="输入签名密钥"/>
</Form.Item>
</>
) : null
}
</Form.Item>
</Card>
{/* Telegram 通知 */}
<Card
title={
<div className={'flex items-center gap-2'}>
<div>Telegram 通知</div>
<div className={'text-xs font-normal'}>
了解更多:<a href="https://core.telegram.org/bots/api"
target="_blank"
rel="noopener noreferrer">https://core.telegram.org/bots/api</a>
</div>
</div>
}
type="inner"
extra={
<Button
type="link"
size="small"
icon={<TestTube size={14}/>}
onClick={() => handleTest('telegram')}
loading={testMutation.isPending}
disabled={!telegramEnabled}
>
测试
</Button>
}
>
<Form.Item label="启用 Telegram 通知" name="telegramEnabled" valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.telegramEnabled !== currentValues.telegramEnabled
}
>
{({getFieldValue}) =>
getFieldValue('telegramEnabled') ? (
<>
);
case 'telegram':
return (
<div className={fieldGridClass}>
<Form.Item
label="Bot Token"
name="telegramBotToken"
@@ -578,49 +477,12 @@ const NotificationChannels = () => {
>
<Input placeholder="输入 Chat ID,例如:123456789"/>
</Form.Item>
</>
) : null
}
</Form.Item>
</Card>
{/* 邮件通知 */}
<Card
title={
<div className={'flex items-center gap-2'}>
<div>邮件通知</div>
<div className={'text-xs font-normal text-gray-500'}>
使用 SMTP 协议发送邮件告警
</div>
</div>
}
type="inner"
extra={
<Button
type="link"
size="small"
icon={<TestTube size={14}/>}
onClick={() => handleTest('email')}
loading={testMutation.isPending}
disabled={!emailEnabled}
>
测试
</Button>
}
>
<Form.Item label="启用邮件通知" name="emailEnabled" valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.emailEnabled !== currentValues.emailEnabled
}
>
{({getFieldValue}) =>
getFieldValue('emailEnabled') ? (
<>
);
case 'email':
return (
<div className="space-y-0">
<div className={fieldGridClass}>
<Form.Item
label="SMTP 服务器"
name="emailSmtpHost"
@@ -648,14 +510,6 @@ const NotificationChannels = () => {
>
<Input placeholder="your-email@example.com"/>
</Form.Item>
<Form.Item
label="邮箱密码/授权码"
name="emailPassword"
rules={[{required: true, message: '请输入邮箱密码或授权码'}]}
tooltip="某些邮件服务商(如 Gmail、QQ 邮箱)需要使用授权码而非密码"
>
<Input.Password placeholder="输入邮箱密码或授权码"/>
</Form.Item>
<Form.Item
label="收件人邮箱"
name="emailToEmail"
@@ -667,6 +521,14 @@ const NotificationChannels = () => {
>
<Input placeholder="receiver@example.com"/>
</Form.Item>
<Form.Item
label="邮箱密码/授权码"
name="emailPassword"
rules={[{required: true, message: '请输入邮箱密码或授权码'}]}
tooltip="某些邮件服务商(如 Gmail、QQ 邮箱)需要使用授权码而非密码"
>
<Input.Password placeholder="输入邮箱密码或授权码"/>
</Form.Item>
<Form.Item
label="邮件主题"
name="emailSubject"
@@ -674,42 +536,12 @@ const NotificationChannels = () => {
>
<Input placeholder="Pika 告警通知"/>
</Form.Item>
</>
) : null
}
</Form.Item>
</Card>
{/* 自定义 Webhook */}
<Card
title="自定义 Webhook"
type="inner"
extra={
<Button
type="link"
size="small"
icon={<TestTube size={14}/>}
onClick={() => handleTest('webhook')}
loading={testMutation.isPending}
disabled={!webhookEnabled}
>
测试
</Button>
}
>
<Form.Item label="启用自定义 Webhook" name="webhookEnabled" valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.webhookEnabled !== currentValues.webhookEnabled
}
>
{({getFieldValue}) =>
getFieldValue('webhookEnabled') ? (
<>
</div>
</div>
);
case 'webhook':
return (
<div className="space-y-0">
<Form.Item
label="Webhook URL"
name="webhookUrl"
@@ -823,22 +655,149 @@ const NotificationChannels = () => {
)}
</Form.List>
</Form.Item>
</>
) : null
</div>
);
}
};
const SelectedIcon = selectedChannel.icon;
return (
<Form
form={form}
layout="vertical"
onFinish={handleSave}
onFinishFailed={({errorFields}) => handleValidateFailed(errorFields)}
onValuesChange={() => setDirty(true)}
>
<div className="grid items-start gap-6 xl:grid-cols-[248px_minmax(0,1fr)]">
{/* 渠道列表 */}
<div>
<div className="mb-2 px-2 text-xs text-[#646a73] dark:text-[#9ba1ab]">
点击渠道查看配置,开关控制启用状态
</div>
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-1">
{CHANNELS.map((channel) => {
const Icon = channel.icon;
const enabled = enabledMap[channel.type];
const selected = channel.type === selectedType;
return (
<div
key={channel.type}
className={cn(
'flex min-w-0 items-center gap-2 rounded-[10px] px-2 py-1.5 transition-colors',
selected
? 'bg-[#eaf2ff] dark:bg-[#1677ff]/15'
: 'hover:bg-[#f5f6f8] dark:hover:bg-[#20242c]',
)}
>
<button
type="button"
onClick={() => setSelectedType(channel.type)}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 border-0 bg-transparent p-0 text-left"
aria-current={selected ? 'true' : undefined}
>
<span className={cn(
'grid h-9 w-9 shrink-0 place-items-center rounded-lg transition-colors',
selected
? 'bg-white text-[#145dcc] dark:bg-[#2a3038] dark:text-[#75adff]'
: 'bg-[#f0f2f5] text-[#646a73] dark:bg-[#20242c] dark:text-[#9ba1ab]',
)}>
<Icon size={16}/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[#1f2329] dark:text-[#e6e8ec]">
{channel.name}
</span>
<span className="block truncate text-xs text-[#98a0ab] dark:text-[#7d8590]">
{enabled ? '已启用' : '未启用'}
</span>
</span>
</button>
<Form.Item noStyle name={channel.enabledField} valuePropName="checked">
<Switch size="small" aria-label={`启用${channel.name}`}/>
</Form.Item>
</div>
);
})}
</div>
</div>
<NotificationCustomHelp/>
</Card>
{/* 选中渠道的配置 */}
<div className="min-w-0">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400">
<SelectedIcon size={20}/>
</span>
<div>
<div className="flex items-center gap-2">
<span className="text-[15px] font-semibold text-[#1f2329] dark:text-[#e6e8ec]">
{selectedChannel.name}
</span>
<Tag color={selectedEnabled ? 'success' : 'default'} style={{margin: 0}}>
{selectedEnabled ? '已启用' : '未启用'}
</Tag>
</div>
<div className="mt-0.5 text-xs text-[#646a73] dark:text-[#9ba1ab]">
{selectedChannel.description}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{selectedChannel.docsUrl && (
<Button
icon={<ExternalLink size={14}/>}
href={selectedChannel.docsUrl}
target="_blank"
rel="noopener noreferrer"
>
接入文档
</Button>
)}
<Button
icon={<TestTube size={14}/>}
onClick={() => testMutation.mutate(selectedChannel.type)}
loading={testingSelected}
disabled={!selectedEnabled || dirty}
title={dirty ? '配置已修改,请先保存再测试' : undefined}
>
发送测试
</Button>
</div>
</div>
<Form.Item>
<div className="my-4 border-t border-[#e8ebf0] dark:border-[#272b33]"/>
{!selectedEnabled && (
<div className="flex items-center gap-2 rounded-lg bg-[#f5f6f8] px-3.5 py-2.5 text-xs text-[#646a73] dark:bg-[#1c2028] dark:text-[#9ba1ab]">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-60"/>
当前渠道未启用,打开左侧的开关后即可编辑配置。
</div>
)}
{/* 已启用渠道的表单项保持挂载(仅隐藏未选中的),保证校验行为一致 */}
{CHANNELS.filter((channel) => enabledMap[channel.type]).map((channel) => (
<div key={channel.type} className={channel.type === selectedType ? undefined : 'hidden'}>
{renderChannelFields(channel)}
</div>
))}
{selectedType === 'webhook' && <NotificationCustomHelp/>}
<div className="mt-5 flex flex-wrap items-center gap-3 border-t border-[#e8ebf0] pt-4 dark:border-[#272b33]">
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
保存配置
</Button>
</Form.Item>
{dirty && (
<span className="text-xs text-amber-600 dark:text-amber-400">
配置尚未保存,保存后才能发送测试通知。
</span>
)}
</div>
</div>
</div>
</Form>
</div>
);
};
+11 -14
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,9 +137,8 @@ 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">
<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>
@@ -150,9 +150,9 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
<InputNumber min={30} max={86400}/>
</Form.Item>
</div>
</Card>
</SettingsSection>
<Card title="IPv4 配置" type="inner">
<SettingsSection title="IPv4 配置">
<Form.Item label="启用 IPv4" name="ipv4Enabled" valuePropName="checked">
<Switch/>
</Form.Item>
@@ -193,9 +193,9 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
>
<Input.TextArea rows={6} placeholder="每行一个 IPv4 API"/>
</Form.Item>
</Card>
</SettingsSection>
<Card title="IPv6 配置" type="inner">
<SettingsSection title="IPv6 配置">
<Form.Item label="启用 IPv6" name="ipv6Enabled" valuePropName="checked">
<Switch/>
</Form.Item>
@@ -236,10 +236,9 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
>
<Input.TextArea rows={6} placeholder="每行一个 IPv6 API"/>
</Form.Item>
</Card>
</SettingsSection>
<Form.Item>
<Space>
<SettingsActions>
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
保存配置
</Button>
@@ -249,9 +248,7 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
<Button onClick={handleUseDefaults}>
使用默认 API
</Button>
</Space>
</Form.Item>
</div>
</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>
);
+16 -21
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,12 +143,12 @@ const SystemConfigComponent = () => {
return (
<div>
<Form form={form} layout="vertical" onFinish={handleSave}>
<div className="flex w-full min-w-0 flex-col gap-4">
<Card
<SettingsSection
title="系统基本信息"
type="inner"
divided={false}
description="展示在公开页面顶部的系统名称、Logo 与底部备案信息。"
>
<div className={'flex items-center gap-2'}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Form.Item
label="系统英文名称"
name="systemNameEn"
@@ -223,7 +224,6 @@ const SystemConfigComponent = () => {
label="系统 Logo"
tooltip="上传系统 Logo,建议使用正方形图片,尺寸为 256x256 或更大,文件大小不超过 500KB"
>
<Space orientation="vertical" className="w-full">
<Upload
accept="image/*"
showUploadList={false}
@@ -234,14 +234,14 @@ const SystemConfigComponent = () => {
{uploading ? '处理中...' : '上传 Logo'}
</Button>
</Upload>
</Space>
</Form.Item>
</Card>
</SettingsSection>
<Card
<SettingsSection
title="自定义代码"
type="inner"
description="将注入到公开页面顶部的 style 与 script 标签中,修改后刷新公开页面生效。"
>
<div className="grid gap-x-4 lg:grid-cols-2">
<Form.Item
label="自定义 CSS"
name="customCSS"
@@ -263,12 +263,10 @@ const SystemConfigComponent = () => {
rows={6}
/>
</Form.Item>
</Card>
</div>
</SettingsSection>
<Card
title="预览效果"
type="inner"
>
<SettingsSection title="预览效果">
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) => {
const systemNameEn = getFieldValue('systemNameEn') || '';
@@ -314,19 +312,16 @@ const SystemConfigComponent = () => {
);
}}
</Form.Item>
</Card>
</SettingsSection>
<Form.Item>
<Space>
<SettingsActions>
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
保存配置
</Button>
<Button onClick={handleReset}>
恢复默认
</Button>
</Space>
</Form.Item>
</div>
</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="正在加载主题...">
+62 -52
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,49 +27,37 @@ export const defaultIPv6APIs = [
'https://v6.yinghualuo.cn/bejson',
];
const Settings = () => {
interface SettingsTab {
key: string;
label: string;
icon: LucideIcon;
content: ReactNode;
}
const [searchParams, setSearchParams] = useSearchParams({tab: 'system'});
const items = [
const settingsTabs: SettingsTab[] = [
{
key: 'system',
label: (
<span className="flex items-center gap-2">
<Settings2 size={16}/>
系统配置
</span>
),
children: <SystemConfig/>,
label: '系统配置',
icon: Settings2,
content: <SystemConfig/>,
},
{
key: 'themes',
label: (
<span className="flex items-center gap-2">
<Palette size={16}/>
主题管理
</span>
),
children: <ThemeSettings/>,
label: '主题管理',
icon: Palette,
content: <ThemeSettings/>,
},
{
key: 'channels',
label: (
<span className="flex items-center gap-2">
<MessageSquare size={16}/>
通知渠道
</span>
),
children: <NotificationChannels/>,
label: '通知渠道',
icon: MessageSquare,
content: <NotificationChannels/>,
},
{
key: 'public-ip',
label: (
<span className="flex items-center gap-2">
<Wifi size={16}/>
公网 IP 采集
</span>
),
children: (
label: '公网 IP 采集',
icon: Wifi,
content: (
<PublicIPConfig
defaultIPv4APIs={defaultIPv4APIs}
defaultIPv6APIs={defaultIPv6APIs}
@@ -74,37 +66,55 @@ const Settings = () => {
},
{
key: 'alert',
label: (
<span className="flex items-center gap-2">
<Bell size={16}/>
告警规则
</span>
),
children: <AlertSettings/>,
label: '告警规则',
icon: Bell,
content: <AlertSettings/>,
},
];
];
const activeKey = searchParams.get('tab') || 'system';
const activeItem = items.find((item) => item.key === activeKey) || items[0];
const Settings = () => {
const [searchParams, setSearchParams] = useSearchParams({tab: 'system'});
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) => (
<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={item.key}
key={tab.key}
type="button"
className={item.key === activeKey
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: item.key})}
aria-current={item.key === activeKey ? 'page' : undefined}
onClick={() => setSearchParams({tab: tab.key})}
aria-current={active ? 'page' : undefined}
>
{item.label}
<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>
<div className="min-w-0">{activeItem.children}</div>
<PagePanel className="min-w-0">
{activeTab.content}
</PagePanel>
</div>
</div>
);
};