MultiKeyManageModal.jsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useState, useEffect } from 'react';
  16. import { useTranslation } from 'react-i18next';
  17. import {
  18. Modal,
  19. Button,
  20. Table,
  21. Tag,
  22. Typography,
  23. Space,
  24. Tooltip,
  25. Popconfirm,
  26. Empty,
  27. Spin,
  28. Select,
  29. Row,
  30. Col,
  31. Badge,
  32. Progress,
  33. Card
  34. } from '@douyinfe/semi-ui';
  35. import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
  36. import { API, showError, showSuccess, timestamp2string } from '../../../../helpers';
  37. const { Text } = Typography;
  38. const MultiKeyManageModal = ({
  39. visible,
  40. onCancel,
  41. channel,
  42. onRefresh
  43. }) => {
  44. const { t } = useTranslation();
  45. const [loading, setLoading] = useState(false);
  46. const [keyStatusList, setKeyStatusList] = useState([]);
  47. const [operationLoading, setOperationLoading] = useState({});
  48. // Pagination states
  49. const [currentPage, setCurrentPage] = useState(1);
  50. const [pageSize, setPageSize] = useState(10);
  51. const [total, setTotal] = useState(0);
  52. const [totalPages, setTotalPages] = useState(0);
  53. // Statistics states
  54. const [enabledCount, setEnabledCount] = useState(0);
  55. const [manualDisabledCount, setManualDisabledCount] = useState(0);
  56. const [autoDisabledCount, setAutoDisabledCount] = useState(0);
  57. // Filter states
  58. const [statusFilter, setStatusFilter] = useState(null); // null=all, 1=enabled, 2=manual_disabled, 3=auto_disabled
  59. // Load key status data
  60. const loadKeyStatus = async (page = currentPage, size = pageSize, status = statusFilter) => {
  61. if (!channel?.id) return;
  62. setLoading(true);
  63. try {
  64. const requestData = {
  65. channel_id: channel.id,
  66. action: 'get_key_status',
  67. page: page,
  68. page_size: size
  69. };
  70. // Add status filter if specified
  71. if (status !== null) {
  72. requestData.status = status;
  73. }
  74. const res = await API.post('/api/channel/multi_key/manage', requestData);
  75. if (res.data.success) {
  76. const data = res.data.data;
  77. setKeyStatusList(data.keys || []);
  78. setTotal(data.total || 0);
  79. setCurrentPage(data.page || 1);
  80. setPageSize(data.page_size || 10);
  81. setTotalPages(data.total_pages || 0);
  82. // Update statistics (these are always the overall statistics)
  83. setEnabledCount(data.enabled_count || 0);
  84. setManualDisabledCount(data.manual_disabled_count || 0);
  85. setAutoDisabledCount(data.auto_disabled_count || 0);
  86. } else {
  87. showError(res.data.message);
  88. }
  89. } catch (error) {
  90. console.error(error);
  91. showError(t('获取密钥状态失败'));
  92. } finally {
  93. setLoading(false);
  94. }
  95. };
  96. // Disable a specific key
  97. const handleDisableKey = async (keyIndex) => {
  98. const operationId = `disable_${keyIndex}`;
  99. setOperationLoading(prev => ({ ...prev, [operationId]: true }));
  100. try {
  101. const res = await API.post('/api/channel/multi_key/manage', {
  102. channel_id: channel.id,
  103. action: 'disable_key',
  104. key_index: keyIndex
  105. });
  106. if (res.data.success) {
  107. showSuccess(t('密钥已禁用'));
  108. await loadKeyStatus(currentPage, pageSize); // Reload current page
  109. onRefresh && onRefresh(); // Refresh parent component
  110. } else {
  111. showError(res.data.message);
  112. }
  113. } catch (error) {
  114. showError(t('禁用密钥失败'));
  115. } finally {
  116. setOperationLoading(prev => ({ ...prev, [operationId]: false }));
  117. }
  118. };
  119. // Enable a specific key
  120. const handleEnableKey = async (keyIndex) => {
  121. const operationId = `enable_${keyIndex}`;
  122. setOperationLoading(prev => ({ ...prev, [operationId]: true }));
  123. try {
  124. const res = await API.post('/api/channel/multi_key/manage', {
  125. channel_id: channel.id,
  126. action: 'enable_key',
  127. key_index: keyIndex
  128. });
  129. if (res.data.success) {
  130. showSuccess(t('密钥已启用'));
  131. await loadKeyStatus(currentPage, pageSize); // Reload current page
  132. onRefresh && onRefresh(); // Refresh parent component
  133. } else {
  134. showError(res.data.message);
  135. }
  136. } catch (error) {
  137. showError(t('启用密钥失败'));
  138. } finally {
  139. setOperationLoading(prev => ({ ...prev, [operationId]: false }));
  140. }
  141. };
  142. // Enable all disabled keys
  143. const handleEnableAll = async () => {
  144. setOperationLoading(prev => ({ ...prev, enable_all: true }));
  145. try {
  146. const res = await API.post('/api/channel/multi_key/manage', {
  147. channel_id: channel.id,
  148. action: 'enable_all_keys'
  149. });
  150. if (res.data.success) {
  151. showSuccess(res.data.message || t('已启用所有密钥'));
  152. // Reset to first page after bulk operation
  153. setCurrentPage(1);
  154. await loadKeyStatus(1, pageSize);
  155. onRefresh && onRefresh(); // Refresh parent component
  156. } else {
  157. showError(res.data.message);
  158. }
  159. } catch (error) {
  160. showError(t('启用所有密钥失败'));
  161. } finally {
  162. setOperationLoading(prev => ({ ...prev, enable_all: false }));
  163. }
  164. };
  165. // Disable all enabled keys
  166. const handleDisableAll = async () => {
  167. setOperationLoading(prev => ({ ...prev, disable_all: true }));
  168. try {
  169. const res = await API.post('/api/channel/multi_key/manage', {
  170. channel_id: channel.id,
  171. action: 'disable_all_keys'
  172. });
  173. if (res.data.success) {
  174. showSuccess(res.data.message || t('已禁用所有密钥'));
  175. // Reset to first page after bulk operation
  176. setCurrentPage(1);
  177. await loadKeyStatus(1, pageSize);
  178. onRefresh && onRefresh(); // Refresh parent component
  179. } else {
  180. showError(res.data.message);
  181. }
  182. } catch (error) {
  183. showError(t('禁用所有密钥失败'));
  184. } finally {
  185. setOperationLoading(prev => ({ ...prev, disable_all: false }));
  186. }
  187. };
  188. // Delete all disabled keys
  189. const handleDeleteDisabledKeys = async () => {
  190. setOperationLoading(prev => ({ ...prev, delete_disabled: true }));
  191. try {
  192. const res = await API.post('/api/channel/multi_key/manage', {
  193. channel_id: channel.id,
  194. action: 'delete_disabled_keys'
  195. });
  196. if (res.data.success) {
  197. showSuccess(res.data.message);
  198. // Reset to first page after deletion as data structure might change
  199. setCurrentPage(1);
  200. await loadKeyStatus(1, pageSize);
  201. onRefresh && onRefresh(); // Refresh parent component
  202. } else {
  203. showError(res.data.message);
  204. }
  205. } catch (error) {
  206. showError(t('删除禁用密钥失败'));
  207. } finally {
  208. setOperationLoading(prev => ({ ...prev, delete_disabled: false }));
  209. }
  210. };
  211. // Handle page change
  212. const handlePageChange = (page) => {
  213. setCurrentPage(page);
  214. loadKeyStatus(page, pageSize);
  215. };
  216. // Handle page size change
  217. const handlePageSizeChange = (size) => {
  218. setPageSize(size);
  219. setCurrentPage(1); // Reset to first page
  220. loadKeyStatus(1, size);
  221. };
  222. // Handle status filter change
  223. const handleStatusFilterChange = (status) => {
  224. setStatusFilter(status);
  225. setCurrentPage(1); // Reset to first page when filter changes
  226. loadKeyStatus(1, pageSize, status);
  227. };
  228. // Effect to load data when modal opens
  229. useEffect(() => {
  230. if (visible && channel?.id) {
  231. setCurrentPage(1); // Reset to first page when opening
  232. loadKeyStatus(1, pageSize);
  233. }
  234. }, [visible, channel?.id]);
  235. // Reset pagination when modal closes
  236. useEffect(() => {
  237. if (!visible) {
  238. setCurrentPage(1);
  239. setKeyStatusList([]);
  240. setTotal(0);
  241. setTotalPages(0);
  242. setEnabledCount(0);
  243. setManualDisabledCount(0);
  244. setAutoDisabledCount(0);
  245. setStatusFilter(null); // Reset filter
  246. }
  247. }, [visible]);
  248. // Percentages for progress display
  249. const enabledPercent = total > 0 ? Math.round((enabledCount / total) * 100) : 0;
  250. const manualDisabledPercent = total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0;
  251. const autoDisabledPercent = total > 0 ? Math.round((autoDisabledCount / total) * 100) : 0;
  252. // 取消饼图:不再需要图表数据与配置
  253. // Get status tag component
  254. const renderStatusTag = (status) => {
  255. switch (status) {
  256. case 1:
  257. return <Tag color='green' shape='circle' size='small'>{t('已启用')}</Tag>;
  258. case 2:
  259. return <Tag color='red' shape='circle' size='small'>{t('已禁用')}</Tag>;
  260. case 3:
  261. return <Tag color='orange' shape='circle' size='small'>{t('自动禁用')}</Tag>;
  262. default:
  263. return <Tag color='grey' shape='circle' size='small'>{t('未知状态')}</Tag>;
  264. }
  265. };
  266. // Table columns definition
  267. const columns = [
  268. {
  269. title: t('索引'),
  270. dataIndex: 'index',
  271. render: (text) => `#${text}`,
  272. },
  273. // {
  274. // title: t('密钥预览'),
  275. // dataIndex: 'key_preview',
  276. // render: (text) => (
  277. // <Text code style={{ fontSize: '12px' }}>
  278. // {text}
  279. // </Text>
  280. // ),
  281. // },
  282. {
  283. title: t('状态'),
  284. dataIndex: 'status',
  285. render: (status) => renderStatusTag(status),
  286. },
  287. {
  288. title: t('禁用原因'),
  289. dataIndex: 'reason',
  290. render: (reason, record) => {
  291. if (record.status === 1 || !reason) {
  292. return <Text type='quaternary'>-</Text>;
  293. }
  294. return (
  295. <Tooltip content={reason}>
  296. <Text style={{ maxWidth: '200px', display: 'block' }} ellipsis>
  297. {reason}
  298. </Text>
  299. </Tooltip>
  300. );
  301. },
  302. },
  303. {
  304. title: t('禁用时间'),
  305. dataIndex: 'disabled_time',
  306. render: (time, record) => {
  307. if (record.status === 1 || !time) {
  308. return <Text type='quaternary'>-</Text>;
  309. }
  310. return (
  311. <Tooltip content={timestamp2string(time)}>
  312. <Text style={{ fontSize: '12px' }}>
  313. {timestamp2string(time)}
  314. </Text>
  315. </Tooltip>
  316. );
  317. },
  318. },
  319. {
  320. title: t('操作'),
  321. key: 'action',
  322. fixed: 'right',
  323. width: 100,
  324. render: (_, record) => (
  325. <Space>
  326. {record.status === 1 ? (
  327. <Button
  328. type='danger'
  329. size='small'
  330. loading={operationLoading[`disable_${record.index}`]}
  331. onClick={() => handleDisableKey(record.index)}
  332. >
  333. {t('禁用')}
  334. </Button>
  335. ) : (
  336. <Button
  337. type='primary'
  338. size='small'
  339. loading={operationLoading[`enable_${record.index}`]}
  340. onClick={() => handleEnableKey(record.index)}
  341. >
  342. {t('启用')}
  343. </Button>
  344. )}
  345. </Space>
  346. ),
  347. },
  348. ];
  349. return (
  350. <Modal
  351. title={
  352. <Space>
  353. <Text>{t('多密钥管理')}</Text>
  354. {channel?.name && (
  355. <Tag size='small' shape='circle' color='white'>{channel.name}</Tag>
  356. )}
  357. <Tag size='small' shape='circle' color='white'>
  358. {t('总密钥数')}: {total}
  359. </Tag>
  360. {channel?.channel_info?.multi_key_mode && (
  361. <Tag size='small' shape='circle' color='white'>
  362. {channel.channel_info.multi_key_mode === 'random' ? t('随机模式') : t('轮询模式')}
  363. </Tag>
  364. )}
  365. </Space>
  366. }
  367. visible={visible}
  368. onCancel={onCancel}
  369. width={900}
  370. footer={null}
  371. >
  372. <div className="flex flex-col mb-5">
  373. {/* Stats & Mode */}
  374. <div
  375. className="rounded-xl p-4 mb-3"
  376. style={{
  377. background: 'var(--semi-color-bg-1)',
  378. border: '1px solid var(--semi-color-border)'
  379. }}
  380. >
  381. <Row gutter={16} align="middle">
  382. <Col span={8}>
  383. <div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
  384. <div className="flex items-center gap-2 mb-2">
  385. <Badge dot type='success' />
  386. <Text type='tertiary'>{t('已启用')}</Text>
  387. </div>
  388. <div className="flex items-end gap-2 mb-2">
  389. <Text style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}>{enabledCount}</Text>
  390. <Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
  391. </div>
  392. <Progress percent={enabledPercent} showInfo={false} size="small" stroke="#22c55e" style={{ height: 6, borderRadius: 999 }} />
  393. </div>
  394. </Col>
  395. <Col span={8}>
  396. <div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
  397. <div className="flex items-center gap-2 mb-2">
  398. <Badge dot type='danger' />
  399. <Text type='tertiary'>{t('手动禁用')}</Text>
  400. </div>
  401. <div className="flex items-end gap-2 mb-2">
  402. <Text style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>{manualDisabledCount}</Text>
  403. <Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
  404. </div>
  405. <Progress percent={manualDisabledPercent} showInfo={false} size="small" stroke="#ef4444" style={{ height: 6, borderRadius: 999 }} />
  406. </div>
  407. </Col>
  408. <Col span={8}>
  409. <div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
  410. <div className="flex items-center gap-2 mb-2">
  411. <Badge dot type='warning' />
  412. <Text type='tertiary'>{t('自动禁用')}</Text>
  413. </div>
  414. <div className="flex items-end gap-2 mb-2">
  415. <Text style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}>{autoDisabledCount}</Text>
  416. <Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
  417. </div>
  418. <Progress percent={autoDisabledPercent} showInfo={false} size="small" stroke="#f59e0b" style={{ height: 6, borderRadius: 999 }} />
  419. </div>
  420. </Col>
  421. </Row>
  422. </div>
  423. {/* Table */}
  424. <div className="flex-1 flex flex-col min-h-0">
  425. <Spin spinning={loading}>
  426. <Card className='!rounded-xl'>
  427. <Table
  428. title={() => (
  429. <Row gutter={12} style={{ width: '100%' }}>
  430. <Col span={14}>
  431. <Row gutter={12} style={{ alignItems: 'center' }}>
  432. <Col>
  433. <Select
  434. value={statusFilter}
  435. onChange={handleStatusFilterChange}
  436. size='small'
  437. placeholder={t('全部状态')}
  438. >
  439. <Select.Option value={null}>{t('全部状态')}</Select.Option>
  440. <Select.Option value={1}>{t('已启用')}</Select.Option>
  441. <Select.Option value={2}>{t('手动禁用')}</Select.Option>
  442. <Select.Option value={3}>{t('自动禁用')}</Select.Option>
  443. </Select>
  444. </Col>
  445. </Row>
  446. </Col>
  447. <Col span={10} style={{ display: 'flex', justifyContent: 'flex-end' }}>
  448. <Space>
  449. <Button
  450. size='small'
  451. type='tertiary'
  452. onClick={() => loadKeyStatus(currentPage, pageSize)}
  453. loading={loading}
  454. >
  455. {t('刷新')}
  456. </Button>
  457. {(manualDisabledCount + autoDisabledCount) > 0 && (
  458. <Popconfirm
  459. title={t('确定要启用所有密钥吗?')}
  460. onConfirm={handleEnableAll}
  461. position={'topRight'}
  462. >
  463. <Button
  464. size='small'
  465. type='primary'
  466. loading={operationLoading.enable_all}
  467. >
  468. {t('启用全部')}
  469. </Button>
  470. </Popconfirm>
  471. )}
  472. {enabledCount > 0 && (
  473. <Popconfirm
  474. title={t('确定要禁用所有的密钥吗?')}
  475. onConfirm={handleDisableAll}
  476. okType={'danger'}
  477. position={'topRight'}
  478. >
  479. <Button
  480. size='small'
  481. type='danger'
  482. loading={operationLoading.disable_all}
  483. >
  484. {t('禁用全部')}
  485. </Button>
  486. </Popconfirm>
  487. )}
  488. <Popconfirm
  489. title={t('确定要删除所有已自动禁用的密钥吗?')}
  490. content={t('此操作不可撤销,将永久删除已自动禁用的密钥')}
  491. onConfirm={handleDeleteDisabledKeys}
  492. okType={'danger'}
  493. position={'topRight'}
  494. >
  495. <Button
  496. size='small'
  497. type='warning'
  498. loading={operationLoading.delete_disabled}
  499. >
  500. {t('删除自动禁用密钥')}
  501. </Button>
  502. </Popconfirm>
  503. </Space>
  504. </Col>
  505. </Row>
  506. )}
  507. columns={columns}
  508. dataSource={keyStatusList}
  509. pagination={{
  510. currentPage: currentPage,
  511. pageSize: pageSize,
  512. total: total,
  513. showSizeChanger: true,
  514. showQuickJumper: true,
  515. pageSizeOpts: [10, 20, 50, 100],
  516. onChange: (page, size) => {
  517. setCurrentPage(page);
  518. loadKeyStatus(page, size);
  519. },
  520. onShowSizeChange: (current, size) => {
  521. setCurrentPage(1);
  522. handlePageSizeChange(size);
  523. }
  524. }}
  525. size='small'
  526. bordered={false}
  527. rowKey='index'
  528. scroll={{ x: 'max-content' }}
  529. empty={
  530. <Empty
  531. image={<IllustrationNoResult style={{ width: 140, height: 140 }} />}
  532. darkModeImage={<IllustrationNoResultDark style={{ width: 140, height: 140 }} />}
  533. title={t('暂无密钥数据')}
  534. description={t('请检查渠道配置或刷新重试')}
  535. style={{ padding: 30 }}
  536. />
  537. }
  538. />
  539. </Card>
  540. </Spin>
  541. </div>
  542. </div>
  543. </Modal>
  544. );
  545. };
  546. export default MultiKeyManageModal;