| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | import React, { useState, useEffect } from 'react'; |
| | import { useTranslation } from 'react-i18next'; |
| | import { |
| | Modal, |
| | Button, |
| | Table, |
| | Tag, |
| | Typography, |
| | Space, |
| | Tooltip, |
| | Popconfirm, |
| | Empty, |
| | Spin, |
| | Select, |
| | Row, |
| | Col, |
| | Badge, |
| | Progress, |
| | Card, |
| | } from '@douyinfe/semi-ui'; |
| | import { |
| | IllustrationNoResult, |
| | IllustrationNoResultDark, |
| | } from '@douyinfe/semi-illustrations'; |
| | import { |
| | API, |
| | showError, |
| | showSuccess, |
| | timestamp2string, |
| | } from '../../../../helpers'; |
| |
|
| | const { Text } = Typography; |
| |
|
| | const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => { |
| | const { t } = useTranslation(); |
| | const [loading, setLoading] = useState(false); |
| | const [keyStatusList, setKeyStatusList] = useState([]); |
| | const [operationLoading, setOperationLoading] = useState({}); |
| |
|
| | |
| | const [currentPage, setCurrentPage] = useState(1); |
| | const [pageSize, setPageSize] = useState(10); |
| | const [total, setTotal] = useState(0); |
| | const [totalPages, setTotalPages] = useState(0); |
| |
|
| | |
| | const [enabledCount, setEnabledCount] = useState(0); |
| | const [manualDisabledCount, setManualDisabledCount] = useState(0); |
| | const [autoDisabledCount, setAutoDisabledCount] = useState(0); |
| |
|
| | |
| | const [statusFilter, setStatusFilter] = useState(null); |
| |
|
| | |
| | const loadKeyStatus = async ( |
| | page = currentPage, |
| | size = pageSize, |
| | status = statusFilter, |
| | ) => { |
| | if (!channel?.id) return; |
| |
|
| | setLoading(true); |
| | try { |
| | const requestData = { |
| | channel_id: channel.id, |
| | action: 'get_key_status', |
| | page: page, |
| | page_size: size, |
| | }; |
| |
|
| | |
| | if (status !== null) { |
| | requestData.status = status; |
| | } |
| |
|
| | const res = await API.post('/api/channel/multi_key/manage', requestData); |
| |
|
| | if (res.data.success) { |
| | const data = res.data.data; |
| | setKeyStatusList(data.keys || []); |
| | setTotal(data.total || 0); |
| | setCurrentPage(data.page || 1); |
| | setPageSize(data.page_size || 10); |
| | setTotalPages(data.total_pages || 0); |
| |
|
| | |
| | setEnabledCount(data.enabled_count || 0); |
| | setManualDisabledCount(data.manual_disabled_count || 0); |
| | setAutoDisabledCount(data.auto_disabled_count || 0); |
| | } else { |
| | showError(res.data.message); |
| | } |
| | } catch (error) { |
| | console.error(error); |
| | showError(t('获取密钥状态失败')); |
| | } finally { |
| | setLoading(false); |
| | } |
| | }; |
| |
|
| | |
| | const handleDisableKey = async (keyIndex) => { |
| | const operationId = `disable_${keyIndex}`; |
| | setOperationLoading((prev) => ({ ...prev, [operationId]: true })); |
| |
|
| | try { |
| | const res = await API.post('/api/channel/multi_key/manage', { |
| | channel_id: channel.id, |
| | action: 'disable_key', |
| | key_index: keyIndex, |
| | }); |
| |
|
| | if (res.data.success) { |
| | showSuccess(t('密钥已禁用')); |
| | await loadKeyStatus(currentPage, pageSize); |
| | onRefresh && onRefresh(); |
| | } else { |
| | showError(res.data.message); |
| | } |
| | } catch (error) { |
| | showError(t('禁用密钥失败')); |
| | } finally { |
| | setOperationLoading((prev) => ({ ...prev, [operationId]: false })); |
| | } |
| | }; |
| |
|
| | |
| | const handleEnableKey = async (keyIndex) => { |
| | const operationId = `enable_${keyIndex}`; |
| | setOperationLoading((prev) => ({ ...prev, [operationId]: true })); |
| |
|
| | try { |
| | const res = await API.post('/api/channel/multi_key/manage', { |
| | channel_id: channel.id, |
| | action: 'enable_key', |
| | key_index: keyIndex, |
| | }); |
| |
|
| | if (res.data.success) { |
| | showSuccess(t('密钥已启用')); |
| | await loadKeyStatus(currentPage, pageSize); |
| | onRefresh && onRefresh(); |
| | } else { |
| | showError(res.data.message); |
| | } |
| | } catch (error) { |
| | showError(t('启用密钥失败')); |
| | } finally { |
| | setOperationLoading((prev) => ({ ...prev, [operationId]: false })); |
| | } |
| | }; |
| |
|
| | |
| | const handleEnableAll = async () => { |
| | setOperationLoading((prev) => ({ ...prev, enable_all: true })); |
| |
|
| | try { |
| | const res = await API.post('/api/channel/multi_key/manage', { |
| | channel_id: channel.id, |
| | action: 'enable_all_keys', |
| | }); |
| |
|
| | if (res.data.success) { |
| | showSuccess(res.data.message || t('已启用所有密钥')); |
| | |
| | setCurrentPage(1); |
| | await loadKeyStatus(1, pageSize); |
| | onRefresh && onRefresh(); |
| | } else { |
| | showError(res.data.message); |
| | } |
| | } catch (error) { |
| | showError(t('启用所有密钥失败')); |
| | } finally { |
| | setOperationLoading((prev) => ({ ...prev, enable_all: false })); |
| | } |
| | }; |
| |
|
| | |
| | const handleDisableAll = async () => { |
| | setOperationLoading((prev) => ({ ...prev, disable_all: true })); |
| |
|
| | try { |
| | const res = await API.post('/api/channel/multi_key/manage', { |
| | channel_id: channel.id, |
| | action: 'disable_all_keys', |
| | }); |
| |
|
| | if (res.data.success) { |
| | showSuccess(res.data.message || t('已禁用所有密钥')); |
| | |
| | setCurrentPage(1); |
| | await loadKeyStatus(1, pageSize); |
| | onRefresh && onRefresh(); |
| | } else { |
| | showError(res.data.message); |
| | } |
| | } catch (error) { |
| | showError(t('禁用所有密钥失败')); |
| | } finally { |
| | setOperationLoading((prev) => ({ ...prev, disable_all: false })); |
| | } |
| | }; |
| |
|
| | |
| | const handleDeleteDisabledKeys = async () => { |
| | setOperationLoading((prev) => ({ ...prev, delete_disabled: true })); |
| |
|
| | try { |
| | const res = await API.post('/api/channel/multi_key/manage', { |
| | channel_id: channel.id, |
| | action: 'delete_disabled_keys', |
| | }); |
| |
|
| | if (res.data.success) { |
| | showSuccess(res.data.message); |
| | |
| | setCurrentPage(1); |
| | await loadKeyStatus(1, pageSize); |
| | onRefresh && onRefresh(); |
| | } else { |
| | showError(res.data.message); |
| | } |
| | } catch (error) { |
| | showError(t('删除禁用密钥失败')); |
| | } finally { |
| | setOperationLoading((prev) => ({ ...prev, delete_disabled: false })); |
| | } |
| | }; |
| |
|
| | |
| | const handleDeleteKey = async (keyIndex) => { |
| | const operationId = `delete_${keyIndex}`; |
| | setOperationLoading((prev) => ({ ...prev, [operationId]: true })); |
| |
|
| | try { |
| | const res = await API.post('/api/channel/multi_key/manage', { |
| | channel_id: channel.id, |
| | action: 'delete_key', |
| | key_index: keyIndex, |
| | }); |
| |
|
| | if (res.data.success) { |
| | showSuccess(t('密钥已删除')); |
| | await loadKeyStatus(currentPage, pageSize); |
| | onRefresh && onRefresh(); |
| | } else { |
| | showError(res.data.message); |
| | } |
| | } catch (error) { |
| | showError(t('删除密钥失败')); |
| | } finally { |
| | setOperationLoading((prev) => ({ ...prev, [operationId]: false })); |
| | } |
| | }; |
| |
|
| | |
| | const handlePageChange = (page) => { |
| | setCurrentPage(page); |
| | loadKeyStatus(page, pageSize); |
| | }; |
| |
|
| | |
| | const handlePageSizeChange = (size) => { |
| | setPageSize(size); |
| | setCurrentPage(1); |
| | loadKeyStatus(1, size); |
| | }; |
| |
|
| | |
| | const handleStatusFilterChange = (status) => { |
| | setStatusFilter(status); |
| | setCurrentPage(1); |
| | loadKeyStatus(1, pageSize, status); |
| | }; |
| |
|
| | |
| | useEffect(() => { |
| | if (visible && channel?.id) { |
| | setCurrentPage(1); |
| | loadKeyStatus(1, pageSize); |
| | } |
| | }, [visible, channel?.id]); |
| |
|
| | |
| | useEffect(() => { |
| | if (!visible) { |
| | setCurrentPage(1); |
| | setKeyStatusList([]); |
| | setTotal(0); |
| | setTotalPages(0); |
| | setEnabledCount(0); |
| | setManualDisabledCount(0); |
| | setAutoDisabledCount(0); |
| | setStatusFilter(null); |
| | } |
| | }, [visible]); |
| |
|
| | |
| | const enabledPercent = |
| | total > 0 ? Math.round((enabledCount / total) * 100) : 0; |
| | const manualDisabledPercent = |
| | total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0; |
| | const autoDisabledPercent = |
| | total > 0 ? Math.round((autoDisabledCount / total) * 100) : 0; |
| |
|
| | |
| |
|
| | |
| | const renderStatusTag = (status) => { |
| | switch (status) { |
| | case 1: |
| | return ( |
| | <Tag color='green' shape='circle' size='small'> |
| | {t('已启用')} |
| | </Tag> |
| | ); |
| | case 2: |
| | return ( |
| | <Tag color='red' shape='circle' size='small'> |
| | {t('已禁用')} |
| | </Tag> |
| | ); |
| | case 3: |
| | return ( |
| | <Tag color='orange' shape='circle' size='small'> |
| | {t('自动禁用')} |
| | </Tag> |
| | ); |
| | default: |
| | return ( |
| | <Tag color='grey' shape='circle' size='small'> |
| | {t('未知状态')} |
| | </Tag> |
| | ); |
| | } |
| | }; |
| |
|
| | |
| | const columns = [ |
| | { |
| | title: t('索引'), |
| | dataIndex: 'index', |
| | render: (text) => `#${text}`, |
| | }, |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | { |
| | title: t('状态'), |
| | dataIndex: 'status', |
| | render: (status) => renderStatusTag(status), |
| | }, |
| | { |
| | title: t('禁用原因'), |
| | dataIndex: 'reason', |
| | render: (reason, record) => { |
| | if (record.status === 1 || !reason) { |
| | return <Text type='quaternary'>-</Text>; |
| | } |
| | return ( |
| | <Tooltip content={reason}> |
| | <Text style={{ maxWidth: '200px', display: 'block' }} ellipsis> |
| | {reason} |
| | </Text> |
| | </Tooltip> |
| | ); |
| | }, |
| | }, |
| | { |
| | title: t('禁用时间'), |
| | dataIndex: 'disabled_time', |
| | render: (time, record) => { |
| | if (record.status === 1 || !time) { |
| | return <Text type='quaternary'>-</Text>; |
| | } |
| | return ( |
| | <Tooltip content={timestamp2string(time)}> |
| | <Text style={{ fontSize: '12px' }}>{timestamp2string(time)}</Text> |
| | </Tooltip> |
| | ); |
| | }, |
| | }, |
| | { |
| | title: t('操作'), |
| | key: 'action', |
| | fixed: 'right', |
| | width: 150, |
| | render: (_, record) => ( |
| | <Space> |
| | {record.status === 1 ? ( |
| | <Button |
| | type='danger' |
| | size='small' |
| | loading={operationLoading[`disable_${record.index}`]} |
| | onClick={() => handleDisableKey(record.index)} |
| | > |
| | {t('禁用')} |
| | </Button> |
| | ) : ( |
| | <Button |
| | type='primary' |
| | size='small' |
| | loading={operationLoading[`enable_${record.index}`]} |
| | onClick={() => handleEnableKey(record.index)} |
| | > |
| | {t('启用')} |
| | </Button> |
| | )} |
| | <Popconfirm |
| | title={t('确定要删除此密钥吗?')} |
| | content={t('此操作不可撤销,将永久删除该密钥')} |
| | onConfirm={() => handleDeleteKey(record.index)} |
| | okType={'danger'} |
| | position={'topRight'} |
| | > |
| | <Button |
| | type='danger' |
| | size='small' |
| | loading={operationLoading[`delete_${record.index}`]} |
| | > |
| | {t('删除')} |
| | </Button> |
| | </Popconfirm> |
| | </Space> |
| | ), |
| | }, |
| | ]; |
| |
|
| | return ( |
| | <Modal |
| | title={ |
| | <Space> |
| | <Text>{t('多密钥管理')}</Text> |
| | {channel?.name && ( |
| | <Tag size='small' shape='circle' color='white'> |
| | {channel.name} |
| | </Tag> |
| | )} |
| | <Tag size='small' shape='circle' color='white'> |
| | {t('总密钥数')}: {total} |
| | </Tag> |
| | {channel?.channel_info?.multi_key_mode && ( |
| | <Tag size='small' shape='circle' color='white'> |
| | {channel.channel_info.multi_key_mode === 'random' |
| | ? t('随机模式') |
| | : t('轮询模式')} |
| | </Tag> |
| | )} |
| | </Space> |
| | } |
| | visible={visible} |
| | onCancel={onCancel} |
| | width={900} |
| | footer={null} |
| | > |
| | <div className='flex flex-col mb-5'> |
| | {/* Stats & Mode */} |
| | <div |
| | className='rounded-xl p-4 mb-3' |
| | style={{ |
| | background: 'var(--semi-color-bg-1)', |
| | border: '1px solid var(--semi-color-border)', |
| | }} |
| | > |
| | <Row gutter={16} align='middle'> |
| | <Col span={8}> |
| | <div |
| | style={{ |
| | background: 'var(--semi-color-bg-0)', |
| | border: '1px solid var(--semi-color-border)', |
| | borderRadius: 12, |
| | padding: 12, |
| | }} |
| | > |
| | <div className='flex items-center gap-2 mb-2'> |
| | <Badge dot type='success' /> |
| | <Text type='tertiary'>{t('已启用')}</Text> |
| | </div> |
| | <div className='flex items-end gap-2 mb-2'> |
| | <Text |
| | style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }} |
| | > |
| | {enabledCount} |
| | </Text> |
| | <Text |
| | style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }} |
| | > |
| | / {total} |
| | </Text> |
| | </div> |
| | <Progress |
| | percent={enabledPercent} |
| | showInfo={false} |
| | size='small' |
| | stroke='#22c55e' |
| | style={{ height: 6, borderRadius: 999 }} |
| | /> |
| | </div> |
| | </Col> |
| | <Col span={8}> |
| | <div |
| | style={{ |
| | background: 'var(--semi-color-bg-0)', |
| | border: '1px solid var(--semi-color-border)', |
| | borderRadius: 12, |
| | padding: 12, |
| | }} |
| | > |
| | <div className='flex items-center gap-2 mb-2'> |
| | <Badge dot type='danger' /> |
| | <Text type='tertiary'>{t('手动禁用')}</Text> |
| | </div> |
| | <div className='flex items-end gap-2 mb-2'> |
| | <Text |
| | style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }} |
| | > |
| | {manualDisabledCount} |
| | </Text> |
| | <Text |
| | style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }} |
| | > |
| | / {total} |
| | </Text> |
| | </div> |
| | <Progress |
| | percent={manualDisabledPercent} |
| | showInfo={false} |
| | size='small' |
| | stroke='#ef4444' |
| | style={{ height: 6, borderRadius: 999 }} |
| | /> |
| | </div> |
| | </Col> |
| | <Col span={8}> |
| | <div |
| | style={{ |
| | background: 'var(--semi-color-bg-0)', |
| | border: '1px solid var(--semi-color-border)', |
| | borderRadius: 12, |
| | padding: 12, |
| | }} |
| | > |
| | <div className='flex items-center gap-2 mb-2'> |
| | <Badge dot type='warning' /> |
| | <Text type='tertiary'>{t('自动禁用')}</Text> |
| | </div> |
| | <div className='flex items-end gap-2 mb-2'> |
| | <Text |
| | style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }} |
| | > |
| | {autoDisabledCount} |
| | </Text> |
| | <Text |
| | style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }} |
| | > |
| | / {total} |
| | </Text> |
| | </div> |
| | <Progress |
| | percent={autoDisabledPercent} |
| | showInfo={false} |
| | size='small' |
| | stroke='#f59e0b' |
| | style={{ height: 6, borderRadius: 999 }} |
| | /> |
| | </div> |
| | </Col> |
| | </Row> |
| | </div> |
| | |
| | {/* Table */} |
| | <div className='flex-1 flex flex-col min-h-0'> |
| | <Spin spinning={loading}> |
| | <Card className='!rounded-xl'> |
| | <Table |
| | title={() => ( |
| | <Row gutter={12} style={{ width: '100%' }}> |
| | <Col span={14}> |
| | <Row gutter={12} style={{ alignItems: 'center' }}> |
| | <Col> |
| | <Select |
| | value={statusFilter} |
| | onChange={handleStatusFilterChange} |
| | size='small' |
| | placeholder={t('全部状态')} |
| | > |
| | <Select.Option value={null}> |
| | {t('全部状态')} |
| | </Select.Option> |
| | <Select.Option value={1}> |
| | {t('已启用')} |
| | </Select.Option> |
| | <Select.Option value={2}> |
| | {t('手动禁用')} |
| | </Select.Option> |
| | <Select.Option value={3}> |
| | {t('自动禁用')} |
| | </Select.Option> |
| | </Select> |
| | </Col> |
| | </Row> |
| | </Col> |
| | <Col |
| | span={10} |
| | style={{ display: 'flex', justifyContent: 'flex-end' }} |
| | > |
| | <Space> |
| | <Button |
| | size='small' |
| | type='tertiary' |
| | onClick={() => loadKeyStatus(currentPage, pageSize)} |
| | loading={loading} |
| | > |
| | {t('刷新')} |
| | </Button> |
| | {manualDisabledCount + autoDisabledCount > 0 && ( |
| | <Popconfirm |
| | title={t('确定要启用所有密钥吗?')} |
| | onConfirm={handleEnableAll} |
| | position={'topRight'} |
| | > |
| | <Button |
| | size='small' |
| | type='primary' |
| | loading={operationLoading.enable_all} |
| | > |
| | {t('启用全部')} |
| | </Button> |
| | </Popconfirm> |
| | )} |
| | {enabledCount > 0 && ( |
| | <Popconfirm |
| | title={t('确定要禁用所有的密钥吗?')} |
| | onConfirm={handleDisableAll} |
| | okType={'danger'} |
| | position={'topRight'} |
| | > |
| | <Button |
| | size='small' |
| | type='danger' |
| | loading={operationLoading.disable_all} |
| | > |
| | {t('禁用全部')} |
| | </Button> |
| | </Popconfirm> |
| | )} |
| | <Popconfirm |
| | title={t('确定要删除所有已自动禁用的密钥吗?')} |
| | content={t( |
| | '此操作不可撤销,将永久删除已自动禁用的密钥', |
| | )} |
| | onConfirm={handleDeleteDisabledKeys} |
| | okType={'danger'} |
| | position={'topRight'} |
| | > |
| | <Button |
| | size='small' |
| | type='warning' |
| | loading={operationLoading.delete_disabled} |
| | > |
| | {t('删除自动禁用密钥')} |
| | </Button> |
| | </Popconfirm> |
| | </Space> |
| | </Col> |
| | </Row> |
| | )} |
| | columns={columns} |
| | dataSource={keyStatusList} |
| | pagination={{ |
| | currentPage: currentPage, |
| | pageSize: pageSize, |
| | total: total, |
| | showSizeChanger: true, |
| | showQuickJumper: true, |
| | pageSizeOpts: [10, 20, 50, 100], |
| | onChange: (page, size) => { |
| | setCurrentPage(page); |
| | loadKeyStatus(page, size); |
| | }, |
| | onShowSizeChange: (current, size) => { |
| | setCurrentPage(1); |
| | handlePageSizeChange(size); |
| | }, |
| | }} |
| | size='small' |
| | bordered={false} |
| | rowKey='index' |
| | scroll={{ x: 'max-content' }} |
| | empty={ |
| | <Empty |
| | image={ |
| | <IllustrationNoResult |
| | style={{ width: 140, height: 140 }} |
| | /> |
| | } |
| | darkModeImage={ |
| | <IllustrationNoResultDark |
| | style={{ width: 140, height: 140 }} |
| | /> |
| | } |
| | title={t('暂无密钥数据')} |
| | description={t('请检查渠道配置或刷新重试')} |
| | style={{ padding: 30 }} |
| | /> |
| | } |
| | /> |
| | </Card> |
| | </Spin> |
| | </div> |
| | </div> |
| | </Modal> |
| | ); |
| | }; |
| |
|
| | export default MultiKeyManageModal; |
| |
|