Commit 217c657e by t0ng7u

🚀 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.
parent eac90f67
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';
......
...@@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; ...@@ -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();
......
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;
\ No newline at end of file
...@@ -31,8 +31,8 @@ import { ...@@ -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();
......
...@@ -5,8 +5,8 @@ import App from '../../App.js'; ...@@ -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';
......
...@@ -3,7 +3,7 @@ import { Link, useLocation } from 'react-router-dom'; ...@@ -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,
......
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,
......
...@@ -37,9 +37,7 @@ import { ...@@ -37,9 +37,7 @@ import {
import { import {
Button, Button,
Card,
Checkbox, Checkbox,
Divider,
Empty, Empty,
Form, Form,
ImagePreview, ImagePreview,
...@@ -51,6 +49,7 @@ import { ...@@ -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 { ...@@ -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,42 +797,40 @@ const LogsTable = () => { ...@@ -798,42 +797,40 @@ 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" />
{loading ? ( {loading ? (
<Skeleton.Title <Skeleton.Title
style={{ style={{
width: 300, width: 300,
marginBottom: 0, marginBottom: 0,
marginTop: 0 marginTop: 0
}} }}
/> />
) : ( ) : (
<Text> <Text>
{isAdminUser && showBanner {isAdminUser && showBanner
? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。') ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。')
: t('Midjourney 任务记录')} : t('Midjourney 任务记录')}
</Text> </Text>
)} )}
</div>
<Button
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div> </div>
<Button
<Divider margin="12px" /> type='tertiary'
className="w-full md:w-auto"
{/* 搜索表单区域 */} onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
}
searchArea={
<Form <Form
initValues={formInitValues} initValues={formInitValues}
getFormApi={(api) => setFormApi(api)} getFormApi={(api) => setFormApi(api)}
...@@ -920,10 +917,7 @@ const LogsTable = () => { ...@@ -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()}
...@@ -950,8 +944,8 @@ const LogsTable = () => { ...@@ -950,8 +944,8 @@ const LogsTable = () => {
onPageSizeChange: handlePageSizeChange, onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange, onPageChange: handlePageChange,
}} }}
/> />
</Card> </CardPro>
<Modal <Modal
visible={isModalOpen} visible={isModalOpen}
......
...@@ -26,9 +26,7 @@ import { ...@@ -26,9 +26,7 @@ import {
import { import {
Button, Button,
Card,
Checkbox, Checkbox,
Divider,
Empty, Empty,
Form, Form,
Layout, Layout,
...@@ -38,6 +36,7 @@ import { ...@@ -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 { ...@@ -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,118 +647,113 @@ const LogsTable = () => { ...@@ -648,118 +647,113 @@ 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" />
<Text>{t('任务记录')}</Text> <Text>{t('任务记录')}</Text>
</div>
<Button
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div> </div>
<Button
<Divider margin="12px" /> type='tertiary'
className="w-full md:w-auto"
{/* 搜索表单区域 */} onClick={() => setCompactMode(!compactMode)}
<Form size="small"
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={refresh}
allowEmpty={true}
autoComplete="off"
layout="vertical"
trigger="change"
stopValidateWithError={false}
> >
<div className="flex flex-col gap-4"> {compactMode ? t('自适应列表') : t('紧凑列表')}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> </Button>
{/* 时间选择器 */} </div>
<div className="col-span-1 lg:col-span-2"> }
<Form.DatePicker searchArea={
field='dateRange' <Form
className="w-full" initValues={formInitValues}
type='dateTimeRange' getFormApi={(api) => setFormApi(api)}
placeholder={[t('开始时间'), t('结束时间')]} onSubmit={refresh}
showClear allowEmpty={true}
pure autoComplete="off"
size="small" layout="vertical"
/> trigger="change"
</div> stopValidateWithError={false}
>
{/* 任务 ID */} <div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<Form.DatePicker
field='dateRange'
className="w-full"
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
size="small"
/>
</div>
{/* 任务 ID */}
<Form.Input
field='task_id'
prefix={<IconSearch />}
placeholder={t('任务 ID')}
showClear
pure
size="small"
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Form.Input <Form.Input
field='task_id' field='channel_id'
prefix={<IconSearch />} prefix={<IconSearch />}
placeholder={t('任务 ID')} placeholder={t('渠道 ID')}
showClear showClear
pure pure
size="small" size="small"
/> />
)}
</div>
{/* 渠道 ID - 仅管理员可见 */} {/* 操作按钮区域 */}
{isAdminUser && ( <div className="flex justify-between items-center">
<Form.Input <div></div>
field='channel_id' <div className="flex gap-2">
prefix={<IconSearch />} <Button
placeholder={t('渠道 ID')} type='tertiary'
showClear htmlType='submit'
pure loading={loading}
size="small" size="small"
/> >
)} {t('查询')}
</div> </Button>
<Button
{/* 操作按钮区域 */} type='tertiary'
<div className="flex justify-between items-center"> onClick={() => {
<div></div> if (formApi) {
<div className="flex gap-2"> formApi.reset();
<Button // 重置后立即查询,使用setTimeout确保表单重置完成
type='tertiary' setTimeout(() => {
htmlType='submit' refresh();
loading={loading} }, 100);
size="small" }
> }}
{t('查询')} size="small"
</Button> >
<Button {t('重置')}
type='tertiary' </Button>
onClick={() => { <Button
if (formApi) { type='tertiary'
formApi.reset(); onClick={() => setShowColumnSelector(true)}
// 重置后立即查询,使用setTimeout确保表单重置完成 size="small"
setTimeout(() => { >
refresh(); {t('列设置')}
}, 100); </Button>
}
}}
size="small"
>
{t('重置')}
</Button>
<Button
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size="small"
>
{t('列设置')}
</Button>
</div>
</div> </div>
</div> </div>
</Form> </div>
</div> </Form>
} }
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 = () => { ...@@ -787,7 +781,7 @@ const LogsTable = () => {
onPageChange: handlePageChange, onPageChange: handlePageChange,
}} }}
/> />
</Card> </CardPro>
<Modal <Modal
visible={isModalOpen} visible={isModalOpen}
......
...@@ -17,8 +17,6 @@ import { ...@@ -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 { ...@@ -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'; ...@@ -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,115 +513,7 @@ const UsersTable = () => { ...@@ -514,115 +513,7 @@ const UsersTable = () => {
} }
}; };
const renderHeader = () => (
<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 items-center text-blue-500">
<IconUserAdd className="mr-2" />
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
</div>
<Button
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
</div>
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
className="w-full md:w-auto"
onClick={() => {
setShowAddUser(true);
}}
size="small"
>
{t('添加用户')}
</Button>
</div>
<Form
initValues={formInitValues}
getFormApi={(api) => 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"
>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-64">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
showClear
pure
size="small"
/>
</div>
<div className="w-full md:w-48">
<Form.Select
field="searchGroup"
placeholder={t('选择分组')}
optionList={groupOptions}
onChange={(value) => {
// 分组变化时自动搜索
setTimeout(() => {
setActivePage(1);
searchUsers(1, pageSize);
}, 100);
}}
className="w-full"
showClear
pure
size="small"
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="tertiary"
htmlType="submit"
loading={loading || searching}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('查询')}
</Button>
<Button
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
setTimeout(() => {
setActivePage(1);
loadUsers(1, pageSize);
}, 100);
}
}}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('重置')}
</Button>
</div>
</div>
</Form>
</div>
</div>
);
return ( return (
<> <>
...@@ -638,11 +529,112 @@ const UsersTable = () => { ...@@ -638,11 +529,112 @@ const UsersTable = () => {
editingUser={editingUser} editingUser={editingUser}
></EditUser> ></EditUser>
<Card <CardPro
className="table-scroll-card !rounded-2xl" type="type1"
title={renderHeader()} descriptionArea={
shadows='always' <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
bordered={false} <div className="flex items-center text-blue-500">
<IconUserAdd className="mr-2" />
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
</div>
<Button
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
}
actionsArea={
<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
className="w-full md:w-auto"
onClick={() => {
setShowAddUser(true);
}}
size="small"
>
{t('添加用户')}
</Button>
</div>
<Form
initValues={formInitValues}
getFormApi={(api) => 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"
>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-64">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
showClear
pure
size="small"
/>
</div>
<div className="w-full md:w-48">
<Form.Select
field="searchGroup"
placeholder={t('选择分组')}
optionList={groupOptions}
onChange={(value) => {
// 分组变化时自动搜索
setTimeout(() => {
setActivePage(1);
searchUsers(1, pageSize);
}, 100);
}}
className="w-full"
showClear
pure
size="small"
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="tertiary"
htmlType="submit"
loading={loading || searching}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('查询')}
</Button>
<Button
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
setTimeout(() => {
setActivePage(1);
loadUsers(1, pageSize);
}, 100);
}
}}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('重置')}
</Button>
</div>
</div>
</Form>
</div>
}
> >
<Table <Table
columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns} columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
...@@ -672,7 +664,7 @@ const UsersTable = () => { ...@@ -672,7 +664,7 @@ const UsersTable = () => {
className="overflow-hidden" className="overflow-hidden"
size="middle" size="middle"
/> />
</Card> </CardPro>
</> </>
); );
}; };
......
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;
\ No newline at end of file
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;
\ No newline at end of file
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;
\ No newline at end of file
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;
\ No newline at end of file
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;
\ No newline at end of file
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;
\ No newline at end of file
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;
\ No newline at end of file
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;
\ No newline at end of file
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,
......
...@@ -4,7 +4,7 @@ import React from 'react'; ...@@ -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 }} />;
......
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([]);
......
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:管理表格紧凑/自适应模式
......
...@@ -5,13 +5,13 @@ import { ...@@ -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,
......
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,
......
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();
......
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,
......
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 = () => {
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息 // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
......
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,
......
...@@ -8,7 +8,7 @@ import { ...@@ -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,
......
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';
......
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();
......
...@@ -54,7 +54,7 @@ import { ...@@ -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';
......
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';
......
...@@ -5,15 +5,15 @@ import { Layout, Toast, Modal } from '@douyinfe/semi-ui'; ...@@ -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 {
......
...@@ -8,7 +8,7 @@ import { ...@@ -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,
......
...@@ -19,7 +19,7 @@ import { ...@@ -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 {
......
...@@ -8,7 +8,7 @@ import { ...@@ -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,
......
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,
......
...@@ -7,7 +7,7 @@ import { ...@@ -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,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment