🚀 feat(web/channels): Deep modular refactor of Channels table

1. Split monolithic `ChannelsTable` (2200+ LOC) into focused components
   • `channels/index.jsx` – composition entry
   • `ChannelsTable.jsx` – pure `<Table>` rendering
   • `ChannelsActions.jsx` – bulk & settings toolbar
   • `ChannelsFilters.jsx` – search / create / column-settings form
   • `ChannelsTabs.jsx` – type tabs
   • `ChannelsColumnDefs.js` – column definitions & render helpers
   • `modals/` – BatchTag, ColumnSelector, ModelTest modals

2. Extract domain hook
   • Moved `useChannelsData.js` → `src/hooks/channels/useChannelsData.js`
     – centralises state, API calls, pagination, filters, batch ops
     – now exports `setActivePage`, fixing tab / status switch errors

3. Update wiring
   • All sub-components consume data via `useChannelsData` props
   • Adjusted import paths after hook relocation

4. Clean legacy file
   • Legacy `components/table/ChannelsTable.js` now re-exports new module

5. Bug fixes
   • Tab switching, status filter & tag aggregation restored
   • Column selector & batch actions operate via unified hook

This commit completes the first phase of modularising the Channels feature, laying groundwork for consistent, maintainable table architecture across the app.
This commit is contained in:
t0ng7u
2025-07-18 21:05:36 +08:00
parent f43c695527
commit 6799daacd1
48 changed files with 3489 additions and 3031 deletions

View File

@@ -1,6 +1,6 @@
import React, { lazy, Suspense } from 'react'; import React, { lazy, Suspense } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom'; 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 User from './pages/User';
import { AuthRedirect, PrivateRoute } from './helpers'; import { AuthRedirect, PrivateRoute } from './helpers';
import RegisterForm from './components/auth/RegisterForm.js'; import RegisterForm from './components/auth/RegisterForm.js';

View File

@@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers'; import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
import { UserContext } from '../../context/User'; import { UserContext } from '../../context/User';
import Loading from '../common/Loading'; import Loading from '../common/ui/Loading';
const OAuth2Callback = (props) => { const OAuth2Callback = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -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 (
<div className="flex flex-col w-full">
{/* 统计信息区域 - 用于type2 */}
{type === 'type2' && statsArea && (
<div className="mb-4">
{statsArea}
</div>
)}
{/* 描述信息区域 - 用于type1和type3 */}
{(type === 'type1' || type === 'type3') && descriptionArea && (
<div className="mb-2">
{descriptionArea}
</div>
)}
{/* 第一个分隔线 - 在描述信息或统计信息后面 */}
{((type === 'type1' || type === 'type3') && descriptionArea) ||
(type === 'type2' && statsArea) ? (
<Divider margin="12px" />
) : null}
{/* 类型切换/标签区域 - 主要用于type3 */}
{type === 'type3' && tabsArea && (
<div className="mb-4">
{tabsArea}
</div>
)}
{/* 操作按钮和搜索表单的容器 */}
<div className="flex flex-col gap-4">
{/* 操作按钮区域 - 用于type1和type3 */}
{(type === 'type1' || type === 'type3') && actionsArea && (
<div className="w-full">
{actionsArea}
</div>
)}
{/* 搜索表单区域 - 所有类型都可能有 */}
{searchArea && (
<div className="w-full">
{searchArea}
</div>
)}
</div>
</div>
);
};
const headerContent = renderHeader();
return (
<Card
className={`table-scroll-card !rounded-2xl ${className}`}
title={headerContent}
shadows={shadows}
bordered={bordered}
style={style}
{...props}
>
{children}
</Card>
);
};
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;

View File

