diff --git a/web/src/App.js b/web/src/App.js index 2d715767..995ae2bb 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -1,6 +1,6 @@ import React, { lazy, Suspense } from 'react'; import { Route, Routes, useLocation } from 'react-router-dom'; -import Loading from './components/common/Loading.js'; +import Loading from './components/common/ui/Loading.js'; import User from './pages/User'; import { AuthRedirect, PrivateRoute } from './helpers'; import RegisterForm from './components/auth/RegisterForm.js'; diff --git a/web/src/components/auth/OAuth2Callback.js b/web/src/components/auth/OAuth2Callback.js index 7d435574..0bd92f58 100644 --- a/web/src/components/auth/OAuth2Callback.js +++ b/web/src/components/auth/OAuth2Callback.js @@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers'; import { UserContext } from '../../context/User'; -import Loading from '../common/Loading'; +import Loading from '../common/ui/Loading'; const OAuth2Callback = (props) => { const { t } = useTranslation(); diff --git a/web/src/components/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js new file mode 100644 index 00000000..4f240e9e --- /dev/null +++ b/web/src/components/common/ui/CardPro.js @@ -0,0 +1,127 @@ +import React from 'react'; +import { Card, Divider, Typography } from '@douyinfe/semi-ui'; +import PropTypes from 'prop-types'; + +const { Text } = Typography; + +/** + * CardPro 高级卡片组件 + * + * 布局分为5个区域: + * 1. 统计信息区域 (statsArea) + * 2. 描述信息区域 (descriptionArea) + * 3. 类型切换/标签区域 (tabsArea) + * 4. 操作按钮区域 (actionsArea) + * 5. 搜索表单区域 (searchArea) + * + * 支持三种布局类型: + * - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单 + * - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单 + * - type3: 复杂型 (如ChannelsTable) - 描述信息 + 类型切换 + 操作按钮 + 搜索表单 + */ +const CardPro = ({ + type = 'type1', + className = '', + children, + // 各个区域的内容 + statsArea, + descriptionArea, + tabsArea, + actionsArea, + searchArea, + // 卡片属性 + shadows = 'always', + bordered = false, + // 自定义样式 + style, + ...props +}) => { + // 渲染头部内容 + const renderHeader = () => { + const hasContent = statsArea || descriptionArea || tabsArea || actionsArea || searchArea; + if (!hasContent) return null; + + return ( +
+ {/* 统计信息区域 - 用于type2 */} + {type === 'type2' && statsArea && ( +
+ {statsArea} +
+ )} + + {/* 描述信息区域 - 用于type1和type3 */} + {(type === 'type1' || type === 'type3') && descriptionArea && ( +
+ {descriptionArea} +
+ )} + + {/* 第一个分隔线 - 在描述信息或统计信息后面 */} + {((type === 'type1' || type === 'type3') && descriptionArea) || + (type === 'type2' && statsArea) ? ( + + ) : null} + + {/* 类型切换/标签区域 - 主要用于type3 */} + {type === 'type3' && tabsArea && ( +
+ {tabsArea} +
+ )} + + {/* 操作按钮和搜索表单的容器 */} +
+ {/* 操作按钮区域 - 用于type1和type3 */} + {(type === 'type1' || type === 'type3') && actionsArea && ( +
+ {actionsArea} +
+ )} + + {/* 搜索表单区域 - 所有类型都可能有 */} + {searchArea && ( +
+ {searchArea} +
+ )} +
+
+ ); + }; + + const headerContent = renderHeader(); + + return ( + + {children} + + ); +}; + +CardPro.propTypes = { + // 布局类型 + type: PropTypes.oneOf(['type1', 'type2', 'type3']), + // 样式相关 + className: PropTypes.string, + style: PropTypes.object, + shadows: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + bordered: PropTypes.bool, + // 内容区域 + statsArea: PropTypes.node, + descriptionArea: PropTypes.node, + tabsArea: PropTypes.node, + actionsArea: PropTypes.node, + searchArea: PropTypes.node, + // 表格内容 + children: PropTypes.node, +}; + +export default CardPro; \ No newline at end of file diff --git a/web/src/components/common/Loading.js b/web/src/components/common/ui/Loading.js similarity index 100% rename from web/src/components/common/Loading.js rename to web/src/components/common/ui/Loading.js diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js index 4d83d48b..6b365345 100644 --- a/web/src/components/layout/HeaderBar.js +++ b/web/src/components/layout/HeaderBar.js @@ -31,8 +31,8 @@ import { Badge, } from '@douyinfe/semi-ui'; import { StatusContext } from '../../context/Status/index.js'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; -import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; +import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js'; const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const { t, i18n } = useTranslation(); diff --git a/web/src/components/layout/PageLayout.js b/web/src/components/layout/PageLayout.js index 365df7da..da955ccc 100644 --- a/web/src/components/layout/PageLayout.js +++ b/web/src/components/layout/PageLayout.js @@ -5,8 +5,8 @@ import App from '../../App.js'; import FooterBar from './Footer.js'; import { ToastContainer } from 'react-toastify'; import React, { useContext, useEffect, useState } from 'react'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; -import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; +import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js'; import { useTranslation } from 'react-i18next'; import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js'; import { UserContext } from '../../context/User/index.js'; diff --git a/web/src/components/layout/SiderBar.js b/web/src/components/layout/SiderBar.js index b18dad6c..4b61667f 100644 --- a/web/src/components/layout/SiderBar.js +++ b/web/src/components/layout/SiderBar.js @@ -3,7 +3,7 @@ import { Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js'; import { ChevronLeft } from 'lucide-react'; -import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js'; +import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js'; import { isAdmin, isRoot, diff --git a/web/src/components/settings/ChannelSelectorModal.js b/web/src/components/settings/ChannelSelectorModal.js index 558f0bef..eec5fb88 100644 --- a/web/src/components/settings/ChannelSelectorModal.js +++ b/web/src/components/settings/ChannelSelectorModal.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { Modal, Table, diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js index 4bf94cb8..6a423997 100644 --- a/web/src/components/table/ChannelsTable.js +++ b/web/src/components/table/ChannelsTable.js @@ -1,2207 +1,2 @@ -import React, { useEffect, useState, useMemo, useRef } from 'react'; -import { - API, - showError, - showInfo, - showSuccess, - timestamp2string, - renderGroup, - renderQuota, - getChannelIcon, - renderQuotaWithAmount -} from '../../helpers/index.js'; -import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js'; -import { - Button, - Divider, - Dropdown, - Empty, - Input, - InputNumber, - Modal, - Space, - SplitButtonGroup, - Switch, - Table, - Tag, - Tooltip, - Typography, - Checkbox, - Card, - Form, - Tabs, - TabPane, - Select -} from '@douyinfe/semi-ui'; -import { - IllustrationNoResult, - IllustrationNoResultDark -} from '@douyinfe/semi-illustrations'; -import EditChannel from '../../pages/Channel/EditChannel.js'; -import { - IconTreeTriangleDown, - IconSearch, - IconMore, - IconDescend2 -} from '@douyinfe/semi-icons'; -import { loadChannelModels, copy } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; -import EditTagModal from '../../pages/Channel/EditTagModal.js'; -import { useTranslation } from 'react-i18next'; -import { useTableCompactMode } from '../../hooks/useTableCompactMode'; -import { FaRandom } from 'react-icons/fa'; - -const ChannelsTable = () => { - const { t } = useTranslation(); - const isMobile = useIsMobile(); - - let type2label = undefined; - - const renderType = (type, channelInfo = undefined) => { - if (!type2label) { - type2label = new Map(); - for (let i = 0; i < CHANNEL_OPTIONS.length; i++) { - type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i]; - } - type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' }; - } - - let icon = getChannelIcon(type); - - if (channelInfo?.is_multi_key) { - icon = ( - channelInfo?.multi_key_mode === 'random' ? ( -
- - {icon} -
- ) : ( -
- - {icon} -
- ) - ) - } - - return ( - - {type2label[type]?.label} - - ); - }; - - const renderTagType = () => { - return ( - - {t('标签聚合')} - - ); - }; - - const renderStatus = (status, channelInfo = undefined) => { - if (channelInfo) { - if (channelInfo.is_multi_key) { - let keySize = channelInfo.multi_key_size; - let enabledKeySize = keySize; - if (channelInfo.multi_key_status_list) { - // multi_key_status_list is a map, key is key, value is status - // get multi_key_status_list length - enabledKeySize = keySize - Object.keys(channelInfo.multi_key_status_list).length; - } - return renderMultiKeyStatus(status, keySize, enabledKeySize); - } - } - switch (status) { - case 1: - return ( - - {t('已启用')} - - ); - case 2: - return ( - - {t('已禁用')} - - ); - case 3: - return ( - - {t('自动禁用')} - - ); - default: - return ( - - {t('未知状态')} - - ); - } - }; - - const renderMultiKeyStatus = (status, keySize, enabledKeySize) => { - switch (status) { - case 1: - return ( - - {t('已启用')} {enabledKeySize}/{keySize} - - ); - case 2: - return ( - - {t('已禁用')} {enabledKeySize}/{keySize} - - ); - case 3: - return ( - - {t('自动禁用')} {enabledKeySize}/{keySize} - - ); - default: - return ( - - {t('未知状态')} {enabledKeySize}/{keySize} - - ); - } - } - - - const renderResponseTime = (responseTime) => { - let time = responseTime / 1000; - time = time.toFixed(2) + t(' 秒'); - if (responseTime === 0) { - return ( - - {t('未测试')} - - ); - } else if (responseTime <= 1000) { - return ( - - {time} - - ); - } else if (responseTime <= 3000) { - return ( - - {time} - - ); - } else if (responseTime <= 5000) { - return ( - - {time} - - ); - } else { - return ( - - {time} - - ); - } - }; - - // Define column keys for selection - const COLUMN_KEYS = { - ID: 'id', - NAME: 'name', - GROUP: 'group', - TYPE: 'type', - STATUS: 'status', - RESPONSE_TIME: 'response_time', - BALANCE: 'balance', - PRIORITY: 'priority', - WEIGHT: 'weight', - OPERATE: 'operate', - }; - - // State for column visibility - const [visibleColumns, setVisibleColumns] = useState({}); - const [showColumnSelector, setShowColumnSelector] = useState(false); - - // 状态筛选 all / enabled / disabled - const [statusFilter, setStatusFilter] = useState( - localStorage.getItem('channel-status-filter') || 'all' - ); - - // Load saved column preferences from localStorage - useEffect(() => { - const savedColumns = localStorage.getItem('channels-table-columns'); - if (savedColumns) { - try { - const parsed = JSON.parse(savedColumns); - // Make sure all columns are accounted for - const defaults = getDefaultColumnVisibility(); - const merged = { ...defaults, ...parsed }; - setVisibleColumns(merged); - } catch (e) { - console.error('Failed to parse saved column preferences', e); - initDefaultColumns(); - } - } else { - initDefaultColumns(); - } - }, []); - - // Update table when column visibility changes - useEffect(() => { - if (Object.keys(visibleColumns).length > 0) { - // Save to localStorage - localStorage.setItem( - 'channels-table-columns', - JSON.stringify(visibleColumns), - ); - } - }, [visibleColumns]); - - // Get default column visibility - const getDefaultColumnVisibility = () => { - return { - [COLUMN_KEYS.ID]: true, - [COLUMN_KEYS.NAME]: true, - [COLUMN_KEYS.GROUP]: true, - [COLUMN_KEYS.TYPE]: true, - [COLUMN_KEYS.STATUS]: true, - [COLUMN_KEYS.RESPONSE_TIME]: true, - [COLUMN_KEYS.BALANCE]: true, - [COLUMN_KEYS.PRIORITY]: true, - [COLUMN_KEYS.WEIGHT]: true, - [COLUMN_KEYS.OPERATE]: true, - }; - }; - - // Initialize default column visibility - const initDefaultColumns = () => { - const defaults = getDefaultColumnVisibility(); - setVisibleColumns(defaults); - }; - - // Handle column visibility change - const handleColumnVisibilityChange = (columnKey, checked) => { - const updatedColumns = { ...visibleColumns, [columnKey]: checked }; - setVisibleColumns(updatedColumns); - }; - - // Handle "Select All" checkbox - const handleSelectAll = (checked) => { - const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]); - const updatedColumns = {}; - - allKeys.forEach((key) => { - updatedColumns[key] = checked; - }); - - setVisibleColumns(updatedColumns); - }; - - // Define all columns with keys - const allColumns = [ - { - key: COLUMN_KEYS.ID, - title: t('ID'), - dataIndex: 'id', - }, - { - key: COLUMN_KEYS.NAME, - title: t('名称'), - dataIndex: 'name', - }, - { - key: COLUMN_KEYS.GROUP, - title: t('分组'), - dataIndex: 'group', - render: (text, record, index) => ( -
- - {text - ?.split(',') - .sort((a, b) => { - if (a === 'default') return -1; - if (b === 'default') return 1; - return a.localeCompare(b); - }) - .map((item, index) => renderGroup(item))} - -
- ), - }, - { - key: COLUMN_KEYS.TYPE, - title: t('类型'), - dataIndex: 'type', - render: (text, record, index) => { - if (record.children === undefined) { - if (record.channel_info) { - if (record.channel_info.is_multi_key) { - return <>{renderType(text, record.channel_info)}; - } - } - return <>{renderType(text)}; - } else { - return <>{renderTagType()}; - } - }, - }, - { - key: COLUMN_KEYS.STATUS, - title: t('状态'), - dataIndex: 'status', - render: (text, record, index) => { - if (text === 3) { - if (record.other_info === '') { - record.other_info = '{}'; - } - let otherInfo = JSON.parse(record.other_info); - let reason = otherInfo['status_reason']; - let time = otherInfo['status_time']; - return ( -
- - {renderStatus(text, record.channel_info)} - -
- ); - } else { - return renderStatus(text, record.channel_info); - } - }, - }, - { - key: COLUMN_KEYS.RESPONSE_TIME, - title: t('响应时间'), - dataIndex: 'response_time', - render: (text, record, index) => ( -
{renderResponseTime(text)}
- ), - }, - { - key: COLUMN_KEYS.BALANCE, - title: t('已用/剩余'), - dataIndex: 'expired_time', - render: (text, record, index) => { - if (record.children === undefined) { - return ( -
- - - - {renderQuota(record.used_quota)} - - - - updateChannelBalance(record)} - > - {renderQuotaWithAmount(record.balance)} - - - -
- ); - } else { - return ( - - - {renderQuota(record.used_quota)} - - - ); - } - }, - }, - { - key: COLUMN_KEYS.PRIORITY, - title: t('优先级'), - dataIndex: 'priority', - render: (text, record, index) => { - if (record.children === undefined) { - return ( -
- { - manageChannel(record.id, 'priority', record, e.target.value); - }} - keepFocus={true} - innerButtons - defaultValue={record.priority} - min={-999} - size="small" - /> -
- ); - } else { - return ( - { - Modal.warning({ - title: t('修改子渠道优先级'), - content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'), - onOk: () => { - if (e.target.value === '') { - return; - } - submitTagEdit('priority', { - tag: record.key, - priority: e.target.value, - }); - }, - }); - }} - innerButtons - defaultValue={record.priority} - min={-999} - size="small" - /> - ); - } - }, - }, - { - key: COLUMN_KEYS.WEIGHT, - title: t('权重'), - dataIndex: 'weight', - render: (text, record, index) => { - if (record.children === undefined) { - return ( -
- { - manageChannel(record.id, 'weight', record, e.target.value); - }} - keepFocus={true} - innerButtons - defaultValue={record.weight} - min={0} - size="small" - /> -
- ); - } else { - return ( - { - Modal.warning({ - title: t('修改子渠道权重'), - content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'), - onOk: () => { - if (e.target.value === '') { - return; - } - submitTagEdit('weight', { - tag: record.key, - weight: e.target.value, - }); - }, - }); - }} - innerButtons - defaultValue={record.weight} - min={-999} - size="small" - /> - ); - } - }, - }, - { - key: COLUMN_KEYS.OPERATE, - title: '', - dataIndex: 'operate', - fixed: 'right', - render: (text, record, index) => { - if (record.children === undefined) { - // 创建更多操作的下拉菜单项 - const moreMenuItems = [ - { - node: 'item', - name: t('删除'), - type: 'danger', - onClick: () => { - Modal.confirm({ - title: t('确定是否要删除此渠道?'), - content: t('此修改将不可逆'), - onOk: () => { - (async () => { - await manageChannel(record.id, 'delete', record); - await refresh(); - setTimeout(() => { - if (channels.length === 0 && activePage > 1) { - refresh(activePage - 1); - } - }, 100); - })(); - }, - }); - }, - }, - { - node: 'item', - name: t('复制'), - type: 'tertiary', - onClick: () => { - Modal.confirm({ - title: t('确定是否要复制此渠道?'), - content: t('复制渠道的所有信息'), - onOk: () => copySelectedChannel(record), - }); - }, - }, - ]; - - return ( - - - - - ) : ( - - ) - } - manageChannel(record.id, 'enable_all', record), - } - ]} - > - - ) : ( - - ) - )} - - - - - - - - - ); - } - }, - }, - ]; - - const [channels, setChannels] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [idSort, setIdSort] = useState(false); - const [searching, setSearching] = useState(false); - const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - const [channelCount, setChannelCount] = useState(pageSize); - const [groupOptions, setGroupOptions] = useState([]); - const [showEdit, setShowEdit] = useState(false); - const [enableBatchDelete, setEnableBatchDelete] = useState(false); - const [editingChannel, setEditingChannel] = useState({ - id: undefined, - }); - const [showEditTag, setShowEditTag] = useState(false); - const [editingTag, setEditingTag] = useState(''); - const [selectedChannels, setSelectedChannels] = useState([]); - const [enableTagMode, setEnableTagMode] = useState(false); - const [showBatchSetTag, setShowBatchSetTag] = useState(false); - const [batchSetTagValue, setBatchSetTagValue] = useState(''); - const [showModelTestModal, setShowModelTestModal] = useState(false); - const [currentTestChannel, setCurrentTestChannel] = useState(null); - const [modelSearchKeyword, setModelSearchKeyword] = useState(''); - const [modelTestResults, setModelTestResults] = useState({}); - const [testingModels, setTestingModels] = useState(new Set()); - const [selectedModelKeys, setSelectedModelKeys] = useState([]); - const [isBatchTesting, setIsBatchTesting] = useState(false); - const [testQueue, setTestQueue] = useState([]); - const [isProcessingQueue, setIsProcessingQueue] = useState(false); - const [modelTablePage, setModelTablePage] = useState(1); - const [activeTypeKey, setActiveTypeKey] = useState('all'); - const [typeCounts, setTypeCounts] = useState({}); - const requestCounter = useRef(0); - const [formApi, setFormApi] = useState(null); - const [compactMode, setCompactMode] = useTableCompactMode('channels'); - const formInitValues = { - searchKeyword: '', - searchGroup: '', - searchModel: '', - }; - const allSelectingRef = useRef(false); - - // Filter columns based on visibility settings - const getVisibleColumns = () => { - return allColumns.filter((column) => visibleColumns[column.key]); - }; - - // Column selector modal - const renderColumnSelector = () => { - return ( - setShowColumnSelector(false)} - footer={ -
- - - -
- } - > -
- v === true)} - indeterminate={ - Object.values(visibleColumns).some((v) => v === true) && - !Object.values(visibleColumns).every((v) => v === true) - } - onChange={(e) => handleSelectAll(e.target.checked)} - > - {t('全选')} - -
-
- {allColumns.map((column) => { - // Skip columns without title - if (!column.title) { - return null; - } - - return ( -
- - handleColumnVisibilityChange(column.key, e.target.checked) - } - > - {column.title} - -
- ); - })} -
-
- ); - }; - - const removeRecord = (record) => { - let newDataSource = [...channels]; - if (record.id != null) { - let idx = newDataSource.findIndex((data) => { - if (data.children !== undefined) { - for (let i = 0; i < data.children.length; i++) { - if (data.children[i].id === record.id) { - data.children.splice(i, 1); - return false; - } - } - } else { - return data.id === record.id; - } - }); - - if (idx > -1) { - newDataSource.splice(idx, 1); - setChannels(newDataSource); - } - } - }; - - const setChannelFormat = (channels, enableTagMode) => { - let channelDates = []; - let channelTags = {}; - for (let i = 0; i < channels.length; i++) { - channels[i].key = '' + channels[i].id; - if (!enableTagMode) { - channelDates.push(channels[i]); - } else { - let tag = channels[i].tag ? channels[i].tag : ''; - // find from channelTags - let tagIndex = channelTags[tag]; - let tagChannelDates = undefined; - if (tagIndex === undefined) { - // not found, create a new tag - channelTags[tag] = 1; - tagChannelDates = { - key: tag, - id: tag, - tag: tag, - name: '标签:' + tag, - group: '', - used_quota: 0, - response_time: 0, - priority: -1, - weight: -1, - }; - tagChannelDates.children = []; - channelDates.push(tagChannelDates); - } else { - // found, add to the tag - tagChannelDates = channelDates.find((item) => item.key === tag); - } - if (tagChannelDates.priority === -1) { - tagChannelDates.priority = channels[i].priority; - } else { - if (tagChannelDates.priority !== channels[i].priority) { - tagChannelDates.priority = ''; - } - } - if (tagChannelDates.weight === -1) { - tagChannelDates.weight = channels[i].weight; - } else { - if (tagChannelDates.weight !== channels[i].weight) { - tagChannelDates.weight = ''; - } - } - - if (tagChannelDates.group === '') { - tagChannelDates.group = channels[i].group; - } else { - let channelGroupsStr = channels[i].group; - channelGroupsStr.split(',').forEach((item, index) => { - if (tagChannelDates.group.indexOf(item) === -1) { - // join - tagChannelDates.group += ',' + item; - } - }); - } - - tagChannelDates.children.push(channels[i]); - if (channels[i].status === 1) { - tagChannelDates.status = 1; - } - tagChannelDates.used_quota += channels[i].used_quota; - tagChannelDates.response_time += channels[i].response_time; - tagChannelDates.response_time = tagChannelDates.response_time / 2; - } - } - setChannels(channelDates); - }; - - const loadChannels = async ( - page, - pageSize, - idSort, - enableTagMode, - typeKey = activeTypeKey, - statusF, - ) => { - if (statusF === undefined) statusF = statusFilter; - - const { searchKeyword, searchGroup, searchModel } = getFormValues(); - if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') { - setLoading(true); - await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort); - setLoading(false); - return; - } - - const reqId = ++requestCounter.current; // 记录当前请求序号 - setLoading(true); - const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; - const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; - const res = await API.get( - `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`, - ); - if (res === undefined || reqId !== requestCounter.current) { - return; - } - const { success, message, data } = res.data; - if (success) { - const { items, total, type_counts } = data; - if (type_counts) { - const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0); - setTypeCounts({ ...type_counts, all: sumAll }); - } - setChannelFormat(items, enableTagMode); - setChannelCount(total); - } else { - showError(message); - } - setLoading(false); - }; - - const copySelectedChannel = async (record) => { - try { - const res = await API.post(`/api/channel/copy/${record.id}`); - if (res?.data?.success) { - showSuccess(t('渠道复制成功')); - await refresh(); - } else { - showError(res?.data?.message || t('渠道复制失败')); - } - } catch (error) { - showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error)); - } - }; - - const refresh = async (page = activePage) => { - const { searchKeyword, searchGroup, searchModel } = getFormValues(); - if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - await loadChannels(page, pageSize, idSort, enableTagMode); - } else { - await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort); - } - }; - - useEffect(() => { - const localIdSort = localStorage.getItem('id-sort') === 'true'; - const localPageSize = - parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; - const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true'; - const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true'; - setIdSort(localIdSort); - setPageSize(localPageSize); - setEnableTagMode(localEnableTagMode); - setEnableBatchDelete(localEnableBatchDelete); - loadChannels(1, localPageSize, localIdSort, localEnableTagMode) - .then() - .catch((reason) => { - showError(reason); - }); - fetchGroups().then(); - loadChannelModels().then(); - }, []); - - const manageChannel = async (id, action, record, value) => { - let data = { id }; - let res; - switch (action) { - case 'delete': - res = await API.delete(`/api/channel/${id}/`); - break; - case 'enable': - data.status = 1; - res = await API.put('/api/channel/', data); - break; - case 'disable': - data.status = 2; - res = await API.put('/api/channel/', data); - break; - case 'priority': - if (value === '') { - return; - } - data.priority = parseInt(value); - res = await API.put('/api/channel/', data); - break; - case 'weight': - if (value === '') { - return; - } - data.weight = parseInt(value); - if (data.weight < 0) { - data.weight = 0; - } - res = await API.put('/api/channel/', data); - break; - case 'enable_all': - data.channel_info = record.channel_info; - data.channel_info.multi_key_status_list = {}; - res = await API.put('/api/channel/', data); - break; - } - const { success, message } = res.data; - if (success) { - showSuccess(t('操作成功完成!')); - let channel = res.data.data; - let newChannels = [...channels]; - if (action === 'delete') { - } else { - record.status = channel.status; - } - setChannels(newChannels); - } else { - showError(message); - } - }; - - const manageTag = async (tag, action) => { - console.log(tag, action); - let res; - switch (action) { - case 'enable': - res = await API.post('/api/channel/tag/enabled', { - tag: tag, - }); - break; - case 'disable': - res = await API.post('/api/channel/tag/disabled', { - tag: tag, - }); - break; - } - const { success, message } = res.data; - if (success) { - showSuccess('操作成功完成!'); - let newChannels = [...channels]; - for (let i = 0; i < newChannels.length; i++) { - if (newChannels[i].tag === tag) { - let status = action === 'enable' ? 1 : 2; - newChannels[i]?.children?.forEach((channel) => { - channel.status = status; - }); - newChannels[i].status = status; - } - } - setChannels(newChannels); - } else { - showError(message); - } - }; - - // 获取表单值的辅助函数 - const getFormValues = () => { - const formValues = formApi ? formApi.getValues() : {}; - return { - searchKeyword: formValues.searchKeyword || '', - searchGroup: formValues.searchGroup || '', - searchModel: formValues.searchModel || '', - }; - }; - - const searchChannels = async ( - enableTagMode, - typeKey = activeTypeKey, - statusF = statusFilter, - page = 1, - pageSz = pageSize, - sortFlag = idSort, - ) => { - const { searchKeyword, searchGroup, searchModel } = getFormValues(); - setSearching(true); - try { - if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF); - return; - } - - const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; - const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; - const res = await API.get( - `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`, - ); - const { success, message, data } = res.data; - if (success) { - const { items = [], total = 0, type_counts = {} } = data; - const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0); - setTypeCounts({ ...type_counts, all: sumAll }); - setChannelFormat(items, enableTagMode); - setChannelCount(total); - setActivePage(page); - } else { - showError(message); - } - } finally { - setSearching(false); - } - }; - - const updateChannelProperty = (channelId, updateFn) => { - // Create a new copy of channels array - const newChannels = [...channels]; - let updated = false; - - // Find and update the correct channel - newChannels.forEach((channel) => { - if (channel.children !== undefined) { - // If this is a tag group, search in its children - channel.children.forEach((child) => { - if (child.id === channelId) { - updateFn(child); - updated = true; - } - }); - } else if (channel.id === channelId) { - // Direct channel match - updateFn(channel); - updated = true; - } - }); - - // Only update state if we actually modified a channel - if (updated) { - setChannels(newChannels); - } - }; - - const processTestQueue = async () => { - if (!isProcessingQueue || testQueue.length === 0) return; - - const { channel, model, indexInFiltered } = testQueue[0]; - - // 自动翻页到正在测试的模型所在页 - if (currentTestChannel && currentTestChannel.id === channel.id) { - let pageNo; - if (indexInFiltered !== undefined) { - pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1; - } else { - const filteredModelsList = currentTestChannel.models - .split(',') - .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase())); - const modelIdx = filteredModelsList.indexOf(model); - pageNo = modelIdx !== -1 ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1 : 1; - } - setModelTablePage(pageNo); - } - - try { - setTestingModels(prev => new Set([...prev, model])); - const res = await API.get(`/api/channel/test/${channel.id}?model=${model}`); - const { success, message, time } = res.data; - - setModelTestResults(prev => ({ - ...prev, - [`${channel.id}-${model}`]: { success, time } - })); - - if (success) { - updateChannelProperty(channel.id, (ch) => { - ch.response_time = time * 1000; - ch.test_time = Date.now() / 1000; - }); - if (!model) { - showInfo( - t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。') - .replace('${name}', channel.name) - .replace('${time.toFixed(2)}', time.toFixed(2)), - ); - } - } else { - showError(message); - } - } catch (error) { - showError(error.message); - } finally { - setTestingModels(prev => { - const newSet = new Set(prev); - newSet.delete(model); - return newSet; - }); - } - - // 移除已处理的测试 - setTestQueue(prev => prev.slice(1)); - }; - - // 监听队列变化 - useEffect(() => { - if (testQueue.length > 0 && isProcessingQueue) { - processTestQueue(); - } else if (testQueue.length === 0 && isProcessingQueue) { - setIsProcessingQueue(false); - setIsBatchTesting(false); - } - }, [testQueue, isProcessingQueue]); - - const testChannel = async (record, model) => { - setTestQueue(prev => [...prev, { channel: record, model }]); - if (!isProcessingQueue) { - setIsProcessingQueue(true); - } - }; - - const batchTestModels = async () => { - if (!currentTestChannel) return; - - setIsBatchTesting(true); - - // 重置分页到第一页 - setModelTablePage(1); - - const filteredModels = currentTestChannel.models - .split(',') - .filter((model) => - model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), - ); - - setTestQueue( - filteredModels.map((model, idx) => ({ - channel: currentTestChannel, - model, - indexInFiltered: idx, // 记录在过滤列表中的顺序 - })), - ); - setIsProcessingQueue(true); - }; - - const handleCloseModal = () => { - if (isBatchTesting) { - // 清空测试队列来停止测试 - setTestQueue([]); - setIsProcessingQueue(false); - setIsBatchTesting(false); - showSuccess(t('已停止测试')); - } else { - setShowModelTestModal(false); - setModelSearchKeyword(''); - setSelectedModelKeys([]); - setModelTablePage(1); - } - }; - - const channelTypeCounts = useMemo(() => { - if (Object.keys(typeCounts).length > 0) return typeCounts; - // fallback 本地计算 - const counts = { all: channels.length }; - channels.forEach((channel) => { - const collect = (ch) => { - const type = ch.type; - counts[type] = (counts[type] || 0) + 1; - }; - if (channel.children !== undefined) { - channel.children.forEach(collect); - } else { - collect(channel); - } - }); - return counts; - }, [typeCounts, channels]); - - const availableTypeKeys = useMemo(() => { - const keys = ['all']; - Object.entries(channelTypeCounts).forEach(([k, v]) => { - if (k !== 'all' && v > 0) keys.push(String(k)); - }); - return keys; - }, [channelTypeCounts]); - - const renderTypeTabs = () => { - if (enableTagMode) return null; - - return ( - { - setActiveTypeKey(key); - setActivePage(1); - loadChannels(1, pageSize, idSort, enableTagMode, key); - }} - className="mb-4" - > - - {t('全部')} - - {channelTypeCounts['all'] || 0} - - - } - /> - - {CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => { - const key = String(option.value); - const count = channelTypeCounts[option.value] || 0; - return ( - - {getChannelIcon(option.value)} - {option.label} - - {count} - - - } - /> - ); - })} - - ); - }; - - let pageData = channels; - - const handlePageChange = (page) => { - const { searchKeyword, searchGroup, searchModel } = getFormValues(); - setActivePage(page); - if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - loadChannels(page, pageSize, idSort, enableTagMode).then(() => { }); - } else { - searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort); - } - }; - - const handlePageSizeChange = async (size) => { - localStorage.setItem('page-size', size + ''); - setPageSize(size); - setActivePage(1); - const { searchKeyword, searchGroup, searchModel } = getFormValues(); - if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - loadChannels(1, size, idSort, enableTagMode) - .then() - .catch((reason) => { - showError(reason); - }); - } else { - searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort); - } - }; - - const fetchGroups = async () => { - try { - let res = await API.get(`/api/group/`); - if (res === undefined) { - return; - } - setGroupOptions( - res.data.data.map((group) => ({ - label: group, - value: group, - })), - ); - } catch (error) { - showError(error.message); - } - }; - - const submitTagEdit = async (type, data) => { - switch (type) { - case 'priority': - if (data.priority === undefined || data.priority === '') { - showInfo('优先级必须是整数!'); - return; - } - data.priority = parseInt(data.priority); - break; - case 'weight': - if ( - data.weight === undefined || - data.weight < 0 || - data.weight === '' - ) { - showInfo('权重必须是非负整数!'); - return; - } - data.weight = parseInt(data.weight); - break; - } - - try { - const res = await API.put('/api/channel/tag', data); - if (res?.data?.success) { - showSuccess('更新成功!'); - await refresh(); - } - } catch (error) { - showError(error); - } - }; - - const closeEdit = () => { - setShowEdit(false); - }; - - const handleRow = (record, index) => { - if (record.status !== 1) { - return { - style: { - background: 'var(--semi-color-disabled-border)', - }, - }; - } else { - return {}; - } - }; - - const batchSetChannelTag = async () => { - if (selectedChannels.length === 0) { - showError(t('请先选择要设置标签的渠道!')); - return; - } - if (batchSetTagValue === '') { - showError(t('标签不能为空!')); - return; - } - let ids = selectedChannels.map((channel) => channel.id); - const res = await API.post('/api/channel/batch/tag', { - ids: ids, - tag: batchSetTagValue === '' ? null : batchSetTagValue, - }); - if (res.data.success) { - showSuccess( - t('已为 ${count} 个渠道设置标签!').replace('${count}', res.data.data), - ); - await refresh(); - setShowBatchSetTag(false); - } else { - showError(res.data.message); - } - }; - - const testAllChannels = async () => { - const res = await API.get(`/api/channel/test`); - const { success, message } = res.data; - if (success) { - showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。')); - } else { - showError(message); - } - }; - - const deleteAllDisabledChannels = async () => { - const res = await API.delete(`/api/channel/disabled`); - const { success, message, data } = res.data; - if (success) { - showSuccess( - t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data), - ); - await refresh(); - } else { - showError(message); - } - }; - - const updateAllChannelsBalance = async () => { - const res = await API.get(`/api/channel/update_balance`); - const { success, message } = res.data; - if (success) { - showInfo(t('已更新完毕所有已启用通道余额!')); - } else { - showError(message); - } - }; - - const updateChannelBalance = async (record) => { - const res = await API.get(`/api/channel/update_balance/${record.id}/`); - const { success, message, balance } = res.data; - if (success) { - updateChannelProperty(record.id, (channel) => { - channel.balance = balance; - channel.balance_updated_time = Date.now() / 1000; - }); - showInfo( - t('通道 ${name} 余额更新成功!').replace('${name}', record.name), - ); - } else { - showError(message); - } - }; - - const batchDeleteChannels = async () => { - if (selectedChannels.length === 0) { - showError(t('请先选择要删除的通道!')); - return; - } - setLoading(true); - let ids = []; - selectedChannels.forEach((channel) => { - ids.push(channel.id); - }); - const res = await API.post(`/api/channel/batch`, { ids: ids }); - const { success, message, data } = res.data; - if (success) { - showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data)); - await refresh(); - setTimeout(() => { - if (channels.length === 0 && activePage > 1) { - refresh(activePage - 1); - } - }, 100); - } else { - showError(message); - } - setLoading(false); - }; - - const fixChannelsAbilities = async () => { - const res = await API.post(`/api/channel/fix`); - const { success, message, data } = res.data; - if (success) { - showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails)); - await refresh(); - } else { - showError(message); - } - }; - - const renderHeader = () => ( -
- {renderTypeTabs()} -
-
- - - - - - - - - - - - - - - - - - - } - > - - - - -
- -
-
- - {t('使用ID排序')} - - { - localStorage.setItem('id-sort', v + ''); - setIdSort(v); - const { searchKeyword, searchGroup, searchModel } = getFormValues(); - if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - loadChannels(activePage, pageSize, v, enableTagMode); - } else { - searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v); - } - }} - /> -
- -
- - {t('开启批量操作')} - - { - localStorage.setItem('enable-batch-delete', v + ''); - setEnableBatchDelete(v); - }} - /> -
- -
- - {t('标签聚合模式')} - - { - localStorage.setItem('enable-tag-mode', v + ''); - setEnableTagMode(v); - setActivePage(1); - loadChannels(1, pageSize, idSort, v); - }} - /> -
- - {/* 状态筛选器 */} -
- - {t('状态筛选')} - - -
-
-
- - - -
-
- - - - - -
- -
-
setFormApi(api)} - onSubmit={() => searchChannels(enableTagMode)} - allowEmpty={true} - autoComplete="off" - layout="horizontal" - trigger="change" - stopValidateWithError={false} - className="flex flex-col md:flex-row items-center gap-4 w-full" - > -
- } - placeholder={t('渠道ID,名称,密钥,API地址')} - showClear - pure - /> -
-
- } - placeholder={t('模型关键字')} - showClear - pure - /> -
-
- { - // 延迟执行搜索,让表单值先更新 - setTimeout(() => { - searchChannels(enableTagMode); - }, 0); - }} - /> -
- - -
-
-
-
- ); - - return ( - <> - {renderColumnSelector()} - setShowEditTag(false)} - refresh={refresh} - /> - - - - rest) : getVisibleColumns()} - dataSource={pageData} - scroll={compactMode ? undefined : { x: 'max-content' }} - pagination={{ - currentPage: activePage, - pageSize: pageSize, - total: channelCount, - pageSizeOpts: [10, 20, 50, 100], - showSizeChanger: true, - onPageSizeChange: (size) => { - handlePageSizeChange(size); - }, - onPageChange: handlePageChange, - }} - expandAllRows={false} - onRow={handleRow} - rowSelection={ - enableBatchDelete - ? { - onChange: (selectedRowKeys, selectedRows) => { - setSelectedChannels(selectedRows); - }, - } - : null - } - empty={ - } - darkModeImage={} - description={t('搜索无结果')} - style={{ padding: 30 }} - /> - } - className="rounded-xl overflow-hidden" - size="middle" - loading={loading || searching} - /> - - - {/* 批量设置标签模态框 */} - setShowBatchSetTag(false)} - maskClosable={false} - centered={true} - size="small" - className="!rounded-lg" - > -
- {t('请输入要设置的标签名称')} -
- setBatchSetTagValue(v)} - /> -
- - {t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)} - -
-
- - {/* 模型测试弹窗 */} - -
- - {currentTestChannel.name} {t('渠道的模型测试')} - - - {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')} - -
- - ) - } - visible={showModelTestModal && currentTestChannel !== null} - onCancel={handleCloseModal} - footer={ -
- {isBatchTesting ? ( - - ) : ( - - )} - -
- } - maskClosable={!isBatchTesting} - className="!rounded-lg" - size={isMobile ? 'full-width' : 'large'} - > -
- {currentTestChannel && ( -
- {/* 搜索与操作按钮 */} -
- { - setModelSearchKeyword(v); - setModelTablePage(1); - }} - className="!w-full" - prefix={} - showClear - /> - - - - -
-
( -
- {text} -
- ) - }, - { - title: t('状态'), - dataIndex: 'status', - render: (text, record) => { - const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`]; - const isTesting = testingModels.has(record.model); - - if (isTesting) { - return ( - - {t('测试中')} - - ); - } - - if (!testResult) { - return ( - - {t('未开始')} - - ); - } - - return ( -
- - {testResult.success ? t('成功') : t('失败')} - - {testResult.success && ( - - {t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))} - - )} -
- ); - } - }, - { - title: '', - dataIndex: 'operate', - render: (text, record) => { - const isTesting = testingModels.has(record.model); - return ( - - ); - } - } - ]} - dataSource={(() => { - const filtered = currentTestChannel.models - .split(',') - .filter((model) => - model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), - ); - const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE; - const end = start + MODEL_TABLE_PAGE_SIZE; - return filtered.slice(start, end).map((model) => ({ - model, - key: model, - })); - })()} - rowSelection={{ - selectedRowKeys: selectedModelKeys, - onChange: (keys) => { - if (allSelectingRef.current) { - allSelectingRef.current = false; - return; - } - setSelectedModelKeys(keys); - }, - onSelectAll: (checked) => { - const filtered = currentTestChannel.models - .split(',') - .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase())); - allSelectingRef.current = true; - setSelectedModelKeys(checked ? filtered : []); - }, - }} - pagination={{ - currentPage: modelTablePage, - pageSize: MODEL_TABLE_PAGE_SIZE, - total: currentTestChannel.models - .split(',') - .filter((model) => - model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), - ).length, - showSizeChanger: false, - onPageChange: (page) => setModelTablePage(page), - }} - /> - - )} - - - - ); -}; - -export default ChannelsTable; +// 重构后的 ChannelsTable - 使用新的模块化架构 +export { default } from './channels/index.jsx'; diff --git a/web/src/components/table/LogsTable.js b/web/src/components/table/LogsTable.js index e3116e41..f181d9c6 100644 --- a/web/src/components/table/LogsTable.js +++ b/web/src/components/table/LogsTable.js @@ -36,11 +36,10 @@ import { Tag, Tooltip, Checkbox, - Card, Typography, - Divider, Form, } from '@douyinfe/semi-ui'; +import CardPro from '../common/ui/CardPro'; import { IllustrationNoResult, IllustrationNoResultDark, @@ -49,7 +48,7 @@ import { ITEMS_PER_PAGE } from '../../constants'; import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph'; import { IconSearch, IconHelpCircle } from '@douyinfe/semi-icons'; import { Route } from 'lucide-react'; -import { useTableCompactMode } from '../../hooks/useTableCompactMode'; +import { useTableCompactMode } from '../../hooks/common/useTableCompactMode'; const { Text } = Typography; @@ -1201,216 +1200,211 @@ const LogsTable = () => { return ( <> {renderColumnSelector()} - - -
- - - {t('消耗额度')}: {renderQuota(stat.quota)} - - - RPM: {stat.rpm} - - - TPM: {stat.tpm} - - - - -
-
+ {t('消耗额度')}: {renderQuota(stat.quota)} + + + RPM: {stat.rpm} + + + TPM: {stat.tpm} + + - + + + + } + searchArea={ +
setFormApi(api)} + onSubmit={refresh} + allowEmpty={true} + autoComplete='off' + layout='vertical' + trigger='change' + stopValidateWithError={false} + > +
+
+ {/* 时间选择器 */} +
+ +
- {/* 搜索表单区域 */} - setFormApi(api)} - onSubmit={refresh} - allowEmpty={true} - autoComplete='off' - layout='vertical' - trigger='change' - stopValidateWithError={false} - > -
-
- {/* 时间选择器 */} -
- } + placeholder={t('令牌名称')} + showClear + pure + size="small" + /> + + } + placeholder={t('模型名称')} + showClear + pure + size="small" + /> + + } + placeholder={t('分组')} + showClear + pure + size="small" + /> + + {isAdminUser && ( + <> + } + placeholder={t('渠道 ID')} showClear pure size="small" /> -
- - {/* 其他搜索字段 */} - } - placeholder={t('令牌名称')} - showClear - pure - size="small" - /> - - } - placeholder={t('模型名称')} - showClear - pure - size="small" - /> - - } - placeholder={t('分组')} - showClear - pure - size="small" - /> - - {isAdminUser && ( - <> - } - placeholder={t('渠道 ID')} - showClear - pure - size="small" - /> - } - placeholder={t('用户名称')} - showClear - pure - size="small" - /> - - )} -
- - {/* 操作按钮区域 */} -
- {/* 日志类型选择器 */} -
- } + placeholder={t('用户名称')} showClear pure - onChange={() => { - // 延迟执行搜索,让表单值先更新 + size="small" + /> + + )} +
+ + {/* 操作按钮区域 */} +
+ {/* 日志类型选择器 */} +
+ { + // 延迟执行搜索,让表单值先更新 + setTimeout(() => { + refresh(); + }, 0); + }} + size="small" + > + + {t('全部')} + + + {t('充值')} + + + {t('消费')} + + + {t('管理')} + + + {t('系统')} + + + {t('错误')} + + +
+ +
+ +
- -
- - - -
+ }, 100); + } + }} + size="small" + > + {t('重置')} + +
- -
+
+ } - shadows='always' - bordered={false} >
rest) : getVisibleColumns()} @@ -1450,7 +1444,7 @@ const LogsTable = () => { onPageChange: handlePageChange, }} /> - + ); }; diff --git a/web/src/components/table/MjLogsTable.js b/web/src/components/table/MjLogsTable.js index 0efe5e25..267a5be9 100644 --- a/web/src/components/table/MjLogsTable.js +++ b/web/src/components/table/MjLogsTable.js @@ -37,9 +37,7 @@ import { import { Button, - Card, Checkbox, - Divider, Empty, Form, ImagePreview, @@ -51,6 +49,7 @@ import { Tag, Typography } from '@douyinfe/semi-ui'; +import CardPro from '../common/ui/CardPro'; import { IllustrationNoResult, IllustrationNoResultDark @@ -60,7 +59,7 @@ import { IconEyeOpened, IconSearch, } from '@douyinfe/semi-icons'; -import { useTableCompactMode } from '../../hooks/useTableCompactMode'; +import { useTableCompactMode } from '../../hooks/common/useTableCompactMode'; const { Text } = Typography; @@ -798,42 +797,40 @@ const LogsTable = () => { <> {renderColumnSelector()} - -
-
- - {loading ? ( - - ) : ( - - {isAdminUser && showBanner - ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。') - : t('Midjourney 任务记录')} - - )} -
- + +
+ + {loading ? ( + + ) : ( + + {isAdminUser && showBanner + ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。') + : t('Midjourney 任务记录')} + + )}
- - - - {/* 搜索表单区域 */} + +
+ } + searchArea={
setFormApi(api)} @@ -920,10 +917,7 @@ const LogsTable = () => { - } - shadows='always' - bordered={false} >
rest) : getVisibleColumns()} @@ -950,8 +944,8 @@ const LogsTable = () => { onPageSizeChange: handlePageSizeChange, onPageChange: handlePageChange, }} - /> - + /> + { } }; - const renderHeader = () => ( -
-
-
-
- - {t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')} -
- -
-
- - - -
-
-
- - -
- -
- -
setFormApi(api)} - onSubmit={() => { - setActivePage(1); - searchRedemptions(null, 1, pageSize); - }} - allowEmpty={true} - autoComplete="off" - layout="horizontal" - trigger="change" - stopValidateWithError={false} - className="w-full md:w-auto order-1 md:order-2" - > -
-
- } - placeholder={t('关键字(id或者名称)')} - showClear - pure - size="small" - /> -
-
- - -
-
- -
-
- ); - return ( <> { handleClose={closeEdit} > - +
+ + {t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')} +
+ + + } + actionsArea={ +
+
+
+ + +
+ +
+ +
setFormApi(api)} + onSubmit={() => { + setActivePage(1); + searchRedemptions(null, 1, pageSize); + }} + allowEmpty={true} + autoComplete="off" + layout="horizontal" + trigger="change" + stopValidateWithError={false} + className="w-full md:w-auto order-1 md:order-2" + > +
+
+ } + placeholder={t('关键字(id或者名称)')} + showClear + pure + size="small" + /> +
+
+ + +
+
+ +
+ } >
rest) : columns} @@ -615,7 +605,7 @@ const RedemptionsTable = () => { className="rounded-xl overflow-hidden" size="middle" >
-
+ ); }; diff --git a/web/src/components/table/TaskLogsTable.js b/web/src/components/table/TaskLogsTable.js index dcfad292..0e3abbb7 100644 --- a/web/src/components/table/TaskLogsTable.js +++ b/web/src/components/table/TaskLogsTable.js @@ -26,9 +26,7 @@ import { import { Button, - Card, Checkbox, - Divider, Empty, Form, Layout, @@ -38,6 +36,7 @@ import { Tag, Typography } from '@douyinfe/semi-ui'; +import CardPro from '../common/ui/CardPro'; import { IllustrationNoResult, IllustrationNoResultDark @@ -47,7 +46,7 @@ import { IconEyeOpened, IconSearch, } from '@douyinfe/semi-icons'; -import { useTableCompactMode } from '../../hooks/useTableCompactMode'; +import { useTableCompactMode } from '../../hooks/common/useTableCompactMode'; import { TASK_ACTION_GENERATE, TASK_ACTION_TEXT_GENERATE } from '../../constants/common.constant'; const { Text } = Typography; @@ -648,118 +647,113 @@ const LogsTable = () => { <> {renderColumnSelector()} - -
-
- - {t('任务记录')} -
- + +
+ + {t('任务记录')}
- - - - {/* 搜索表单区域 */} -
setFormApi(api)} - onSubmit={refresh} - allowEmpty={true} - autoComplete="off" - layout="vertical" - trigger="change" - stopValidateWithError={false} + +
+ } + searchArea={ + setFormApi(api)} + onSubmit={refresh} + allowEmpty={true} + autoComplete="off" + layout="vertical" + trigger="change" + stopValidateWithError={false} + > +
+
+ {/* 时间选择器 */} +
+ - - {/* 渠道 ID - 仅管理员可见 */} - {isAdminUser && ( - } - placeholder={t('渠道 ID')} - showClear - pure - size="small" - /> - )}
- {/* 操作按钮区域 */} -
-
-
- - - -
+ {/* 任务 ID */} + } + placeholder={t('任务 ID')} + showClear + pure + size="small" + /> + + {/* 渠道 ID - 仅管理员可见 */} + {isAdminUser && ( + } + placeholder={t('渠道 ID')} + showClear + pure + size="small" + /> + )} +
+ + {/* 操作按钮区域 */} +
+
+
+ + +
- -
+
+ } - shadows='always' - bordered={false} > rest) : getVisibleColumns()} @@ -787,7 +781,7 @@ const LogsTable = () => { onPageChange: handlePageChange, }} /> - + { } }; - const renderHeader = () => ( -
-
-
-
- - {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} -
+ const renderDescriptionArea = () => ( +
+
+ + {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} +
+ +
+ ); + + const renderActionsArea = () => ( +
+ + + + + ), + }); + }} + size="small" + > + {t('复制所选令牌')} + + +
+ ); + + const renderSearchArea = () => ( +
setFormApi(api)} + onSubmit={searchTokens} + allowEmpty={true} + autoComplete="off" + layout="horizontal" + trigger="change" + stopValidateWithError={false} + className="w-full md:w-auto order-1 md:order-2" + > +
+
+ } + placeholder={t('搜索关键字')} + showClear + pure + size="small" + /> +
+
+ } + placeholder={t('密钥')} + showClear + pure + size="small" + /> +
+
-
-
- - - -
-
- - - - ), - }); }} + className="flex-1 md:flex-initial md:w-auto" size="small" > - {t('复制所选令牌')} - -
- - setFormApi(api)} - onSubmit={searchTokens} - allowEmpty={true} - autoComplete="off" - layout="horizontal" - trigger="change" - stopValidateWithError={false} - className="w-full md:w-auto order-1 md:order-2" - > -
-
- } - placeholder={t('搜索关键字')} - showClear - pure - size="small" - /> -
-
- } - placeholder={t('密钥')} - showClear - pure - size="small" - /> -
-
- - -
-
-
-
+ ); return ( @@ -871,11 +866,19 @@ const TokensTable = () => { handleClose={closeEdit} > - +
+ {renderActionsArea()} +
+
+ {renderSearchArea()} +
+
+ } >
{ @@ -910,7 +913,7 @@ const TokensTable = () => { className="rounded-xl overflow-hidden" size="middle" >
-
+ ); }; diff --git a/web/src/components/table/UsersTable.js b/web/src/components/table/UsersTable.js index 8cfc35b8..7a38fc03 100644 --- a/web/src/components/table/UsersTable.js +++ b/web/src/components/table/UsersTable.js @@ -17,8 +17,6 @@ import { } from 'lucide-react'; import { Button, - Card, - Divider, Dropdown, Empty, Form, @@ -29,6 +27,7 @@ import { Tooltip, Typography } from '@douyinfe/semi-ui'; +import CardPro from '../common/ui/CardPro'; import { IllustrationNoResult, IllustrationNoResultDark @@ -42,7 +41,7 @@ import { ITEMS_PER_PAGE } from '../../constants'; import AddUser from '../../pages/User/AddUser'; import EditUser from '../../pages/User/EditUser'; import { useTranslation } from 'react-i18next'; -import { useTableCompactMode } from '../../hooks/useTableCompactMode'; +import { useTableCompactMode } from '../../hooks/common/useTableCompactMode'; const { Text } = Typography; @@ -514,115 +513,7 @@ const UsersTable = () => { } }; - const renderHeader = () => ( -
-
-
-
- - {t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')} -
- -
-
- - -
-
- -
- -
setFormApi(api)} - onSubmit={() => { - setActivePage(1); - searchUsers(1, pageSize); - }} - allowEmpty={true} - autoComplete="off" - layout="horizontal" - trigger="change" - stopValidateWithError={false} - className="w-full md:w-auto order-1 md:order-2" - > -
-
- } - placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')} - showClear - pure - size="small" - /> -
-
- { - // 分组变化时自动搜索 - setTimeout(() => { - setActivePage(1); - searchUsers(1, pageSize); - }, 100); - }} - className="w-full" - showClear - pure - size="small" - /> -
-
- - -
-
-
-
-
- ); return ( <> @@ -638,11 +529,112 @@ const UsersTable = () => { editingUser={editingUser} > - +
+ + {t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')} +
+ + + } + actionsArea={ +
+
+ +
+ +
setFormApi(api)} + onSubmit={() => { + setActivePage(1); + searchUsers(1, pageSize); + }} + allowEmpty={true} + autoComplete="off" + layout="horizontal" + trigger="change" + stopValidateWithError={false} + className="w-full md:w-auto order-1 md:order-2" + > +
+
+ } + placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')} + showClear + pure + size="small" + /> +
+
+ { + // 分组变化时自动搜索 + setTimeout(() => { + setActivePage(1); + searchUsers(1, pageSize); + }, 100); + }} + className="w-full" + showClear + pure + size="small" + /> +
+
+ + +
+
+
+
+ } > rest) : columns} @@ -672,7 +664,7 @@ const UsersTable = () => { className="overflow-hidden" size="middle" /> - + ); }; diff --git a/web/src/components/table/channels/ChannelsActions.jsx b/web/src/components/table/channels/ChannelsActions.jsx new file mode 100644 index 00000000..f244243c --- /dev/null +++ b/web/src/components/table/channels/ChannelsActions.jsx @@ -0,0 +1,240 @@ +import React from 'react'; +import { + Button, + Dropdown, + Modal, + Switch, + Typography, + Select +} from '@douyinfe/semi-ui'; + +const ChannelsActions = ({ + enableBatchDelete, + batchDeleteChannels, + setShowBatchSetTag, + testAllChannels, + fixChannelsAbilities, + updateAllChannelsBalance, + deleteAllDisabledChannels, + compactMode, + setCompactMode, + idSort, + setIdSort, + setEnableBatchDelete, + enableTagMode, + setEnableTagMode, + statusFilter, + setStatusFilter, + getFormValues, + loadChannels, + searchChannels, + activeTypeKey, + activePage, + pageSize, + setActivePage, + t +}) => { + return ( +
+ {/* 第一行:批量操作按钮 + 设置开关 */} +
+ {/* 左侧:批量操作按钮 */} +
+ + + + + + + + + + + + + + + + + + + } + > + + + + +
+ + {/* 右侧:设置开关区域 */} +
+
+ + {t('使用ID排序')} + + { + localStorage.setItem('id-sort', v + ''); + setIdSort(v); + const { searchKeyword, searchGroup, searchModel } = getFormValues(); + if (searchKeyword === '' && searchGroup === '' && searchModel === '') { + loadChannels(activePage, pageSize, v, enableTagMode); + } else { + searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v); + } + }} + /> +
+ +
+ + {t('开启批量操作')} + + { + localStorage.setItem('enable-batch-delete', v + ''); + setEnableBatchDelete(v); + }} + /> +
+ +
+ + {t('标签聚合模式')} + + { + localStorage.setItem('enable-tag-mode', v + ''); + setEnableTagMode(v); + setActivePage(1); + loadChannels(1, pageSize, idSort, v); + }} + /> +
+ +
+ + {t('状态筛选')} + + +
+
+
+
+ ); +}; + +export default ChannelsActions; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsColumnDefs.js b/web/src/components/table/channels/ChannelsColumnDefs.js new file mode 100644 index 00000000..9f7c50de --- /dev/null +++ b/web/src/components/table/channels/ChannelsColumnDefs.js @@ -0,0 +1,604 @@ +import React from 'react'; +import { + Button, + Dropdown, + InputNumber, + Modal, + Space, + SplitButtonGroup, + Tag, + Tooltip, + Typography +} from '@douyinfe/semi-ui'; +import { + timestamp2string, + renderGroup, + renderQuota, + getChannelIcon, + renderQuotaWithAmount +} from '../../../helpers/index.js'; +import { CHANNEL_OPTIONS } from '../../../constants/index.js'; +import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons'; +import { FaRandom } from 'react-icons/fa'; + +// Render functions +const renderType = (type, channelInfo = undefined, t) => { + let type2label = new Map(); + for (let i = 0; i < CHANNEL_OPTIONS.length; i++) { + type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i]; + } + type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' }; + + let icon = getChannelIcon(type); + + if (channelInfo?.is_multi_key) { + icon = ( + channelInfo?.multi_key_mode === 'random' ? ( +
+ + {icon} +
+ ) : ( +
+ + {icon} +
+ ) + ) + } + + return ( + + {type2label[type]?.label} + + ); +}; + +const renderTagType = (t) => { + return ( + + {t('标签聚合')} + + ); +}; + +const renderStatus = (status, channelInfo = undefined, t) => { + if (channelInfo) { + if (channelInfo.is_multi_key) { + let keySize = channelInfo.multi_key_size; + let enabledKeySize = keySize; + if (channelInfo.multi_key_status_list) { + enabledKeySize = keySize - Object.keys(channelInfo.multi_key_status_list).length; + } + return renderMultiKeyStatus(status, keySize, enabledKeySize, t); + } + } + switch (status) { + case 1: + return ( + + {t('已启用')} + + ); + case 2: + return ( + + {t('已禁用')} + + ); + case 3: + return ( + + {t('自动禁用')} + + ); + default: + return ( + + {t('未知状态')} + + ); + } +}; + +const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => { + switch (status) { + case 1: + return ( + + {t('已启用')} {enabledKeySize}/{keySize} + + ); + case 2: + return ( + + {t('已禁用')} {enabledKeySize}/{keySize} + + ); + case 3: + return ( + + {t('自动禁用')} {enabledKeySize}/{keySize} + + ); + default: + return ( + + {t('未知状态')} {enabledKeySize}/{keySize} + + ); + } +} + +const renderResponseTime = (responseTime, t) => { + let time = responseTime / 1000; + time = time.toFixed(2) + t(' 秒'); + if (responseTime === 0) { + return ( + + {t('未测试')} + + ); + } else if (responseTime <= 1000) { + return ( + + {time} + + ); + } else if (responseTime <= 3000) { + return ( + + {time} + + ); + } else if (responseTime <= 5000) { + return ( + + {time} + + ); + } else { + return ( + + {time} + + ); + } +}; + +export const getChannelsColumns = ({ + t, + COLUMN_KEYS, + updateChannelBalance, + manageChannel, + manageTag, + submitTagEdit, + testChannel, + setCurrentTestChannel, + setShowModelTestModal, + setEditingChannel, + setShowEdit, + setShowEditTag, + setEditingTag, + copySelectedChannel, + refresh, + activePage, + channels +}) => { + return [ + { + key: COLUMN_KEYS.ID, + title: t('ID'), + dataIndex: 'id', + }, + { + key: COLUMN_KEYS.NAME, + title: t('名称'), + dataIndex: 'name', + }, + { + key: COLUMN_KEYS.GROUP, + title: t('分组'), + dataIndex: 'group', + render: (text, record, index) => ( +
+ + {text + ?.split(',') + .sort((a, b) => { + if (a === 'default') return -1; + if (b === 'default') return 1; + return a.localeCompare(b); + }) + .map((item, index) => renderGroup(item))} + +
+ ), + }, + { + key: COLUMN_KEYS.TYPE, + title: t('类型'), + dataIndex: 'type', + render: (text, record, index) => { + if (record.children === undefined) { + if (record.channel_info) { + if (record.channel_info.is_multi_key) { + return <>{renderType(text, record.channel_info, t)}; + } + } + return <>{renderType(text, undefined, t)}; + } else { + return <>{renderTagType(t)}; + } + }, + }, + { + key: COLUMN_KEYS.STATUS, + title: t('状态'), + dataIndex: 'status', + render: (text, record, index) => { + if (text === 3) { + if (record.other_info === '') { + record.other_info = '{}'; + } + let otherInfo = JSON.parse(record.other_info); + let reason = otherInfo['status_reason']; + let time = otherInfo['status_time']; + return ( +
+ + {renderStatus(text, record.channel_info, t)} + +
+ ); + } else { + return renderStatus(text, record.channel_info, t); + } + }, + }, + { + key: COLUMN_KEYS.RESPONSE_TIME, + title: t('响应时间'), + dataIndex: 'response_time', + render: (text, record, index) => ( +
{renderResponseTime(text, t)}
+ ), + }, + { + key: COLUMN_KEYS.BALANCE, + title: t('已用/剩余'), + dataIndex: 'expired_time', + render: (text, record, index) => { + if (record.children === undefined) { + return ( +
+ + + + {renderQuota(record.used_quota)} + + + + updateChannelBalance(record)} + > + {renderQuotaWithAmount(record.balance)} + + + +
+ ); + } else { + return ( + + + {renderQuota(record.used_quota)} + + + ); + } + }, + }, + { + key: COLUMN_KEYS.PRIORITY, + title: t('优先级'), + dataIndex: 'priority', + render: (text, record, index) => { + if (record.children === undefined) { + return ( +
+ { + manageChannel(record.id, 'priority', record, e.target.value); + }} + keepFocus={true} + innerButtons + defaultValue={record.priority} + min={-999} + size="small" + /> +
+ ); + } else { + return ( + { + Modal.warning({ + title: t('修改子渠道优先级'), + content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'), + onOk: () => { + if (e.target.value === '') { + return; + } + submitTagEdit('priority', { + tag: record.key, + priority: e.target.value, + }); + }, + }); + }} + innerButtons + defaultValue={record.priority} + min={-999} + size="small" + /> + ); + } + }, + }, + { + key: COLUMN_KEYS.WEIGHT, + title: t('权重'), + dataIndex: 'weight', + render: (text, record, index) => { + if (record.children === undefined) { + return ( +
+ { + manageChannel(record.id, 'weight', record, e.target.value); + }} + keepFocus={true} + innerButtons + defaultValue={record.weight} + min={0} + size="small" + /> +
+ ); + } else { + return ( + { + Modal.warning({ + title: t('修改子渠道权重'), + content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'), + onOk: () => { + if (e.target.value === '') { + return; + } + submitTagEdit('weight', { + tag: record.key, + weight: e.target.value, + }); + }, + }); + }} + innerButtons + defaultValue={record.weight} + min={-999} + size="small" + /> + ); + } + }, + }, + { + key: COLUMN_KEYS.OPERATE, + title: '', + dataIndex: 'operate', + fixed: 'right', + render: (text, record, index) => { + if (record.children === undefined) { + const moreMenuItems = [ + { + node: 'item', + name: t('删除'), + type: 'danger', + onClick: () => { + Modal.confirm({ + title: t('确定是否要删除此渠道?'), + content: t('此修改将不可逆'), + onOk: () => { + (async () => { + await manageChannel(record.id, 'delete', record); + await refresh(); + setTimeout(() => { + if (channels.length === 0 && activePage > 1) { + refresh(activePage - 1); + } + }, 100); + })(); + }, + }); + }, + }, + { + node: 'item', + name: t('复制'), + type: 'tertiary', + onClick: () => { + Modal.confirm({ + title: t('确定是否要复制此渠道?'), + content: t('复制渠道的所有信息'), + onOk: () => copySelectedChannel(record), + }); + }, + }, + ]; + + return ( + + + + + ) : ( + + ) + } + manageChannel(record.id, 'enable_all', record), + } + ]} + > + + ) : ( + + ) + )} + + + + + + + + + ); + } + }, + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsFilters.jsx b/web/src/components/table/channels/ChannelsFilters.jsx new file mode 100644 index 00000000..4b3804df --- /dev/null +++ b/web/src/components/table/channels/ChannelsFilters.jsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { Button, Form } from '@douyinfe/semi-ui'; +import { IconSearch } from '@douyinfe/semi-icons'; + +const ChannelsFilters = ({ + setEditingChannel, + setShowEdit, + refresh, + setShowColumnSelector, + formInitValues, + setFormApi, + searchChannels, + enableTagMode, + formApi, + groupOptions, + loading, + searching, + t +}) => { + return ( +
+
+ + + + + +
+ +
+
setFormApi(api)} + onSubmit={() => searchChannels(enableTagMode)} + allowEmpty={true} + autoComplete="off" + layout="horizontal" + trigger="change" + stopValidateWithError={false} + className="flex flex-col md:flex-row items-center gap-4 w-full" + > +
+ } + placeholder={t('渠道ID,名称,密钥,API地址')} + showClear + pure + /> +
+
+ } + placeholder={t('模型关键字')} + showClear + pure + /> +
+
+ { + // 延迟执行搜索,让表单值先更新 + setTimeout(() => { + searchChannels(enableTagMode); + }, 0); + }} + /> +
+ + + +
+
+ ); +}; + +export default ChannelsFilters; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsTable.jsx b/web/src/components/table/channels/ChannelsTable.jsx new file mode 100644 index 00000000..c95d0b17 --- /dev/null +++ b/web/src/components/table/channels/ChannelsTable.jsx @@ -0,0 +1,138 @@ +import React, { useMemo } from 'react'; +import { Table, Empty } from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; +import { getChannelsColumns } from './ChannelsColumnDefs.js'; + +const ChannelsTable = (channelsData) => { + const { + channels, + loading, + searching, + activePage, + pageSize, + channelCount, + enableBatchDelete, + compactMode, + visibleColumns, + setSelectedChannels, + handlePageChange, + handlePageSizeChange, + handleRow, + t, + COLUMN_KEYS, + // Column functions and data + updateChannelBalance, + manageChannel, + manageTag, + submitTagEdit, + testChannel, + setCurrentTestChannel, + setShowModelTestModal, + setEditingChannel, + setShowEdit, + setShowEditTag, + setEditingTag, + copySelectedChannel, + refresh, + } = channelsData; + + // Get all columns + const allColumns = useMemo(() => { + return getChannelsColumns({ + t, + COLUMN_KEYS, + updateChannelBalance, + manageChannel, + manageTag, + submitTagEdit, + testChannel, + setCurrentTestChannel, + setShowModelTestModal, + setEditingChannel, + setShowEdit, + setShowEditTag, + setEditingTag, + copySelectedChannel, + refresh, + activePage, + channels, + }); + }, [ + t, + COLUMN_KEYS, + updateChannelBalance, + manageChannel, + manageTag, + submitTagEdit, + testChannel, + setCurrentTestChannel, + setShowModelTestModal, + setEditingChannel, + setShowEdit, + setShowEditTag, + setEditingTag, + copySelectedChannel, + refresh, + activePage, + channels, + ]); + + // Filter columns based on visibility settings + const getVisibleColumns = () => { + return allColumns.filter((column) => visibleColumns[column.key]); + }; + + const visibleColumnsList = useMemo(() => { + return getVisibleColumns(); + }, [visibleColumns, allColumns]); + + const tableColumns = useMemo(() => { + return compactMode + ? visibleColumnsList.map(({ fixed, ...rest }) => rest) + : visibleColumnsList; + }, [compactMode, visibleColumnsList]); + + return ( +
{ + setSelectedChannels(selectedRows); + }, + } + : null + } + empty={ + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + className="rounded-xl overflow-hidden" + size="middle" + loading={loading || searching} + /> + ); +}; + +export default ChannelsTable; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsTabs.jsx b/web/src/components/table/channels/ChannelsTabs.jsx new file mode 100644 index 00000000..9115c4f5 --- /dev/null +++ b/web/src/components/table/channels/ChannelsTabs.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Tabs, TabPane, Tag } from '@douyinfe/semi-ui'; +import { CHANNEL_OPTIONS } from '../../../constants/index.js'; +import { getChannelIcon } from '../../../helpers/index.js'; + +const ChannelsTabs = ({ + enableTagMode, + activeTypeKey, + setActiveTypeKey, + channelTypeCounts, + availableTypeKeys, + loadChannels, + activePage, + pageSize, + idSort, + setActivePage, + t +}) => { + if (enableTagMode) return null; + + const handleTabChange = (key) => { + setActiveTypeKey(key); + setActivePage(1); + loadChannels(1, pageSize, idSort, enableTagMode, key); + }; + + return ( + + + {t('全部')} + + {channelTypeCounts['all'] || 0} + + + } + /> + + {CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => { + const key = String(option.value); + const count = channelTypeCounts[option.value] || 0; + return ( + + {getChannelIcon(option.value)} + {option.label} + + {count} + + + } + /> + ); + })} + + ); +}; + +export default ChannelsTabs; \ No newline at end of file diff --git a/web/src/components/table/channels/index.jsx b/web/src/components/table/channels/index.jsx new file mode 100644 index 00000000..45699306 --- /dev/null +++ b/web/src/components/table/channels/index.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import CardPro from '../../common/ui/CardPro.js'; +import ChannelsTable from './ChannelsTable.jsx'; +import ChannelsActions from './ChannelsActions.jsx'; +import ChannelsFilters from './ChannelsFilters.jsx'; +import ChannelsTabs from './ChannelsTabs.jsx'; +import { useChannelsData } from '../../../hooks/channels/useChannelsData.js'; +import BatchTagModal from './modals/BatchTagModal.jsx'; +import ModelTestModal from './modals/ModelTestModal.jsx'; +import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx'; +import EditChannel from '../../../pages/Channel/EditChannel.js'; +import EditTagModal from '../../../pages/Channel/EditTagModal.js'; + +const ChannelsPage = () => { + const channelsData = useChannelsData(); + + return ( + <> + {/* Modals */} + + channelsData.setShowEditTag(false)} + refresh={channelsData.refresh} + /> + + + + + {/* Main Content */} + } + actionsArea={} + searchArea={} + > + + + + ); +}; + +export default ChannelsPage; \ No newline at end of file diff --git a/web/src/components/table/channels/modals/BatchTagModal.jsx b/web/src/components/table/channels/modals/BatchTagModal.jsx new file mode 100644 index 00000000..5f3a7a93 --- /dev/null +++ b/web/src/components/table/channels/modals/BatchTagModal.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Modal, Input, Typography } from '@douyinfe/semi-ui'; + +const BatchTagModal = ({ + showBatchSetTag, + setShowBatchSetTag, + batchSetChannelTag, + batchSetTagValue, + setBatchSetTagValue, + selectedChannels, + t +}) => { + return ( + setShowBatchSetTag(false)} + maskClosable={false} + centered={true} + size="small" + className="!rounded-lg" + > +
+ {t('请输入要设置的标签名称')} +
+ setBatchSetTagValue(v)} + /> +
+ + {t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)} + +
+
+ ); +}; + +export default BatchTagModal; \ No newline at end of file diff --git a/web/src/components/table/channels/modals/ColumnSelectorModal.jsx b/web/src/components/table/channels/modals/ColumnSelectorModal.jsx new file mode 100644 index 00000000..8805a84b --- /dev/null +++ b/web/src/components/table/channels/modals/ColumnSelectorModal.jsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Modal, Button, Checkbox } from '@douyinfe/semi-ui'; +import { getChannelsColumns } from '../ChannelsColumnDefs.js'; + +const ColumnSelectorModal = ({ + showColumnSelector, + setShowColumnSelector, + visibleColumns, + handleColumnVisibilityChange, + handleSelectAll, + initDefaultColumns, + COLUMN_KEYS, + t, + // Props needed for getChannelsColumns + updateChannelBalance, + manageChannel, + manageTag, + submitTagEdit, + testChannel, + setCurrentTestChannel, + setShowModelTestModal, + setEditingChannel, + setShowEdit, + setShowEditTag, + setEditingTag, + copySelectedChannel, + refresh, + activePage, + channels, +}) => { + // Get all columns for display in selector + const allColumns = getChannelsColumns({ + t, + COLUMN_KEYS, + updateChannelBalance, + manageChannel, + manageTag, + submitTagEdit, + testChannel, + setCurrentTestChannel, + setShowModelTestModal, + setEditingChannel, + setShowEdit, + setShowEditTag, + setEditingTag, + copySelectedChannel, + refresh, + activePage, + channels, + }); + + return ( + setShowColumnSelector(false)} + footer={ +
+ + + +
+ } + > +
+ v === true)} + indeterminate={ + Object.values(visibleColumns).some((v) => v === true) && + !Object.values(visibleColumns).every((v) => v === true) + } + onChange={(e) => handleSelectAll(e.target.checked)} + > + {t('全选')} + +
+
+ {allColumns.map((column) => { + // Skip columns without title + if (!column.title) { + return null; + } + + return ( +
+ + handleColumnVisibilityChange(column.key, e.target.checked) + } + > + {column.title} + +
+ ); + })} +
+
+ ); +}; + +export default ColumnSelectorModal; \ No newline at end of file diff --git a/web/src/components/table/channels/modals/ModelTestModal.jsx b/web/src/components/table/channels/modals/ModelTestModal.jsx new file mode 100644 index 00000000..05d272c0 --- /dev/null +++ b/web/src/components/table/channels/modals/ModelTestModal.jsx @@ -0,0 +1,256 @@ +import React from 'react'; +import { + Modal, + Button, + Input, + Table, + Tag, + Typography +} from '@douyinfe/semi-ui'; +import { IconSearch } from '@douyinfe/semi-icons'; +import { copy, showError, showInfo, showSuccess } from '../../../../helpers/index.js'; +import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants/index.js'; + +const ModelTestModal = ({ + showModelTestModal, + currentTestChannel, + handleCloseModal, + isBatchTesting, + batchTestModels, + modelSearchKeyword, + setModelSearchKeyword, + selectedModelKeys, + setSelectedModelKeys, + modelTestResults, + testingModels, + testChannel, + modelTablePage, + setModelTablePage, + allSelectingRef, + isMobile, + t +}) => { + if (!showModelTestModal || !currentTestChannel) { + return null; + } + + const filteredModels = currentTestChannel.models + .split(',') + .filter((model) => + model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) + ); + + const handleCopySelected = () => { + if (selectedModelKeys.length === 0) { + showError(t('请先选择模型!')); + return; + } + copy(selectedModelKeys.join(',')).then((ok) => { + if (ok) { + showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length)); + } else { + showError(t('复制失败,请手动复制')); + } + }); + }; + + const handleSelectSuccess = () => { + if (!currentTestChannel) return; + const successKeys = currentTestChannel.models + .split(',') + .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase())) + .filter((m) => { + const result = modelTestResults[`${currentTestChannel.id}-${m}`]; + return result && result.success; + }); + if (successKeys.length === 0) { + showInfo(t('暂无成功模型')); + } + setSelectedModelKeys(successKeys); + }; + + const columns = [ + { + title: t('模型名称'), + dataIndex: 'model', + render: (text) => ( +
+ {text} +
+ ) + }, + { + title: t('状态'), + dataIndex: 'status', + render: (text, record) => { + const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`]; + const isTesting = testingModels.has(record.model); + + if (isTesting) { + return ( + + {t('测试中')} + + ); + } + + if (!testResult) { + return ( + + {t('未开始')} + + ); + } + + return ( +
+ + {testResult.success ? t('成功') : t('失败')} + + {testResult.success && ( + + {t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))} + + )} +
+ ); + } + }, + { + title: '', + dataIndex: 'operate', + render: (text, record) => { + const isTesting = testingModels.has(record.model); + return ( + + ); + } + } + ]; + + const dataSource = (() => { + const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE; + const end = start + MODEL_TABLE_PAGE_SIZE; + return filteredModels.slice(start, end).map((model) => ({ + model, + key: model, + })); + })(); + + return ( + +
+ + {currentTestChannel.name} {t('渠道的模型测试')} + + + {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')} + +
+ + } + visible={showModelTestModal} + onCancel={handleCloseModal} + footer={ +
+ {isBatchTesting ? ( + + ) : ( + + )} + +
+ } + maskClosable={!isBatchTesting} + className="!rounded-lg" + size={isMobile ? 'full-width' : 'large'} + > +
+ {/* 搜索与操作按钮 */} +
+ { + setModelSearchKeyword(v); + setModelTablePage(1); + }} + className="!w-full" + prefix={} + showClear + /> + + + + +
+ +
{ + if (allSelectingRef.current) { + allSelectingRef.current = false; + return; + } + setSelectedModelKeys(keys); + }, + onSelectAll: (checked) => { + allSelectingRef.current = true; + setSelectedModelKeys(checked ? filteredModels : []); + }, + }} + pagination={{ + currentPage: modelTablePage, + pageSize: MODEL_TABLE_PAGE_SIZE, + total: filteredModels.length, + showSizeChanger: false, + onPageChange: (page) => setModelTablePage(page), + }} + /> + + + ); +}; + +export default ModelTestModal; \ No newline at end of file diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index 34ba78d7..8c7cb20f 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -1,7 +1,7 @@ import i18next from 'i18next'; import { Modal, Tag, Typography } from '@douyinfe/semi-ui'; import { copy, showSuccess } from './utils'; -import { MOBILE_BREAKPOINT } from '../hooks/useIsMobile.js'; +import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js'; import { visit } from 'unist-util-visit'; import { OpenAI, diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 6c4f1275..f74b437a 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -4,7 +4,7 @@ import React from 'react'; import { toast } from 'react-toastify'; import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants'; import { TABLE_COMPACT_MODES_KEY } from '../constants'; -import { MOBILE_BREAKPOINT } from '../hooks/useIsMobile.js'; +import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js'; const HTMLToastContent = ({ htmlContent }) => { return
; diff --git a/web/src/hooks/channels/useChannelsData.js b/web/src/hooks/channels/useChannelsData.js new file mode 100644 index 00000000..b6890f95 --- /dev/null +++ b/web/src/hooks/channels/useChannelsData.js @@ -0,0 +1,917 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + API, + showError, + showInfo, + showSuccess, + loadChannelModels, + copy +} from '../../helpers/index.js'; +import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js'; +import { useIsMobile } from '../common/useIsMobile.js'; +import { useTableCompactMode } from '../common/useTableCompactMode.js'; +import { Modal } from '@douyinfe/semi-ui'; + +export const useChannelsData = () => { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + + // Basic states + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [activePage, setActivePage] = useState(1); + const [idSort, setIdSort] = useState(false); + const [searching, setSearching] = useState(false); + const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); + const [channelCount, setChannelCount] = useState(ITEMS_PER_PAGE); + const [groupOptions, setGroupOptions] = useState([]); + + // UI states + const [showEdit, setShowEdit] = useState(false); + const [enableBatchDelete, setEnableBatchDelete] = useState(false); + const [editingChannel, setEditingChannel] = useState({ id: undefined }); + const [showEditTag, setShowEditTag] = useState(false); + const [editingTag, setEditingTag] = useState(''); + const [selectedChannels, setSelectedChannels] = useState([]); + const [enableTagMode, setEnableTagMode] = useState(false); + const [showBatchSetTag, setShowBatchSetTag] = useState(false); + const [batchSetTagValue, setBatchSetTagValue] = useState(''); + const [compactMode, setCompactMode] = useTableCompactMode('channels'); + + // Column visibility states + const [visibleColumns, setVisibleColumns] = useState({}); + const [showColumnSelector, setShowColumnSelector] = useState(false); + + // Status filter + const [statusFilter, setStatusFilter] = useState( + localStorage.getItem('channel-status-filter') || 'all' + ); + + // Type tabs states + const [activeTypeKey, setActiveTypeKey] = useState('all'); + const [typeCounts, setTypeCounts] = useState({}); + + // Model test states + const [showModelTestModal, setShowModelTestModal] = useState(false); + const [currentTestChannel, setCurrentTestChannel] = useState(null); + const [modelSearchKeyword, setModelSearchKeyword] = useState(''); + const [modelTestResults, setModelTestResults] = useState({}); + const [testingModels, setTestingModels] = useState(new Set()); + const [selectedModelKeys, setSelectedModelKeys] = useState([]); + const [isBatchTesting, setIsBatchTesting] = useState(false); + const [testQueue, setTestQueue] = useState([]); + const [isProcessingQueue, setIsProcessingQueue] = useState(false); + const [modelTablePage, setModelTablePage] = useState(1); + + // Refs + const requestCounter = useRef(0); + const allSelectingRef = useRef(false); + const [formApi, setFormApi] = useState(null); + + const formInitValues = { + searchKeyword: '', + searchGroup: '', + searchModel: '', + }; + + // Column keys + const COLUMN_KEYS = { + ID: 'id', + NAME: 'name', + GROUP: 'group', + TYPE: 'type', + STATUS: 'status', + RESPONSE_TIME: 'response_time', + BALANCE: 'balance', + PRIORITY: 'priority', + WEIGHT: 'weight', + OPERATE: 'operate', + }; + + // Initialize from localStorage + useEffect(() => { + const localIdSort = localStorage.getItem('id-sort') === 'true'; + const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; + const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true'; + const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true'; + + setIdSort(localIdSort); + setPageSize(localPageSize); + setEnableTagMode(localEnableTagMode); + setEnableBatchDelete(localEnableBatchDelete); + + loadChannels(1, localPageSize, localIdSort, localEnableTagMode) + .then() + .catch((reason) => { + showError(reason); + }); + fetchGroups().then(); + loadChannelModels().then(); + }, []); + + // Column visibility management + const getDefaultColumnVisibility = () => { + return { + [COLUMN_KEYS.ID]: true, + [COLUMN_KEYS.NAME]: true, + [COLUMN_KEYS.GROUP]: true, + [COLUMN_KEYS.TYPE]: true, + [COLUMN_KEYS.STATUS]: true, + [COLUMN_KEYS.RESPONSE_TIME]: true, + [COLUMN_KEYS.BALANCE]: true, + [COLUMN_KEYS.PRIORITY]: true, + [COLUMN_KEYS.WEIGHT]: true, + [COLUMN_KEYS.OPERATE]: true, + }; + }; + + const initDefaultColumns = () => { + const defaults = getDefaultColumnVisibility(); + setVisibleColumns(defaults); + }; + + // Load saved column preferences + useEffect(() => { + const savedColumns = localStorage.getItem('channels-table-columns'); + if (savedColumns) { + try { + const parsed = JSON.parse(savedColumns); + const defaults = getDefaultColumnVisibility(); + const merged = { ...defaults, ...parsed }; + setVisibleColumns(merged); + } catch (e) { + console.error('Failed to parse saved column preferences', e); + initDefaultColumns(); + } + } else { + initDefaultColumns(); + } + }, []); + + // Save column preferences + useEffect(() => { + if (Object.keys(visibleColumns).length > 0) { + localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns)); + } + }, [visibleColumns]); + + const handleColumnVisibilityChange = (columnKey, checked) => { + const updatedColumns = { ...visibleColumns, [columnKey]: checked }; + setVisibleColumns(updatedColumns); + }; + + const handleSelectAll = (checked) => { + const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]); + const updatedColumns = {}; + allKeys.forEach((key) => { + updatedColumns[key] = checked; + }); + setVisibleColumns(updatedColumns); + }; + + // Data formatting + const setChannelFormat = (channels, enableTagMode) => { + let channelDates = []; + let channelTags = {}; + + for (let i = 0; i < channels.length; i++) { + channels[i].key = '' + channels[i].id; + if (!enableTagMode) { + channelDates.push(channels[i]); + } else { + let tag = channels[i].tag ? channels[i].tag : ''; + let tagIndex = channelTags[tag]; + let tagChannelDates = undefined; + + if (tagIndex === undefined) { + channelTags[tag] = 1; + tagChannelDates = { + key: tag, + id: tag, + tag: tag, + name: '标签:' + tag, + group: '', + used_quota: 0, + response_time: 0, + priority: -1, + weight: -1, + }; + tagChannelDates.children = []; + channelDates.push(tagChannelDates); + } else { + tagChannelDates = channelDates.find((item) => item.key === tag); + } + + if (tagChannelDates.priority === -1) { + tagChannelDates.priority = channels[i].priority; + } else { + if (tagChannelDates.priority !== channels[i].priority) { + tagChannelDates.priority = ''; + } + } + + if (tagChannelDates.weight === -1) { + tagChannelDates.weight = channels[i].weight; + } else { + if (tagChannelDates.weight !== channels[i].weight) { + tagChannelDates.weight = ''; + } + } + + if (tagChannelDates.group === '') { + tagChannelDates.group = channels[i].group; + } else { + let channelGroupsStr = channels[i].group; + channelGroupsStr.split(',').forEach((item, index) => { + if (tagChannelDates.group.indexOf(item) === -1) { + tagChannelDates.group += ',' + item; + } + }); + } + + tagChannelDates.children.push(channels[i]); + if (channels[i].status === 1) { + tagChannelDates.status = 1; + } + tagChannelDates.used_quota += channels[i].used_quota; + tagChannelDates.response_time += channels[i].response_time; + tagChannelDates.response_time = tagChannelDates.response_time / 2; + } + } + setChannels(channelDates); + }; + + // Get form values helper + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + searchGroup: formValues.searchGroup || '', + searchModel: formValues.searchModel || '', + }; + }; + + // Load channels + const loadChannels = async ( + page, + pageSize, + idSort, + enableTagMode, + typeKey = activeTypeKey, + statusF, + ) => { + if (statusF === undefined) statusF = statusFilter; + + const { searchKeyword, searchGroup, searchModel } = getFormValues(); + if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') { + setLoading(true); + await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort); + setLoading(false); + return; + } + + const reqId = ++requestCounter.current; + setLoading(true); + const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; + const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; + const res = await API.get( + `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`, + ); + + if (res === undefined || reqId !== requestCounter.current) { + return; + } + + const { success, message, data } = res.data; + if (success) { + const { items, total, type_counts } = data; + if (type_counts) { + const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0); + setTypeCounts({ ...type_counts, all: sumAll }); + } + setChannelFormat(items, enableTagMode); + setChannelCount(total); + } else { + showError(message); + } + setLoading(false); + }; + + // Search channels + const searchChannels = async ( + enableTagMode, + typeKey = activeTypeKey, + statusF = statusFilter, + page = 1, + pageSz = pageSize, + sortFlag = idSort, + ) => { + const { searchKeyword, searchGroup, searchModel } = getFormValues(); + setSearching(true); + try { + if (searchKeyword === '' && searchGroup === '' && searchModel === '') { + await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF); + return; + } + + const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; + const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; + const res = await API.get( + `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`, + ); + const { success, message, data } = res.data; + if (success) { + const { items = [], total = 0, type_counts = {} } = data; + const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0); + setTypeCounts({ ...type_counts, all: sumAll }); + setChannelFormat(items, enableTagMode); + setChannelCount(total); + setActivePage(page); + } else { + showError(message); + } + } finally { + setSearching(false); + } + }; + + // Refresh + const refresh = async (page = activePage) => { + const { searchKeyword, searchGroup, searchModel } = getFormValues(); + if (searchKeyword === '' && searchGroup === '' && searchModel === '') { + await loadChannels(page, pageSize, idSort, enableTagMode); + } else { + await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort); + } + }; + + // Channel management + const manageChannel = async (id, action, record, value) => { + let data = { id }; + let res; + switch (action) { + case 'delete': + res = await API.delete(`/api/channel/${id}/`); + break; + case 'enable': + data.status = 1; + res = await API.put('/api/channel/', data); + break; + case 'disable': + data.status = 2; + res = await API.put('/api/channel/', data); + break; + case 'priority': + if (value === '') return; + data.priority = parseInt(value); + res = await API.put('/api/channel/', data); + break; + case 'weight': + if (value === '') return; + data.weight = parseInt(value); + if (data.weight < 0) data.weight = 0; + res = await API.put('/api/channel/', data); + break; + case 'enable_all': + data.channel_info = record.channel_info; + data.channel_info.multi_key_status_list = {}; + res = await API.put('/api/channel/', data); + break; + } + const { success, message } = res.data; + if (success) { + showSuccess(t('操作成功完成!')); + let channel = res.data.data; + let newChannels = [...channels]; + if (action !== 'delete') { + record.status = channel.status; + } + setChannels(newChannels); + } else { + showError(message); + } + }; + + // Tag management + const manageTag = async (tag, action) => { + let res; + switch (action) { + case 'enable': + res = await API.post('/api/channel/tag/enabled', { tag: tag }); + break; + case 'disable': + res = await API.post('/api/channel/tag/disabled', { tag: tag }); + break; + } + const { success, message } = res.data; + if (success) { + showSuccess('操作成功完成!'); + let newChannels = [...channels]; + for (let i = 0; i < newChannels.length; i++) { + if (newChannels[i].tag === tag) { + let status = action === 'enable' ? 1 : 2; + newChannels[i]?.children?.forEach((channel) => { + channel.status = status; + }); + newChannels[i].status = status; + } + } + setChannels(newChannels); + } else { + showError(message); + } + }; + + // Page handlers + const handlePageChange = (page) => { + const { searchKeyword, searchGroup, searchModel } = getFormValues(); + setActivePage(page); + if (searchKeyword === '' && searchGroup === '' && searchModel === '') { + loadChannels(page, pageSize, idSort, enableTagMode).then(() => { }); + } else { + searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort); + } + }; + + const handlePageSizeChange = async (size) => { + localStorage.setItem('page-size', size + ''); + setPageSize(size); + setActivePage(1); + const { searchKeyword, searchGroup, searchModel } = getFormValues(); + if (searchKeyword === '' && searchGroup === '' && searchModel === '') { + loadChannels(1, size, idSort, enableTagMode) + .then() + .catch((reason) => { + showError(reason); + }); + } else { + searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort); + } + }; + + // Fetch groups + const fetchGroups = async () => { + try { + let res = await API.get(`/api/group/`); + if (res === undefined) return; + setGroupOptions( + res.data.data.map((group) => ({ + label: group, + value: group, + })), + ); + } catch (error) { + showError(error.message); + } + }; + + // Copy channel + const copySelectedChannel = async (record) => { + try { + const res = await API.post(`/api/channel/copy/${record.id}`); + if (res?.data?.success) { + showSuccess(t('渠道复制成功')); + await refresh(); + } else { + showError(res?.data?.message || t('渠道复制失败')); + } + } catch (error) { + showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error)); + } + }; + + // Update channel property + const updateChannelProperty = (channelId, updateFn) => { + const newChannels = [...channels]; + let updated = false; + + newChannels.forEach((channel) => { + if (channel.children !== undefined) { + channel.children.forEach((child) => { + if (child.id === channelId) { + updateFn(child); + updated = true; + } + }); + } else if (channel.id === channelId) { + updateFn(channel); + updated = true; + } + }); + + if (updated) { + setChannels(newChannels); + } + }; + + // Tag edit + const submitTagEdit = async (type, data) => { + switch (type) { + case 'priority': + if (data.priority === undefined || data.priority === '') { + showInfo('优先级必须是整数!'); + return; + } + data.priority = parseInt(data.priority); + break; + case 'weight': + if (data.weight === undefined || data.weight < 0 || data.weight === '') { + showInfo('权重必须是非负整数!'); + return; + } + data.weight = parseInt(data.weight); + break; + } + + try { + const res = await API.put('/api/channel/tag', data); + if (res?.data?.success) { + showSuccess('更新成功!'); + await refresh(); + } + } catch (error) { + showError(error); + } + }; + + // Close edit + const closeEdit = () => { + setShowEdit(false); + }; + + // Row style + const handleRow = (record, index) => { + if (record.status !== 1) { + return { + style: { + background: 'var(--semi-color-disabled-border)', + }, + }; + } else { + return {}; + } + }; + + // Batch operations + const batchSetChannelTag = async () => { + if (selectedChannels.length === 0) { + showError(t('请先选择要设置标签的渠道!')); + return; + } + if (batchSetTagValue === '') { + showError(t('标签不能为空!')); + return; + } + let ids = selectedChannels.map((channel) => channel.id); + const res = await API.post('/api/channel/batch/tag', { + ids: ids, + tag: batchSetTagValue === '' ? null : batchSetTagValue, + }); + if (res.data.success) { + showSuccess( + t('已为 ${count} 个渠道设置标签!').replace('${count}', res.data.data), + ); + await refresh(); + setShowBatchSetTag(false); + } else { + showError(res.data.message); + } + }; + + const batchDeleteChannels = async () => { + if (selectedChannels.length === 0) { + showError(t('请先选择要删除的通道!')); + return; + } + setLoading(true); + let ids = []; + selectedChannels.forEach((channel) => { + ids.push(channel.id); + }); + const res = await API.post(`/api/channel/batch`, { ids: ids }); + const { success, message, data } = res.data; + if (success) { + showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data)); + await refresh(); + setTimeout(() => { + if (channels.length === 0 && activePage > 1) { + refresh(activePage - 1); + } + }, 100); + } else { + showError(message); + } + setLoading(false); + }; + + // Channel operations + const testAllChannels = async () => { + const res = await API.get(`/api/channel/test`); + const { success, message } = res.data; + if (success) { + showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。')); + } else { + showError(message); + } + }; + + const deleteAllDisabledChannels = async () => { + const res = await API.delete(`/api/channel/disabled`); + const { success, message, data } = res.data; + if (success) { + showSuccess( + t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data), + ); + await refresh(); + } else { + showError(message); + } + }; + + const updateAllChannelsBalance = async () => { + const res = await API.get(`/api/channel/update_balance`); + const { success, message } = res.data; + if (success) { + showInfo(t('已更新完毕所有已启用通道余额!')); + } else { + showError(message); + } + }; + + const updateChannelBalance = async (record) => { + const res = await API.get(`/api/channel/update_balance/${record.id}/`); + const { success, message, balance } = res.data; + if (success) { + updateChannelProperty(record.id, (channel) => { + channel.balance = balance; + channel.balance_updated_time = Date.now() / 1000; + }); + showInfo( + t('通道 ${name} 余额更新成功!').replace('${name}', record.name), + ); + } else { + showError(message); + } + }; + + const fixChannelsAbilities = async () => { + const res = await API.post(`/api/channel/fix`); + const { success, message, data } = res.data; + if (success) { + showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails)); + await refresh(); + } else { + showError(message); + } + }; + + // Test channel + const testChannel = async (record, model) => { + setTestQueue(prev => [...prev, { channel: record, model }]); + if (!isProcessingQueue) { + setIsProcessingQueue(true); + } + }; + + // Process test queue + const processTestQueue = async () => { + if (!isProcessingQueue || testQueue.length === 0) return; + + const { channel, model, indexInFiltered } = testQueue[0]; + + if (currentTestChannel && currentTestChannel.id === channel.id) { + let pageNo; + if (indexInFiltered !== undefined) { + pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1; + } else { + const filteredModelsList = currentTestChannel.models + .split(',') + .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase())); + const modelIdx = filteredModelsList.indexOf(model); + pageNo = modelIdx !== -1 ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1 : 1; + } + setModelTablePage(pageNo); + } + + try { + setTestingModels(prev => new Set([...prev, model])); + const res = await API.get(`/api/channel/test/${channel.id}?model=${model}`); + const { success, message, time } = res.data; + + setModelTestResults(prev => ({ + ...prev, + [`${channel.id}-${model}`]: { success, time } + })); + + if (success) { + updateChannelProperty(channel.id, (ch) => { + ch.response_time = time * 1000; + ch.test_time = Date.now() / 1000; + }); + if (!model) { + showInfo( + t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。') + .replace('${name}', channel.name) + .replace('${time.toFixed(2)}', time.toFixed(2)), + ); + } + } else { + showError(message); + } + } catch (error) { + showError(error.message); + } finally { + setTestingModels(prev => { + const newSet = new Set(prev); + newSet.delete(model); + return newSet; + }); + } + + setTestQueue(prev => prev.slice(1)); + }; + + // Monitor queue changes + useEffect(() => { + if (testQueue.length > 0 && isProcessingQueue) { + processTestQueue(); + } else if (testQueue.length === 0 && isProcessingQueue) { + setIsProcessingQueue(false); + setIsBatchTesting(false); + } + }, [testQueue, isProcessingQueue]); + + // Batch test models + const batchTestModels = async () => { + if (!currentTestChannel) return; + + setIsBatchTesting(true); + setModelTablePage(1); + + const filteredModels = currentTestChannel.models + .split(',') + .filter((model) => + model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), + ); + + setTestQueue( + filteredModels.map((model, idx) => ({ + channel: currentTestChannel, + model, + indexInFiltered: idx, + })), + ); + setIsProcessingQueue(true); + }; + + // Handle close modal + const handleCloseModal = () => { + if (isBatchTesting) { + setTestQueue([]); + setIsProcessingQueue(false); + setIsBatchTesting(false); + showSuccess(t('已停止测试')); + } else { + setShowModelTestModal(false); + setModelSearchKeyword(''); + setSelectedModelKeys([]); + setModelTablePage(1); + } + }; + + // Type counts + const channelTypeCounts = useMemo(() => { + if (Object.keys(typeCounts).length > 0) return typeCounts; + const counts = { all: channels.length }; + channels.forEach((channel) => { + const collect = (ch) => { + const type = ch.type; + counts[type] = (counts[type] || 0) + 1; + }; + if (channel.children !== undefined) { + channel.children.forEach(collect); + } else { + collect(channel); + } + }); + return counts; + }, [typeCounts, channels]); + + const availableTypeKeys = useMemo(() => { + const keys = ['all']; + Object.entries(channelTypeCounts).forEach(([k, v]) => { + if (k !== 'all' && v > 0) keys.push(String(k)); + }); + return keys; + }, [channelTypeCounts]); + + return { + // Basic states + channels, + loading, + searching, + activePage, + pageSize, + channelCount, + groupOptions, + idSort, + enableTagMode, + enableBatchDelete, + statusFilter, + compactMode, + + // UI states + showEdit, + setShowEdit, + editingChannel, + setEditingChannel, + showEditTag, + setShowEditTag, + editingTag, + setEditingTag, + selectedChannels, + setSelectedChannels, + showBatchSetTag, + setShowBatchSetTag, + batchSetTagValue, + setBatchSetTagValue, + + // Column states + visibleColumns, + showColumnSelector, + setShowColumnSelector, + COLUMN_KEYS, + + // Type tab states + activeTypeKey, + setActiveTypeKey, + typeCounts, + channelTypeCounts, + availableTypeKeys, + + // Model test states + showModelTestModal, + setShowModelTestModal, + currentTestChannel, + setCurrentTestChannel, + modelSearchKeyword, + setModelSearchKeyword, + modelTestResults, + testingModels, + selectedModelKeys, + setSelectedModelKeys, + isBatchTesting, + modelTablePage, + setModelTablePage, + allSelectingRef, + + // Form + formApi, + setFormApi, + formInitValues, + + // Helpers + t, + isMobile, + + // Functions + loadChannels, + searchChannels, + refresh, + manageChannel, + manageTag, + handlePageChange, + handlePageSizeChange, + copySelectedChannel, + updateChannelProperty, + submitTagEdit, + closeEdit, + handleRow, + batchSetChannelTag, + batchDeleteChannels, + testAllChannels, + deleteAllDisabledChannels, + updateAllChannelsBalance, + updateChannelBalance, + fixChannelsAbilities, + testChannel, + batchTestModels, + handleCloseModal, + getFormValues, + + // Column functions + handleColumnVisibilityChange, + handleSelectAll, + initDefaultColumns, + getDefaultColumnVisibility, + + // Setters + setIdSort, + setEnableTagMode, + setEnableBatchDelete, + setStatusFilter, + setCompactMode, + setActivePage, + }; +}; \ No newline at end of file diff --git a/web/src/hooks/useTokenKeys.js b/web/src/hooks/chat/useTokenKeys.js similarity index 87% rename from web/src/hooks/useTokenKeys.js rename to web/src/hooks/chat/useTokenKeys.js index eba69e08..24e5b95e 100644 --- a/web/src/hooks/useTokenKeys.js +++ b/web/src/hooks/chat/useTokenKeys.js @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { fetchTokenKeys, getServerAddress } from '../helpers/token'; -import { showError } from '../helpers'; +import { fetchTokenKeys, getServerAddress } from '../../helpers/token'; +import { showError } from '../../helpers'; export function useTokenKeys(id) { const [keys, setKeys] = useState([]); diff --git a/web/src/hooks/useIsMobile.js b/web/src/hooks/common/useIsMobile.js similarity index 100% rename from web/src/hooks/useIsMobile.js rename to web/src/hooks/common/useIsMobile.js diff --git a/web/src/hooks/useSidebarCollapsed.js b/web/src/hooks/common/useSidebarCollapsed.js similarity index 100% rename from web/src/hooks/useSidebarCollapsed.js rename to web/src/hooks/common/useSidebarCollapsed.js diff --git a/web/src/hooks/useTableCompactMode.js b/web/src/hooks/common/useTableCompactMode.js similarity index 89% rename from web/src/hooks/useTableCompactMode.js rename to web/src/hooks/common/useTableCompactMode.js index f943bda7..1238a173 100644 --- a/web/src/hooks/useTableCompactMode.js +++ b/web/src/hooks/common/useTableCompactMode.js @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; -import { getTableCompactMode, setTableCompactMode } from '../helpers'; -import { TABLE_COMPACT_MODES_KEY } from '../constants'; +import { getTableCompactMode, setTableCompactMode } from '../../helpers'; +import { TABLE_COMPACT_MODES_KEY } from '../../constants'; /** * 自定义 Hook:管理表格紧凑/自适应模式 diff --git a/web/src/hooks/useApiRequest.js b/web/src/hooks/playground/useApiRequest.js similarity index 99% rename from web/src/hooks/useApiRequest.js rename to web/src/hooks/playground/useApiRequest.js index 62c57032..f7bb2139 100644 --- a/web/src/hooks/useApiRequest.js +++ b/web/src/hooks/playground/useApiRequest.js @@ -5,13 +5,13 @@ import { API_ENDPOINTS, MESSAGE_STATUS, DEBUG_TABS -} from '../constants/playground.constants'; +} from '../../constants/playground.constants'; import { getUserIdFromLocalStorage, handleApiError, processThinkTags, processIncompleteThinkTags -} from '../helpers'; +} from '../../helpers'; export const useApiRequest = ( setMessage, diff --git a/web/src/hooks/useDataLoader.js b/web/src/hooks/playground/useDataLoader.js similarity index 92% rename from web/src/hooks/useDataLoader.js rename to web/src/hooks/playground/useDataLoader.js index 83d53199..4927fcf5 100644 --- a/web/src/hooks/useDataLoader.js +++ b/web/src/hooks/playground/useDataLoader.js @@ -1,7 +1,7 @@ import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { API, processModelsData, processGroupsData } from '../helpers'; -import { API_ENDPOINTS } from '../constants/playground.constants'; +import { API, processModelsData, processGroupsData } from '../../helpers'; +import { API_ENDPOINTS } from '../../constants/playground.constants'; export const useDataLoader = ( userState, diff --git a/web/src/hooks/useMessageActions.js b/web/src/hooks/playground/useMessageActions.js similarity index 98% rename from web/src/hooks/useMessageActions.js rename to web/src/hooks/playground/useMessageActions.js index 4cfcf9f1..e400f56f 100644 --- a/web/src/hooks/useMessageActions.js +++ b/web/src/hooks/playground/useMessageActions.js @@ -1,8 +1,8 @@ import { useCallback } from 'react'; import { Toast, Modal } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; -import { getTextContent } from '../helpers'; -import { ERROR_MESSAGES } from '../constants/playground.constants'; +import { getTextContent } from '../../helpers'; +import { ERROR_MESSAGES } from '../../constants/playground.constants'; export const useMessageActions = (message, setMessage, onMessageSend, saveMessages) => { const { t } = useTranslation(); diff --git a/web/src/hooks/useMessageEdit.js b/web/src/hooks/playground/useMessageEdit.js similarity index 97% rename from web/src/hooks/useMessageEdit.js rename to web/src/hooks/playground/useMessageEdit.js index 479524b6..5a8bfdc4 100644 --- a/web/src/hooks/useMessageEdit.js +++ b/web/src/hooks/playground/useMessageEdit.js @@ -1,8 +1,8 @@ import { useCallback, useState, useRef } from 'react'; import { Toast, Modal } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; -import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../helpers'; -import { MESSAGE_ROLES } from '../constants/playground.constants'; +import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../../helpers'; +import { MESSAGE_ROLES } from '../../constants/playground.constants'; export const useMessageEdit = ( setMessage, diff --git a/web/src/hooks/usePlaygroundState.js b/web/src/hooks/playground/usePlaygroundState.js similarity index 97% rename from web/src/hooks/usePlaygroundState.js rename to web/src/hooks/playground/usePlaygroundState.js index e8c4727d..253b95da 100644 --- a/web/src/hooks/usePlaygroundState.js +++ b/web/src/hooks/playground/usePlaygroundState.js @@ -1,7 +1,7 @@ import { useState, useCallback, useRef, useEffect } from 'react'; -import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../constants/playground.constants'; -import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage'; -import { processIncompleteThinkTags } from '../helpers'; +import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../../constants/playground.constants'; +import { loadConfig, saveConfig, loadMessages, saveMessages } from '../../components/playground/configStorage'; +import { processIncompleteThinkTags } from '../../helpers'; export const usePlaygroundState = () => { // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息 diff --git a/web/src/hooks/useSyncMessageAndCustomBody.js b/web/src/hooks/playground/useSyncMessageAndCustomBody.js similarity index 98% rename from web/src/hooks/useSyncMessageAndCustomBody.js rename to web/src/hooks/playground/useSyncMessageAndCustomBody.js index 6f0c19ad..f0f36734 100644 --- a/web/src/hooks/useSyncMessageAndCustomBody.js +++ b/web/src/hooks/playground/useSyncMessageAndCustomBody.js @@ -1,5 +1,5 @@ import { useCallback, useRef } from 'react'; -import { MESSAGE_ROLES } from '../constants/playground.constants'; +import { MESSAGE_ROLES } from '../../constants/playground.constants'; export const useSyncMessageAndCustomBody = ( customRequestMode, diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index 0934d891..c882fe10 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -8,7 +8,7 @@ import { showSuccess, verifyJSON, } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { CHANNEL_OPTIONS } from '../../constants'; import { SideSheet, diff --git a/web/src/pages/Chat/index.js b/web/src/pages/Chat/index.js index 52e91526..53fa03fb 100644 --- a/web/src/pages/Chat/index.js +++ b/web/src/pages/Chat/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import { useTokenKeys } from '../../hooks/useTokenKeys'; +import { useTokenKeys } from '../../hooks/chat/useTokenKeys'; import { Spin } from '@douyinfe/semi-ui'; import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/pages/Chat2Link/index.js b/web/src/pages/Chat2Link/index.js index f46bbd50..b3e17ac3 100644 --- a/web/src/pages/Chat2Link/index.js +++ b/web/src/pages/Chat2Link/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import { useTokenKeys } from '../../hooks/useTokenKeys'; +import { useTokenKeys } from '../../hooks/chat/useTokenKeys'; const chat2page = () => { const { keys, chatLink, serverAddress, isLoading } = useTokenKeys(); diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index 704093bb..f124452a 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/index.js @@ -54,7 +54,7 @@ import { copy, getRelativeTime } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { UserContext } from '../../context/User/index.js'; import { StatusContext } from '../../context/Status/index.js'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/pages/Home/index.js b/web/src/pages/Home/index.js index 582410d4..bf859091 100644 --- a/web/src/pages/Home/index.js +++ b/web/src/pages/Home/index.js @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useState } from 'react'; import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui'; import { API, showError, copy, showSuccess } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { API_ENDPOINTS } from '../../constants/common.constant'; import { StatusContext } from '../../context/Status'; import { marked } from 'marked'; diff --git a/web/src/pages/Playground/index.js b/web/src/pages/Playground/index.js index 345959a1..bc95d489 100644 --- a/web/src/pages/Playground/index.js +++ b/web/src/pages/Playground/index.js @@ -5,15 +5,15 @@ import { Layout, Toast, Modal } from '@douyinfe/semi-ui'; // Context import { UserContext } from '../../context/User/index.js'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; // hooks -import { usePlaygroundState } from '../../hooks/usePlaygroundState.js'; -import { useMessageActions } from '../../hooks/useMessageActions.js'; -import { useApiRequest } from '../../hooks/useApiRequest.js'; -import { useSyncMessageAndCustomBody } from '../../hooks/useSyncMessageAndCustomBody.js'; -import { useMessageEdit } from '../../hooks/useMessageEdit.js'; -import { useDataLoader } from '../../hooks/useDataLoader.js'; +import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState.js'; +import { useMessageActions } from '../../hooks/playground/useMessageActions.js'; +import { useApiRequest } from '../../hooks/playground/useApiRequest.js'; +import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody.js'; +import { useMessageEdit } from '../../hooks/playground/useMessageEdit.js'; +import { useDataLoader } from '../../hooks/playground/useDataLoader.js'; // Constants and utils import { diff --git a/web/src/pages/Redemption/EditRedemption.js b/web/src/pages/Redemption/EditRedemption.js index 44d17e62..310fdcd0 100644 --- a/web/src/pages/Redemption/EditRedemption.js +++ b/web/src/pages/Redemption/EditRedemption.js @@ -8,7 +8,7 @@ import { renderQuota, renderQuotaWithPrompt, } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { Button, Modal, diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js index 5a82f40b..3bb8d091 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -19,7 +19,7 @@ import { CheckCircle, } from 'lucide-react'; import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers'; -import { useIsMobile } from '../../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../../hooks/common/useIsMobile.js'; import { DEFAULT_ENDPOINT } from '../../../constants'; import { useTranslation } from 'react-i18next'; import { diff --git a/web/src/pages/Token/EditToken.js b/web/src/pages/Token/EditToken.js index 4eb9bcf4..7c7a61e9 100644 --- a/web/src/pages/Token/EditToken.js +++ b/web/src/pages/Token/EditToken.js @@ -8,7 +8,7 @@ import { renderQuotaWithPrompt, getModelCategories, } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { Button, SideSheet, diff --git a/web/src/pages/User/AddUser.js b/web/src/pages/User/AddUser.js index fa4c97e6..54d9b002 100644 --- a/web/src/pages/User/AddUser.js +++ b/web/src/pages/User/AddUser.js @@ -1,6 +1,6 @@ import React, { useState, useRef } from 'react'; import { API, showError, showSuccess } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { Button, SideSheet, diff --git a/web/src/pages/User/EditUser.js b/web/src/pages/User/EditUser.js index bfccf37b..53fa9b20 100644 --- a/web/src/pages/User/EditUser.js +++ b/web/src/pages/User/EditUser.js @@ -7,7 +7,7 @@ import { renderQuota, renderQuotaWithPrompt, } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { Button, Modal,