Files
new-api-hunter/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js
Apple\Apple 882cdbd34a 🎛️ feat(dashboard): add per-panel enable switches & conditional backend payload
Backend:
• ConsoleSetting
  - Introduce `ApiInfoEnabled`, `UptimeKumaEnabled`, `AnnouncementsEnabled`, `FAQEnabled` (default true).
• misc.GetStatus
  - Refactor to build response map dynamically.
  - Return the four *_enabled flags.
  - Only append `api_info`, `announcements`, `faq` when their respective flags are true.

Frontend:
• Detail page
  - Remove all `self_use_mode_enabled` checks.
  - Render API, Announcement, FAQ and Uptime panels based on the new *_enabled flags.
• Dashboard → Settings
  - Added `Switch` controls in:
    · SettingsAPIInfo.js
    · SettingsAnnouncements.js
    · SettingsFAQ.js
    · SettingsUptimeKuma.js
  - Each switch persists its state via `/api/option` to the corresponding
    `console_setting.<panel>_enabled` key and reflects current status on load.
  - DashboardSetting.js now initialises and refreshes the four *_enabled keys so
    child components receive accurate panel states.

Fixes:
• Switches previously defaulted to “on” because *_enabled keys were missing.
  They are now included, ensuring correct visual state when panels are disabled.

No breaking changes; existing functionality remains untouched aside from the
new per-panel visibility control.
2025-06-14 01:39:23 +08:00