@@ -31,8 +31,8 @@ import {
Badge, Badge,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { StatusContext } from '../../context/Status/index.js'; import { StatusContext } from '../../context/Status/index.js';
import { useIsMobile } from '../../hooks/useIsMobile.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js';
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();

View File

@@ -5,8 +5,8 @@ import App from '../../App.js';
import FooterBar from './Footer.js'; import FooterBar from './Footer.js';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/useIsMobile.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js';
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js'; import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
import { UserContext } from '../../context/User/index.js'; import { UserContext } from '../../context/User/index.js';

View File

@@ -3,7 +3,7 @@ import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js'; import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
import { import {
isAdmin, isAdmin,
isRoot, isRoot,

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { useIsMobile } from '../../hooks/useIsMobile.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js';
import { import {
Modal, Modal,
Table, Table,

File diff suppressed because it is too large Load Diff

View File

@@ -36,11 +36,10 @@ import {
Tag, Tag,
Tooltip, Tooltip,
Checkbox, Checkbox,
Card,
Typography, Typography,
Divider,
Form, Form,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import CardPro from '../common/ui/CardPro';
import { import {
IllustrationNoResult, IllustrationNoResult,
IllustrationNoResultDark, IllustrationNoResultDark,
@@ -49,7 +48,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph'; import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
import { IconSearch, IconHelpCircle } from '@douyinfe/semi-icons'; import { IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
import { Route } from 'lucide-react'; import { Route } from 'lucide-react';
import { useTableCompactMode } from '../../hooks/useTableCompactMode'; import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
const { Text } = Typography; const { Text } = Typography;
@@ -1201,10 +1200,10 @@ const LogsTable = () => {
return ( return (
<> <>
{renderColumnSelector()} {renderColumnSelector()}
<Card <CardPro
className='table-scroll-card !rounded-2xl mb-4' type="type2"
title={ className='mb-4'
<div className='flex flex-col w-full'> statsArea={
<Spin spinning={loadingStat}> <Spin spinning={loadingStat}>
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
<Space> <Space>
@@ -1254,10 +1253,8 @@ const LogsTable = () => {
</Button> </Button>
</div> </div>
</Spin> </Spin>
}
<Divider margin='12px' /> searchArea={
{/* 搜索表单区域 */}
<Form <Form
initValues={formInitValues} initValues={formInitValues}
getFormApi={(api) => setFormApi(api)} getFormApi={(api) => setFormApi(api)}
@@ -1407,10 +1404,7 @@ const LogsTable = () => {
</div> </div>
</div> </div>
</Form> </Form>
</div>
} }
shadows='always'
bordered={false}
> >
<Table <Table
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()} columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
@@ -1450,7 +1444,7 @@ const LogsTable = () => {
onPageChange: handlePageChange, onPageChange: handlePageChange,
}} }}
/> />
</Card> </CardPro>
</> </>
); );
}; };

View File

@@ -37,9 +37,7 @@ import {
import { import {
Button, Button,
Card,
Checkbox, Checkbox,
Divider,
Empty, Empty,
Form, Form,
ImagePreview, ImagePreview,
@@ -51,6 +49,7 @@ import {
Tag, Tag,
Typography Typography
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import CardPro from '../common/ui/CardPro';
import { import {
IllustrationNoResult, IllustrationNoResult,
IllustrationNoResultDark IllustrationNoResultDark
@@ -60,7 +59,7 @@ import {
IconEyeOpened, IconEyeOpened,
IconSearch, IconSearch,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { useTableCompactMode } from '../../hooks/useTableCompactMode'; import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
const { Text } = Typography; const { Text } = Typography;
@@ -798,10 +797,10 @@ const LogsTable = () => {
<> <>
{renderColumnSelector()} {renderColumnSelector()}
<Layout> <Layout>
<Card <CardPro
className="table-scroll-card !rounded-2xl mb-4" type="type2"
title={ className="mb-4"
<div className="flex flex-col w-full"> statsArea={
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
<div className="flex items-center text-orange-500 mb-2 md:mb-0"> <div className="flex items-center text-orange-500 mb-2 md:mb-0">
<IconEyeOpened className="mr-2" /> <IconEyeOpened className="mr-2" />
@@ -830,10 +829,8 @@ const LogsTable = () => {
{compactMode ? t('自适应列表') : t('紧凑列表')} {compactMode ? t('自适应列表') : t('紧凑列表')}
</Button> </Button>
</div> </div>
}
<Divider margin="12px" /> searchArea={
{/* 搜索表单区域 */}
<Form <Form
initValues={formInitValues} initValues={formInitValues}
getFormApi={(api) => setFormApi(api)} getFormApi={(api) => setFormApi(api)}
@@ -920,10 +917,7 @@ const LogsTable = () => {
</div> </div>
</div> </div>
</Form> </Form>
</div>
} }
shadows='always'
bordered={false}
> >
<Table <Table
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()} columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
@@ -951,7 +945,7 @@ const LogsTable = () => {
onPageChange: handlePageChange, onPageChange: handlePageChange,
}} }}
/> />
</Card> </CardPro>
<Modal <Modal
visible={isModalOpen} visible={isModalOpen}

View File

@@ -13,8 +13,6 @@ import { Ticket } from 'lucide-react';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import { import {
Button, Button,
Card,
Divider,
Dropdown, Dropdown,
Empty, Empty,
Form, Form,
@@ -25,6 +23,7 @@ import {
Tag, Tag,
Typography Typography
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import CardPro from '../common/ui/CardPro';
import { import {
IllustrationNoResult, IllustrationNoResult,
IllustrationNoResultDark IllustrationNoResultDark
@@ -35,7 +34,7 @@ import {
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import EditRedemption from '../../pages/Redemption/EditRedemption'; import EditRedemption from '../../pages/Redemption/EditRedemption';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode'; import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
const { Text } = Typography; const { Text } = Typography;
@@ -422,9 +421,18 @@ const RedemptionsTable = () => {
} }
}; };
const renderHeader = () => ( return (
<div className="flex flex-col w-full"> <>
<div className="mb-2"> <EditRedemption
refresh={refresh}
editingRedemption={editingRedemption}
visiable={showEdit}
handleClose={closeEdit}
></EditRedemption>
<CardPro
type="type1"
descriptionArea={
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
<div className="flex items-center text-orange-500"> <div className="flex items-center text-orange-500">
<Ticket size={16} className="mr-2" /> <Ticket size={16} className="mr-2" />
@@ -439,10 +447,8 @@ const RedemptionsTable = () => {
{compactMode ? t('自适应列表') : t('紧凑列表')} {compactMode ? t('自适应列表') : t('紧凑列表')}
</Button> </Button>
</div> </div>
</div> }
actionsArea={
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full"> <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto order-2 md:order-1"> <div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
<div className="flex gap-2 w-full sm:w-auto"> <div className="flex gap-2 w-full sm:w-auto">
@@ -561,23 +567,7 @@ const RedemptionsTable = () => {
</div> </div>
</Form> </Form>
</div> </div>
</div> }
);
return (
<>
<EditRedemption
refresh={refresh}
editingRedemption={editingRedemption}
visiable={showEdit}
handleClose={closeEdit}
></EditRedemption>
<Card
className="table-scroll-card !rounded-2xl"
title={renderHeader()}
shadows='always'
bordered={false}
> >
<Table <Table
columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns} columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
@@ -615,7 +605,7 @@ const RedemptionsTable = () => {
className="rounded-xl overflow-hidden" className="rounded-xl overflow-hidden"
size="middle" size="middle"
></Table> ></Table>
</Card> </CardPro>
</> </>
); );
}; };

View File

@@ -26,9 +26,7 @@ import {
import { import {
Button, Button,
Card,
Checkbox, Checkbox,
Divider,
Empty, Empty,
Form, Form,
Layout, Layout,
@@ -38,6 +36,7 @@ import {
Tag, Tag,
Typography Typography
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import CardPro from '../common/ui/CardPro';
import { import {
IllustrationNoResult, IllustrationNoResult,
IllustrationNoResultDark IllustrationNoResultDark
@@ -47,7 +46,7 @@ import {
IconEyeOpened, IconEyeOpened,
IconSearch, IconSearch,
} from '@douyinfe/semi-icons'; } 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'; import { TASK_ACTION_GENERATE, TASK_ACTION_TEXT_GENERATE } from '../../constants/common.constant';
const { Text } = Typography; const { Text } = Typography;
@@ -648,10 +647,10 @@ const LogsTable = () => {
<> <>
{renderColumnSelector()} {renderColumnSelector()}
<Layout> <Layout>
<Card <CardPro
className="table-scroll-card !rounded-2xl mb-4" type="type2"
title={ className="mb-4"
<div className="flex flex-col w-full"> statsArea={
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
<div className="flex items-center text-orange-500 mb-2 md:mb-0"> <div className="flex items-center text-orange-500 mb-2 md:mb-0">
<IconEyeOpened className="mr-2" /> <IconEyeOpened className="mr-2" />
@@ -666,10 +665,8 @@ const LogsTable = () => {
{compactMode ? t('自适应列表') : t('紧凑列表')} {compactMode ? t('自适应列表') : t('紧凑列表')}
</Button> </Button>
</div> </div>
}
<Divider margin="12px" /> searchArea={
{/* 搜索表单区域 */}
<Form <Form
initValues={formInitValues} initValues={formInitValues}
getFormApi={(api) => setFormApi(api)} getFormApi={(api) => setFormApi(api)}
@@ -756,10 +753,7 @@ const LogsTable = () => {
</div> </div>
</div> </div>
</Form> </Form>
</div>
} }
shadows='always'
bordered={false}
> >
<Table <Table
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()} columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
@@ -787,7 +781,7 @@ const LogsTable = () => {
onPageChange: handlePageChange, onPageChange: handlePageChange,
}} }}
/> />
</Card> </CardPro>
<Modal <Modal
visible={isModalOpen} visible={isModalOpen}

View File

@@ -12,8 +12,6 @@ import {
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import { import {
Button, Button,
Card,
Divider,
Dropdown, Dropdown,
Empty, Empty,
Form, Form,
@@ -30,6 +28,7 @@ import {
Input, Input,
Typography Typography
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import CardPro from '../common/ui/CardPro';
import { import {
IllustrationNoResult, IllustrationNoResult,
IllustrationNoResultDark IllustrationNoResultDark
@@ -44,7 +43,7 @@ import {
import { Key } from 'lucide-react'; import { Key } from 'lucide-react';
import EditToken from '../../pages/Token/EditToken'; import EditToken from '../../pages/Token/EditToken';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode'; import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
const { Text } = Typography; const { Text } = Typography;
@@ -689,9 +688,7 @@ const TokensTable = () => {
} }
}; };
const renderHeader = () => ( const renderDescriptionArea = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
<div className="flex items-center text-blue-500"> <div className="flex items-center text-blue-500">
<Key size={16} className="mr-2" /> <Key size={16} className="mr-2" />
@@ -706,11 +703,9 @@ const TokensTable = () => {
{compactMode ? t('自适应列表') : t('紧凑列表')} {compactMode ? t('自适应列表') : t('紧凑列表')}
</Button> </Button>
</div> </div>
</div> );
<Divider margin="12px" /> const renderActionsArea = () => (
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1"> <div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
<Button <Button
type="primary" type="primary"
@@ -796,7 +791,9 @@ const TokensTable = () => {
{t('删除所选令牌')} {t('删除所选令牌')}
</Button> </Button>
</div> </div>
);
const renderSearchArea = () => (
<Form <Form
initValues={formInitValues} initValues={formInitValues}
getFormApi={(api) => setFormApi(api)} getFormApi={(api) => setFormApi(api)}
@@ -858,8 +855,6 @@ const TokensTable = () => {
</div> </div>
</div> </div>
</Form> </Form>
</div>
</div>
); );
return ( return (
@@ -871,11 +866,19 @@ const TokensTable = () => {
handleClose={closeEdit} handleClose={closeEdit}
></EditToken> ></EditToken>
<Card <CardPro
className="table-scroll-card !rounded-2xl" type="type1"
title={renderHeader()} descriptionArea={renderDescriptionArea()}
shadows='always' actionsArea={
bordered={false} <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
{renderActionsArea()}
</div>
<div className="flex-1 md:flex-initial order-1 md:order-2">
{renderSearchArea()}
</div>
</div>
}
> >
<Table <Table
columns={compactMode ? columns.map(col => { columns={compactMode ? columns.map(col => {
@@ -910,7 +913,7 @@ const TokensTable = () => {
className="rounded-xl overflow-hidden" className="rounded-xl overflow-hidden"
size="middle" size="middle"
></Table> ></Table>
</Card> </CardPro>
</> </>
); );
}; };

View File

@@ -17,8 +17,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { import {
Button, Button,
Card,
Divider,
Dropdown, Dropdown,
Empty, Empty,
Form, Form,
@@ -29,6 +27,7 @@ import {
Tooltip, Tooltip,
Typography Typography
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import CardPro from '../common/ui/CardPro';
import { import {
IllustrationNoResult, IllustrationNoResult,
IllustrationNoResultDark IllustrationNoResultDark
@@ -42,7 +41,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
import AddUser from '../../pages/User/AddUser'; import AddUser from '../../pages/User/AddUser';
import EditUser from '../../pages/User/EditUser'; import EditUser from '../../pages/User/EditUser';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode'; import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
const { Text } = Typography; const { Text } = Typography;
@@ -514,9 +513,25 @@ const UsersTable = () => {
} }
}; };
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2"> return (
<>
<AddUser
refresh={refresh}
visible={showAddUser}
handleClose={closeAddUser}
></AddUser>
<EditUser
refresh={refresh}
visible={showEditUser}
handleClose={closeEditUser}
editingUser={editingUser}
></EditUser>
<CardPro
type="type1"
descriptionArea={
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
<div className="flex items-center text-blue-500"> <div className="flex items-center text-blue-500">
<IconUserAdd className="mr-2" /> <IconUserAdd className="mr-2" />
@@ -531,10 +546,8 @@ const UsersTable = () => {
{compactMode ? t('自适应列表') : t('紧凑列表')} {compactMode ? t('自适应列表') : t('紧凑列表')}
</Button> </Button>
</div> </div>
</div> }
actionsArea={
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full"> <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1"> <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button <Button
@@ -621,28 +634,7 @@ const UsersTable = () => {
</div> </div>
</Form> </Form>
</div> </div>
</div> }
);
return (
<>
<AddUser
refresh={refresh}
visible={showAddUser}
handleClose={closeAddUser}
></AddUser>
<EditUser
refresh={refresh}
visible={showEditUser}
handleClose={closeEditUser}
editingUser={editingUser}
></EditUser>
<Card
className="table-scroll-card !rounded-2xl"
title={renderHeader()}
shadows='always'
bordered={false}
> >
<Table <Table
columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns} columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
@@ -672,7 +664,7 @@ const UsersTable = () => {
className="overflow-hidden" className="overflow-hidden"
size="middle" size="middle"
/> />
</Card> </CardPro>
</> </>
); );
}; };

View File

@@ -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 (
<div className="flex flex-col gap-4">
{/* 第一行:批量操作按钮 + 设置开关 */}
<div className="flex flex-col md:flex-row justify-between gap-4">
{/* 左侧:批量操作按钮 */}
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
<Button
size='small'
disabled={!enableBatchDelete}
type='danger'
className="w-full md:w-auto"
onClick={() => {
Modal.confirm({
title: t('确定是否要删除所选通道?'),
content: t('此修改将不可逆'),
onOk: () => batchDeleteChannels(),
});
}}
>
{t('删除所选通道')}
</Button>
<Button
size='small'
disabled={!enableBatchDelete}
type='tertiary'
onClick={() => setShowBatchSetTag(true)}
className="w-full md:w-auto"
>
{t('批量设置标签')}
</Button>
<Dropdown
size='small'
trigger='click'
render={
<Dropdown.Menu>
<Dropdown.Item>
<Button
size='small'
type='tertiary'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定?'),
content: t('确定要测试所有通道吗?'),
onOk: () => testAllChannels(),
size: 'small',
centered: true,
});
}}
>
{t('测试所有通道')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定是否要修复数据库一致性?'),
content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
onOk: () => fixChannelsAbilities(),
size: 'sm',
centered: true,
});
}}
>
{t('修复数据库一致性')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
type='secondary'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定?'),
content: t('确定要更新所有已启用通道余额吗?'),
onOk: () => updateAllChannelsBalance(),
size: 'sm',
centered: true,
});
}}
>
{t('更新所有已启用通道余额')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
type='danger'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定是否要删除禁用通道?'),
content: t('此修改将不可逆'),
onOk: () => deleteAllDisabledChannels(),
size: 'sm',
centered: true,
});
}}
>
{t('删除禁用通道')}
</Button>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button size='small' theme='light' type='tertiary' className="w-full md:w-auto">
{t('批量操作')}
</Button>
</Dropdown>
<Button
size='small'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
{/* 右侧:设置开关区域 */}
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
{t('使用ID排序')}
</Typography.Text>
<Switch
size='small'
checked={idSort}
onChange={(v) => {
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);
}
}}
/>
</div>
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
{t('开启批量操作')}
</Typography.Text>
<Switch
size='small'
checked={enableBatchDelete}
onChange={(v) => {
localStorage.setItem('enable-batch-delete', v + '');
setEnableBatchDelete(v);
}}
/>
</div>
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
{t('标签聚合模式')}
</Typography.Text>
<Switch
size='small'
checked={enableTagMode}
onChange={(v) => {
localStorage.setItem('enable-tag-mode', v + '');
setEnableTagMode(v);
setActivePage(1);
loadChannels(1, pageSize, idSort, v);
}}
/>
</div>
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
{t('状态筛选')}
</Typography.Text>
<Select
size='small'
value={statusFilter}
onChange={(v) => {
localStorage.setItem('channel-status-filter', v);
setStatusFilter(v);
setActivePage(1);
loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
}}
>
<Select.Option value="all">{t('全部')}</Select.Option>
<Select.Option value="enabled">{t('已启用')}</Select.Option>
<Select.Option value="disabled">{t('已禁用')}</Select.Option>
</Select>
</div>
</div>
</div>
</div>
);
};
export default ChannelsActions;

View File

@@ -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' ? (
<div className="flex items-center gap-1">
<FaRandom className="text-blue-500" />
{icon}
</div>
) : (
<div className="flex items-center gap-1">
<IconTreeTriangleDown className="text-blue-500" />
{icon}
</div>
)
)
}
return (
<Tag
color={type2label[type]?.color}
shape='circle'
prefixIcon={icon}
>
{type2label[type]?.label}
</Tag>
);
};
const renderTagType = (t) => {
return (
<Tag
color='light-blue'
shape='circle'
type='light'
>
{t('标签聚合')}
</Tag>
);
};
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 (
<Tag color='green' shape='circle'>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag color='red' shape='circle'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='yellow' shape='circle'>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag color='grey' shape='circle'>
{t('未知状态')}
</Tag>
);
}
};
const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
switch (status) {
case 1:
return (
<Tag color='green' shape='circle'>
{t('已启用')} {enabledKeySize}/{keySize}
</Tag>
);
case 2:
return (
<Tag color='red' shape='circle'>
{t('已禁用')} {enabledKeySize}/{keySize}
</Tag>
);
case 3:
return (
<Tag color='yellow' shape='circle'>
{t('自动禁用')} {enabledKeySize}/{keySize}
</Tag>
);
default:
return (
<Tag color='grey' shape='circle'>
{t('未知状态')} {enabledKeySize}/{keySize}
</Tag>
);
}
}
const renderResponseTime = (responseTime, t) => {
let time = responseTime / 1000;
time = time.toFixed(2) + t(' 秒');
if (responseTime === 0) {
return (
<Tag color='grey' shape='circle'>
{t('未测试')}
</Tag>
);
} else if (responseTime <= 1000) {
return (
<Tag color='green' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 3000) {
return (
<Tag color='lime' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 5000) {
return (
<Tag color='yellow' shape='circle'>
{time}
</Tag>
);
} else {
return (
<Tag color='red' shape='circle'>
{time}
</Tag>
);
}
};
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) => (
<div>
<Space spacing={2}>
{text
?.split(',')
.sort((a, b) => {
if (a === 'default') return -1;
if (b === 'default') return 1;
return a.localeCompare(b);
})
.map((item, index) => renderGroup(item))}
</Space>
</div>
),
},
{
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 (
<div>
<Tooltip
content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
>
{renderStatus(text, record.channel_info, t)}
</Tooltip>
</div>
);
} else {
return renderStatus(text, record.channel_info, t);
}
},
},
{
key: COLUMN_KEYS.RESPONSE_TIME,
title: t('响应时间'),
dataIndex: 'response_time',
render: (text, record, index) => (
<div>{renderResponseTime(text, t)}</div>
),
},
{
key: COLUMN_KEYS.BALANCE,
title: t('已用/剩余'),
dataIndex: 'expired_time',
render: (text, record, index) => {
if (record.children === undefined) {
return (
<div>
<Space spacing={1}>
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' shape='circle'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
<Tooltip content={t('剩余额度$') + record.balance + t(',点击更新')}>
<Tag
color='white'
type='ghost'
shape='circle'
onClick={() => updateChannelBalance(record)}
>
{renderQuotaWithAmount(record.balance)}
</Tag>
</Tooltip>
</Space>
</div>
);
} else {
return (
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' shape='circle'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
);
}
},
},
{
key: COLUMN_KEYS.PRIORITY,
title: t('优先级'),
dataIndex: 'priority',
render: (text, record, index) => {
if (record.children === undefined) {
return (
<div>
<InputNumber
style={{ width: 70 }}
name='priority'
onBlur={(e) => {
manageChannel(record.id, 'priority', record, e.target.value);
}}
keepFocus={true}
innerButtons
defaultValue={record.priority}
min={-999}
size="small"
/>
</div>
);
} else {
return (
<InputNumber
style={{ width: 70 }}
name='priority'
keepFocus={true}
onBlur={(e) => {
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 (
<div>
<InputNumber
style={{ width: 70 }}
name='weight'
onBlur={(e) => {
manageChannel(record.id, 'weight', record, e.target.value);
}}
keepFocus={true}
innerButtons
defaultValue={record.weight}
min={0}
size="small"
/>
</div>
);
} else {
return (
<InputNumber
style={{ width: 70 }}
name='weight'
keepFocus={true}
onBlur={(e) => {
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 (
<Space wrap>
<SplitButtonGroup
className="overflow-hidden"
aria-label={t('测试单个渠道操作项目组')}
>
<Button
size="small"
type='tertiary'
onClick={() => testChannel(record, '')}
>
{t('测试')}
</Button>
<Button
size="small"
type='tertiary'
icon={<IconTreeTriangleDown />}
onClick={() => {
setCurrentTestChannel(record);
setShowModelTestModal(true);
}}
/>
</SplitButtonGroup>
{record.channel_info?.is_multi_key ? (
<SplitButtonGroup
aria-label={t('多密钥渠道操作项目组')}
>
{
record.status === 1 ? (
<Button
type='danger'
size="small"
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('禁用')}
</Button>
) : (
<Button
size="small"
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)
}
<Dropdown
trigger='click'
position='bottomRight'
menu={[
{
node: 'item',
name: t('启用全部密钥'),
onClick: () => manageChannel(record.id, 'enable_all', record),
}
]}
>
<Button
type='tertiary'
size="small"
icon={<IconTreeTriangleDown />}
/>
</Dropdown>
</SplitButtonGroup>
) : (
record.status === 1 ? (
<Button
type='danger'
size="small"
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('禁用')}
</Button>
) : (
<Button
size="small"
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)
)}
<Button
type='tertiary'
size="small"
onClick={() => {
setEditingChannel(record);
setShowEdit(true);
}}
>
{t('编辑')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
>
<Button
icon={<IconMore />}
type='tertiary'
size="small"
/>
</Dropdown>
</Space>
);
} else {
// 标签操作按钮
return (
<Space wrap>
<Button
type='tertiary'
size="small"
onClick={() => manageTag(record.key, 'enable')}
>
{t('启用全部')}
</Button>
<Button
type='tertiary'
size="small"
onClick={() => manageTag(record.key, 'disable')}
>
{t('禁用全部')}
</Button>
<Button
type='tertiary'
size="small"
onClick={() => {
setShowEditTag(true);
setEditingTag(record.key);
}}
>
{t('编辑')}
</Button>
</Space>
);
}
},
},
];
};

View File

@@ -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 (
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
size='small'
theme='light'
type='primary'
className="w-full md:w-auto"
onClick={() => {
setEditingChannel({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加渠道')}
</Button>
<Button
size='small'
type='tertiary'
className="w-full md:w-auto"
onClick={refresh}
>
{t('刷新')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
className="w-full md:w-auto"
>
{t('列设置')}
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<Form
initValues={formInitValues}
getFormApi={(api) => 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"
>
<div className="relative w-full md:w-64">
<Form.Input
size='small'
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('渠道ID名称密钥API地址')}
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<Form.Input
size='small'
field="searchModel"
prefix={<IconSearch />}
placeholder={t('模型关键字')}
showClear
pure
/>
</div>
<div className="w-full md:w-32">
<Form.Select
size='small'
field="searchGroup"
placeholder={t('选择分组')}
optionList={[
{ label: t('选择分组'), value: null },
...groupOptions,
]}
className="w-full"
showClear
pure
onChange={() => {
// 延迟执行搜索,让表单值先更新
setTimeout(() => {
searchChannels(enableTagMode);
}, 0);
}}
/>
</div>
<Button
size='small'
type="tertiary"
htmlType="submit"
loading={loading || searching}
className="w-full md:w-auto"
>
{t('查询')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className="w-full md:w-auto"
>
{t('重置')}
</Button>
</Form>
</div>
</div>
);
};
export default ChannelsFilters;

View File

@@ -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 (
<Table
columns={tableColumns}
dataSource={channels}
scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: channelCount,
pageSizeOpts: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
expandAllRows={false}
onRow={handleRow}
rowSelection={
enableBatchDelete
? {
onChange: (selectedRowKeys, selectedRows) => {
setSelectedChannels(selectedRows);
},
}
: null
}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
size="middle"
loading={loading || searching}
/>
);
};
export default ChannelsTable;

View File

@@ -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 (
<Tabs
activeKey={activeTypeKey}
type="card"
collapsible
onChange={handleTabChange}
className="mb-4"
>
<TabPane
itemKey="all"
tab={
<span className="flex items-center gap-2">
{t('全部')}
<Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} shape='circle'>
{channelTypeCounts['all'] || 0}
</Tag>
</span>
}
/>
{CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => {
const key = String(option.value);
const count = channelTypeCounts[option.value] || 0;
return (
<TabPane
key={key}
itemKey={key}
tab={
<span className="flex items-center gap-2">
{getChannelIcon(option.value)}
{option.label}
<Tag color={activeTypeKey === key ? 'red' : 'grey'} shape='circle'>
{count}
</Tag>
</span>
}
/>
);
})}
</Tabs>
);
};
export default ChannelsTabs;

View File

@@ -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 */}
<ColumnSelectorModal {...channelsData} />
<EditTagModal
visible={channelsData.showEditTag}
tag={channelsData.editingTag}
handleClose={() => channelsData.setShowEditTag(false)}
refresh={channelsData.refresh}
/>
<EditChannel
refresh={channelsData.refresh}
visible={channelsData.showEdit}
handleClose={channelsData.closeEdit}
editingChannel={channelsData.editingChannel}
/>
<BatchTagModal {...channelsData} />
<ModelTestModal {...channelsData} />
{/* Main Content */}
<CardPro
type="type3"
tabsArea={<ChannelsTabs {...channelsData} />}
actionsArea={<ChannelsActions {...channelsData} />}
searchArea={<ChannelsFilters {...channelsData} />}
>
<ChannelsTable {...channelsData} />
</CardPro>
</>
);
};
export default ChannelsPage;

View File

@@ -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 (
<Modal
title={t('批量设置标签')}
visible={showBatchSetTag}
onOk={batchSetChannelTag}
onCancel={() => setShowBatchSetTag(false)}
maskClosable={false}
centered={true}
size="small"
className="!rounded-lg"
>
<div className="mb-5">
<Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
</div>
<Input
placeholder={t('请输入标签名称')}
value={batchSetTagValue}
onChange={(v) => setBatchSetTagValue(v)}
/>
<div className="mt-4">
<Typography.Text type='secondary'>
{t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
</Typography.Text>
</div>
</Modal>
);
};
export default BatchTagModal;

View File

@@ -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 (
<Modal
title={t('列设置')}
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={Object.values(visibleColumns).every((v) => v === true)}
indeterminate={
Object.values(visibleColumns).some((v) => v === true) &&
!Object.values(visibleColumns).every((v) => v === true)
}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div
className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
style={{ border: '1px solid var(--semi-color-border)' }}
>
{allColumns.map((column) => {
// Skip columns without title
if (!column.title) {
return null;
}
return (
<div
key={column.key}
className="w-1/2 mb-4 pr-2"
>
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
handleColumnVisibilityChange(column.key, e.target.checked)
}
>
{column.title}
</Checkbox>
</div>
);
})}
</div>
</Modal>
);
};
export default ColumnSelectorModal;

View File

@@ -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) => (
<div className="flex items-center">
<Typography.Text strong>{text}</Typography.Text>
</div>
)
},
{
title: t('状态'),
dataIndex: 'status',
render: (text, record) => {
const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`];
const isTesting = testingModels.has(record.model);
if (isTesting) {
return (
<Tag color='blue' shape='circle'>
{t('测试中')}
</Tag>
);
}
if (!testResult) {
return (
<Tag color='grey' shape='circle'>
{t('未开始')}
</Tag>
);
}
return (
<div className="flex items-center gap-2">
<Tag
color={testResult.success ? 'green' : 'red'}
shape='circle'
>
{testResult.success ? t('成功') : t('失败')}
</Tag>
{testResult.success && (
<Typography.Text type="tertiary">
{t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))}
</Typography.Text>
)}
</div>
);
}
},
{
title: '',
dataIndex: 'operate',
render: (text, record) => {
const isTesting = testingModels.has(record.model);
return (
<Button
type='tertiary'
onClick={() => testChannel(currentTestChannel, record.model)}
loading={isTesting}
size='small'
>
{t('测试')}
</Button>
);
}
}
];
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 (
<Modal
title={
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
{currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type="tertiary" className="!text-xs flex items-center">
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
</Typography.Text>
</div>
</div>
}
visible={showModelTestModal}
onCancel={handleCloseModal}
footer={
<div className="flex justify-end">
{isBatchTesting ? (
<Button
type='danger'
onClick={handleCloseModal}
>
{t('停止测试')}
</Button>
) : (
<Button
type='tertiary'
onClick={handleCloseModal}
>
{t('取消')}
</Button>
)}
<Button
onClick={batchTestModels}
loading={isBatchTesting}
disabled={isBatchTesting}
>
{isBatchTesting ? t('测试中...') : t('批量测试${count}个模型').replace(
'${count}',
filteredModels.length
)}
</Button>
</div>
}
maskClosable={!isBatchTesting}
className="!rounded-lg"
size={isMobile ? 'full-width' : 'large'}
>
<div className="model-test-scroll">
{/* 搜索与操作按钮 */}
<div className="flex items-center justify-end gap-2 w-full mb-2">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => {
setModelSearchKeyword(v);
setModelTablePage(1);
}}
className="!w-full"
prefix={<IconSearch />}
showClear
/>
<Button onClick={handleCopySelected}>
{t('复制已选')}
</Button>
<Button
type='tertiary'
onClick={handleSelectSuccess}
>
{t('选择成功')}
</Button>
</div>
<Table
columns={columns}
dataSource={dataSource}
rowSelection={{
selectedRowKeys: selectedModelKeys,
onChange: (keys) => {
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),
}}
/>
</div>
</Modal>
);
};
export default ModelTestModal;

View File

@@ -1,7 +1,7 @@
import i18next from 'i18next'; import i18next from 'i18next';
import { Modal, Tag, Typography } from '@douyinfe/semi-ui'; import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
import { copy, showSuccess } from './utils'; 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 { visit } from 'unist-util-visit';
import { import {
OpenAI, OpenAI,

View File

@@ -4,7 +4,7 @@ import React from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants'; import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
import { TABLE_COMPACT_MODES_KEY } from '../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 }) => { const HTMLToastContent = ({ htmlContent }) => {
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />; return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;

View File

@@ -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,
};
};

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { fetchTokenKeys, getServerAddress } from '../helpers/token'; import { fetchTokenKeys, getServerAddress } from '../../helpers/token';
import { showError } from '../helpers'; import { showError } from '../../helpers';
export function useTokenKeys(id) { export function useTokenKeys(id) {
const [keys, setKeys] = useState([]); const [keys, setKeys] = useState([]);

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getTableCompactMode, setTableCompactMode } from '../helpers'; import { getTableCompactMode, setTableCompactMode } from '../../helpers';
import { TABLE_COMPACT_MODES_KEY } from '../constants'; import { TABLE_COMPACT_MODES_KEY } from '../../constants';
/** /**
* 自定义 Hook管理表格紧凑/自适应模式 * 自定义 Hook管理表格紧凑/自适应模式

View File

@@ -5,13 +5,13 @@ import {
API_ENDPOINTS, API_ENDPOINTS,
MESSAGE_STATUS, MESSAGE_STATUS,
DEBUG_TABS DEBUG_TABS
} from '../constants/playground.constants'; } from '../../constants/playground.constants';
import { import {
getUserIdFromLocalStorage, getUserIdFromLocalStorage,
handleApiError, handleApiError,
processThinkTags, processThinkTags,
processIncompleteThinkTags processIncompleteThinkTags
} from '../helpers'; } from '../../helpers';
export const useApiRequest = ( export const useApiRequest = (
setMessage, setMessage,

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { API, processModelsData, processGroupsData } from '../helpers'; import { API, processModelsData, processGroupsData } from '../../helpers';
import { API_ENDPOINTS } from '../constants/playground.constants'; import { API_ENDPOINTS } from '../../constants/playground.constants';
export const useDataLoader = ( export const useDataLoader = (
userState, userState,

View File

@@ -1,8 +1,8 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Toast, Modal } from '@douyinfe/semi-ui'; import { Toast, Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getTextContent } from '../helpers'; import { getTextContent } from '../../helpers';
import { ERROR_MESSAGES } from '../constants/playground.constants'; import { ERROR_MESSAGES } from '../../constants/playground.constants';
export const useMessageActions = (message, setMessage, onMessageSend, saveMessages) => { export const useMessageActions = (message, setMessage, onMessageSend, saveMessages) => {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -1,8 +1,8 @@
import { useCallback, useState, useRef } from 'react'; import { useCallback, useState, useRef } from 'react';
import { Toast, Modal } from '@douyinfe/semi-ui'; import { Toast, Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../helpers'; import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../../helpers';
import { MESSAGE_ROLES } from '../constants/playground.constants'; import { MESSAGE_ROLES } from '../../constants/playground.constants';
export const useMessageEdit = ( export const useMessageEdit = (
setMessage, setMessage,

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../constants/playground.constants'; import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../../constants/playground.constants';
import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage'; import { loadConfig, saveConfig, loadMessages, saveMessages } from '../../components/playground/configStorage';
import { processIncompleteThinkTags } from '../helpers'; import { processIncompleteThinkTags } from '../../helpers';
export const usePlaygroundState = () => { export const usePlaygroundState = () => {
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息 // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息

View File

@@ -1,5 +1,5 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { MESSAGE_ROLES } from '../constants/playground.constants'; import { MESSAGE_ROLES } from '../../constants/playground.constants';
export const useSyncMessageAndCustomBody = ( export const useSyncMessageAndCustomBody = (
customRequestMode, customRequestMode,

View File

@@ -8,7 +8,7 @@ import {
showSuccess, showSuccess,
verifyJSON, verifyJSON,
} from '../../helpers'; } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js';
import { CHANNEL_OPTIONS } from '../../constants'; import { CHANNEL_OPTIONS } from '../../constants';
import { import {
SideSheet, SideSheet,

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useTokenKeys } from '../../hooks/useTokenKeys'; import { useTokenKeys } from '../../hooks/chat/useTokenKeys';
import { Spin } from '@douyinfe/semi-ui'; import { Spin } from '@douyinfe/semi-ui';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useTokenKeys } from '../../hooks/useTokenKeys'; import { useTokenKeys } from '../../hooks/chat/useTokenKeys';
const chat2page = () => { const chat2page = () => {
const { keys, chatLink, serverAddress, isLoading } = useTokenKeys(); const { keys, chatLink, serverAddress, isLoading } = useTokenKeys();

View File

@@ -54,7 +54,7 @@ import {
copy, copy,
getRelativeTime getRelativeTime
} from '../../helpers'; } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js';
import { UserContext } from '../../context/User/index.js'; import { UserContext } from '../../context/User/index.js';
import { StatusContext } from '../../context/Status/index.js'; import { StatusContext } from '../../context/Status/index.js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';

View File

@@ -1,7 +1,7 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui'; import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui';
import { API, showError, copy, showSuccess } from '../../helpers'; 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 { API_ENDPOINTS } from '../../constants/common.constant';
import { StatusContext } from '../../context/Status'; import { StatusContext } from '../../context/Status';
import { marked } from 'marked'; import { marked } from 'marked';

View File

@@ -5,15 +5,15 @@ import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
// Context // Context
import { UserContext } from '../../context/User/index.js'; import { UserContext } from '../../context/User/index.js';
import { useIsMobile } from '../../hooks/useIsMobile.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js';
// hooks // hooks
import { usePlaygroundState } from '../../hooks/usePlaygroundState.js'; import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState.js';
import { useMessageActions } from '../../hooks/useMessageActions.js'; import { useMessageActions } from '../../hooks/playground/useMessageActions.js';
import { useApiRequest } from '../../hooks/useApiRequest.js'; import { useApiRequest } from '../../hooks/playground/useApiRequest.js';
import { useSyncMessageAndCustomBody } from '../../hooks/useSyncMessageAndCustomBody.js'; import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody.js';
import { useMessageEdit } from '../../hooks/useMessageEdit.js'; import { useMessageEdit } from '../../hooks/playground/useMessageEdit.js';
import { useDataLoader } from '../../hooks/useDataLoader.js'; import { useDataLoader } from '../../hooks/playground/useDataLoader.js';
// Constants and utils // Constants and utils
import { import {

View File

@@ -8,7 +8,7 @@ import {
renderQuota, renderQuota,
renderQuotaWithPrompt, renderQuotaWithPrompt,
} from '../../helpers'; } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js';
import { import {
Button, Button,
Modal, Modal,

View File

@@ -19,7 +19,7 @@ import {
CheckCircle, CheckCircle,
} from 'lucide-react'; } from 'lucide-react';
import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers'; 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 { DEFAULT_ENDPOINT } from '../../../constants';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {

View File

@@ -8,7 +8,7 @@ import {
renderQuotaWithPrompt, renderQuotaWithPrompt,
getModelCategories, getModelCategories,
} from '../../helpers'; } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js';
import { import {
Button, Button,
SideSheet, SideSheet,

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { API, showError, showSuccess } from '../../helpers'; import { API, showError, showSuccess } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js';
import { import {
Button, Button,
SideSheet, SideSheet,

View File

@@ -7,7 +7,7 @@ import {
renderQuota, renderQuota,
renderQuotaWithPrompt, renderQuotaWithPrompt,
} from '../../helpers'; } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js';
import { import {
Button, Button,
Modal, Modal,