510 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from 'react';
import {
Button,
Space,
Table,
Form,
Typography,
Empty,
Divider,
Avatar,
Modal,
Tag,
Switch
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
Plus,
Edit,
Trash2,
Save,
Settings
} from 'lucide-react';
import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
const SettingsAPIInfo = ({ options, refresh }) => {
const { t } = useTranslation();
const [apiInfoList, setApiInfoList] = useState([]);
const [showApiModal, setShowApiModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingApi, setDeletingApi] = useState(null);
const [editingApi, setEditingApi] = useState(null);
const [modalLoading, setModalLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [apiForm, setApiForm] = useState({
url: '',
description: '',
route: '',
color: 'blue'
});
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
// 面板启用状态 state
const [panelEnabled, setPanelEnabled] = useState(true);
const colorOptions = [
{ value: 'blue', label: 'blue' },
{ value: 'green', label: 'green' },
{ value: 'cyan', label: 'cyan' },
{ value: 'purple', label: 'purple' },
{ value: 'pink', label: 'pink' },
{ value: 'red', label: 'red' },
{ value: 'orange', label: 'orange' },
{ value: 'amber', label: 'amber' },
{ value: 'yellow', label: 'yellow' },
{ value: 'lime', label: 'lime' },
{ value: 'light-green', label: 'light-green' },
{ value: 'teal', label: 'teal' },
{ value: 'light-blue', label: 'light-blue' },
{ value: 'indigo', label: 'indigo' },
{ value: 'violet', label: 'violet' },
{ value: 'grey', label: 'grey' }
];
const updateOption = async (key, value) => {
const res = await API.put('/api/option/', {
key,
value,
});
const { success, message } = res.data;
if (success) {
showSuccess('API信息已更新');
if (refresh) refresh();
} else {
showError(message);
}
};
const submitApiInfo = async () => {
try {
setLoading(true);
const apiInfoJson = JSON.stringify(apiInfoList);
await updateOption('console_setting.api_info', apiInfoJson);
setHasChanges(false);
} catch (error) {
console.error('API信息更新失败', error);
showError('API信息更新失败');
} finally {
setLoading(false);
}
};
const handleAddApi = () => {
setEditingApi(null);
setApiForm({
url: '',
description: '',
route: '',
color: 'blue'
});
setShowApiModal(true);
};
const handleEditApi = (api) => {
setEditingApi(api);
setApiForm({
url: api.url,
description: api.description,
route: api.route,
color: api.color
});
setShowApiModal(true);
};
const handleDeleteApi = (api) => {
setDeletingApi(api);
setShowDeleteModal(true);
};
const confirmDeleteApi = () => {
if (deletingApi) {
const newList = apiInfoList.filter(api => api.id !== deletingApi.id);
setApiInfoList(newList);
setHasChanges(true);
showSuccess('API信息已删除请及时点击“保存设置”进行保存');
}
setShowDeleteModal(false);
setDeletingApi(null);
};
const handleSaveApi = async () => {
if (!apiForm.url || !apiForm.route || !apiForm.description) {
showError('请填写完整的API信息');
return;
}
try {
setModalLoading(true);
let newList;
if (editingApi) {
newList = apiInfoList.map(api =>
api.id === editingApi.id
? { ...api, ...apiForm }
: api
);
} else {
const newId = Math.max(...apiInfoList.map(api => api.id), 0) + 1;
const newApi = {
id: newId,
...apiForm
};
newList = [...apiInfoList, newApi];
}
setApiInfoList(newList);
setHasChanges(true);
setShowApiModal(false);
showSuccess(editingApi ? 'API信息已更新请及时点击“保存设置”进行保存' : 'API信息已添加请及时点击“保存设置”进行保存');
} catch (error) {
showError('操作失败: ' + error.message);
} finally {
setModalLoading(false);
}
};
const parseApiInfo = (apiInfoStr) => {
if (!apiInfoStr) {
setApiInfoList([]);
return;
}
try {
const parsed = JSON.parse(apiInfoStr);
setApiInfoList(Array.isArray(parsed) ? parsed : []);
} catch (error) {
console.error('解析API信息失败:', error);
setApiInfoList([]);
}
};
useEffect(() => {
const apiInfoStr = options['console_setting.api_info'] ?? options.ApiInfo;
if (apiInfoStr !== undefined) {
parseApiInfo(apiInfoStr);
}
}, [options['console_setting.api_info'], options.ApiInfo]);
useEffect(() => {
const enabledStr = options['console_setting.api_info_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
}, [options['console_setting.api_info_enabled']]);
const handleToggleEnabled = async (checked) => {
const newValue = checked ? 'true' : 'false';
try {
const res = await API.put('/api/option/', {
key: 'console_setting.api_info_enabled',
value: newValue,
});
if (res.data.success) {
setPanelEnabled(checked);
showSuccess(t('设置已保存'));
refresh?.();
} else {
showError(res.data.message);
}
} catch (err) {
showError(err.message);
}
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
},
{
title: t('API地址'),
dataIndex: 'url',
render: (text, record) => (
<Tag
color={record.color}
className="!rounded-full"
style={{ maxWidth: '280px' }}
>
{text}
</Tag>
),
},
{
title: t('线路描述'),
dataIndex: 'route',
render: (text, record) => (
<Tag shape='circle'>
{text}
</Tag>
),
},
{
title: t('说明'),
dataIndex: 'description',
ellipsis: true,
render: (text, record) => (
<Tag shape='circle'>
{text || '-'}
</Tag>
),
},
{
title: t('颜色'),
dataIndex: 'color',
render: (color) => (
<Avatar
size="extra-extra-small"
color={color}
/>
),
},
{
title: t('操作'),
fixed: 'right',
width: 150,
render: (_, record) => (
<Space>
<Button
icon={<Edit size={14} />}
theme='light'
type='tertiary'
size='small'
className="!rounded-full"
onClick={() => handleEditApi(record)}
>
{t('编辑')}
</Button>
<Button
icon={<Trash2 size={14} />}
type='danger'
theme='light'
size='small'
className="!rounded-full"
onClick={() => handleDeleteApi(record)}
>
{t('删除')}
</Button>
</Space>
),
},
];
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
showError('请先选择要删除的API信息');
return;
}
const newList = apiInfoList.filter(api => !selectedRowKeys.includes(api.id));
setApiInfoList(newList);
setSelectedRowKeys([]);
setHasChanges(true);
showSuccess(`已删除 ${selectedRowKeys.length} 个API信息请及时点击“保存设置”进行保存`);
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Settings size={16} className="mr-2" />
<Text>{t('API信息管理可以配置多个API地址用于状态展示和负载均衡最多50个')}</Text>
</div>
</div>
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
theme='light'
type='primary'
icon={<Plus size={14} />}
className="!rounded-full w-full md:w-auto"
onClick={handleAddApi}
>
{t('添加API')}
</Button>
<Button
icon={<Trash2 size={14} />}
type='danger'
theme='light'
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
className="!rounded-full w-full md:w-auto"
>
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button>
<Button
icon={<Save size={14} />}
onClick={submitApiInfo}
loading={loading}
disabled={!hasChanges}
type='secondary'
className="!rounded-full w-full md:w-auto"
>
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<Switch
checked={panelEnabled}
onChange={handleToggleEnabled}
/>
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
</div>
</div>
);
// 计算当前页显示的数据
const getCurrentPageData = () => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return apiInfoList.slice(startIndex, endIndex);
};
const rowSelection = {
selectedRowKeys,
onChange: (selectedRowKeys, selectedRows) => {
setSelectedRowKeys(selectedRowKeys);
},
onSelect: (record, selected, selectedRows) => {
console.log(`选择行: ${selected}`, record);
},
onSelectAll: (selected, selectedRows) => {
console.log(`全选: ${selected}`, selectedRows);
},
getCheckboxProps: (record) => ({
disabled: false,
name: record.id,
}),
};
return (
<>
<Form.Section text={renderHeader()}>
<Table
columns={columns}
dataSource={getCurrentPageData()}
rowSelection={rowSelection}
rowKey="id"
scroll={{ x: 'max-content' }}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: apiInfoList.length,
showSizeChanger: true,
showQuickJumper: true,
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: apiInfoList.length,
}),
pageSizeOptions: ['5', '10', '20', '50'],
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
onShowSizeChange: (current, size) => {
setCurrentPage(1);
setPageSize(size);
}
}}
size='middle'
loading={loading}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('暂无API信息')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
/>
</Form.Section>
<Modal
title={editingApi ? t('编辑API') : t('添加API')}
visible={showApiModal}
onOk={handleSaveApi}
onCancel={() => setShowApiModal(false)}
okText={t('保存')}
cancelText={t('取消')}
className="rounded-xl"
confirmLoading={modalLoading}
>
<Form layout='vertical' initValues={apiForm} key={editingApi ? editingApi.id : 'new'}>
<Form.Input
field='url'
label={t('API地址')}
placeholder='https://api.example.com'
rules={[{ required: true, message: t('请输入API地址') }]}
onChange={(value) => setApiForm({ ...apiForm, url: value })}
/>
<Form.Input
field='route'
label={t('线路描述')}
placeholder={t('如:香港线路')}
rules={[{ required: true, message: t('请输入线路描述') }]}
onChange={(value) => setApiForm({ ...apiForm, route: value })}
/>
<Form.Input
field='description'
label={t('说明')}
placeholder={t('如:大带宽批量分析图片推荐')}
rules={[{ required: true, message: t('请输入说明') }]}
onChange={(value) => setApiForm({ ...apiForm, description: value })}
/>
<Form.Select
field='color'
label={t('标识颜色')}
optionList={colorOptions}
onChange={(value) => setApiForm({ ...apiForm, color: value })}
render={(option) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar
size="extra-extra-small"
color={option.value}
/>
{option.label}
</div>
)}
/>
</Form>
</Modal>
<Modal
title={t('确认删除')}
visible={showDeleteModal}
onOk={confirmDeleteApi}
onCancel={() => {
setShowDeleteModal(false);
setDeletingApi(null);
}}
okText={t('确认删除')}
cancelText={t('取消')}
type="warning"
className="rounded-xl"
okButtonProps={{
type: 'danger',
theme: 'solid'
}}
>
<Text>{t('确定要删除此API信息吗')}</Text>
</Modal>
</>
);
};
export default SettingsAPIInfo;