🚀 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:
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
127
web/src/components/common/ui/CardPro.js
Normal file
127
web/src/components/common/ui/CardPro.js
Normal 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;
|
||||||
@@ -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';
|
|||||||
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';
|
|||||||
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,
|
||||||
|
|||||||
@@ -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
@@ -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,216 +1200,211 @@ 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>
|
||||||
<Tag
|
<Tag
|
||||||
color='blue'
|
color='blue'
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
padding: 13,
|
padding: 13,
|
||||||
}}
|
}}
|
||||||
className='!rounded-lg'
|
className='!rounded-lg'
|
||||||
>
|
|
||||||
{t('消耗额度')}: {renderQuota(stat.quota)}
|
|
||||||
</Tag>
|
|
||||||
<Tag
|
|
||||||
color='pink'
|
|
||||||
style={{
|
|
||||||
fontWeight: 500,
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
|
||||||
padding: 13,
|
|
||||||
}}
|
|
||||||
className='!rounded-lg'
|
|
||||||
>
|
|
||||||
RPM: {stat.rpm}
|
|
||||||
</Tag>
|
|
||||||
<Tag
|
|
||||||
color='white'
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
|
||||||
fontWeight: 500,
|
|
||||||
padding: 13,
|
|
||||||
}}
|
|
||||||
className='!rounded-lg'
|
|
||||||
>
|
|
||||||
TPM: {stat.tpm}
|
|
||||||
</Tag>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type='tertiary'
|
|
||||||
className="w-full md:w-auto"
|
|
||||||
onClick={() => setCompactMode(!compactMode)}
|
|
||||||
size="small"
|
|
||||||
>
|
>
|
||||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
{t('消耗额度')}: {renderQuota(stat.quota)}
|
||||||
</Button>
|
</Tag>
|
||||||
</div>
|
<Tag
|
||||||
</Spin>
|
color='pink'
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: 13,
|
||||||
|
}}
|
||||||
|
className='!rounded-lg'
|
||||||
|
>
|
||||||
|
RPM: {stat.rpm}
|
||||||
|
</Tag>
|
||||||
|
<Tag
|
||||||
|
color='white'
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: 13,
|
||||||
|
}}
|
||||||
|
className='!rounded-lg'
|
||||||
|
>
|
||||||
|
TPM: {stat.tpm}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
|
||||||
<Divider margin='12px' />
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
onClick={() => setCompactMode(!compactMode)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
}
|
||||||
|
searchArea={
|
||||||
|
<Form
|
||||||
|
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'>
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* 搜索表单区域 */}
|
{/* 其他搜索字段 */}
|
||||||
<Form
|
<Form.Input
|
||||||
initValues={formInitValues}
|
field='token_name'
|
||||||
getFormApi={(api) => setFormApi(api)}
|
prefix={<IconSearch />}
|
||||||
onSubmit={refresh}
|
placeholder={t('令牌名称')}
|
||||||
allowEmpty={true}
|
showClear
|
||||||
autoComplete='off'
|
pure
|
||||||
layout='vertical'
|
size="small"
|
||||||
trigger='change'
|
/>
|
||||||
stopValidateWithError={false}
|
|
||||||
>
|
<Form.Input
|
||||||
<div className='flex flex-col gap-4'>
|
field='model_name'
|
||||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
|
prefix={<IconSearch />}
|
||||||
{/* 时间选择器 */}
|
placeholder={t('模型名称')}
|
||||||
<div className='col-span-1 lg:col-span-2'>
|
showClear
|
||||||
<Form.DatePicker
|
pure
|
||||||
field='dateRange'
|
size="small"
|
||||||
className='w-full'
|
/>
|
||||||
type='dateTimeRange'
|
|
||||||
placeholder={[t('开始时间'), t('结束时间')]}
|
<Form.Input
|
||||||
|
field='group'
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
placeholder={t('分组')}
|
||||||
|
showClear
|
||||||
|
pure
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isAdminUser && (
|
||||||
|
<>
|
||||||
|
<Form.Input
|
||||||
|
field='channel'
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
placeholder={t('渠道 ID')}
|
||||||
showClear
|
showClear
|
||||||
pure
|
pure
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Form.Input
|
||||||
|
field='username'
|
||||||
{/* 其他搜索字段 */}
|
prefix={<IconSearch />}
|
||||||
<Form.Input
|
placeholder={t('用户名称')}
|
||||||
field='token_name'
|
|
||||||
prefix={<IconSearch />}
|
|
||||||
placeholder={t('令牌名称')}
|
|
||||||
showClear
|
|
||||||
pure
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form.Input
|
|
||||||
field='model_name'
|
|
||||||
prefix={<IconSearch />}
|
|
||||||
placeholder={t('模型名称')}
|
|
||||||
showClear
|
|
||||||
pure
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form.Input
|
|
||||||
field='group'
|
|
||||||
prefix={<IconSearch />}
|
|
||||||
placeholder={t('分组')}
|
|
||||||
showClear
|
|
||||||
pure
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isAdminUser && (
|
|
||||||
<>
|
|
||||||
<Form.Input
|
|
||||||
field='channel'
|
|
||||||
prefix={<IconSearch />}
|
|
||||||
placeholder={t('渠道 ID')}
|
|
||||||
showClear
|
|
||||||
pure
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<Form.Input
|
|
||||||
field='username'
|
|
||||||
prefix={<IconSearch />}
|
|
||||||
placeholder={t('用户名称')}
|
|
||||||
showClear
|
|
||||||
pure
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮区域 */}
|
|
||||||
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3'>
|
|
||||||
{/* 日志类型选择器 */}
|
|
||||||
<div className='w-full sm:w-auto'>
|
|
||||||
<Form.Select
|
|
||||||
field='logType'
|
|
||||||
placeholder={t('日志类型')}
|
|
||||||
className='w-full sm:w-auto min-w-[120px]'
|
|
||||||
showClear
|
showClear
|
||||||
pure
|
pure
|
||||||
onChange={() => {
|
size="small"
|
||||||
// 延迟执行搜索,让表单值先更新
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮区域 */}
|
||||||
|
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3'>
|
||||||
|
{/* 日志类型选择器 */}
|
||||||
|
<div className='w-full sm:w-auto'>
|
||||||
|
<Form.Select
|
||||||
|
field='logType'
|
||||||
|
placeholder={t('日志类型')}
|
||||||
|
className='w-full sm:w-auto min-w-[120px]'
|
||||||
|
showClear
|
||||||
|
pure
|
||||||
|
onChange={() => {
|
||||||
|
// 延迟执行搜索,让表单值先更新
|
||||||
|
setTimeout(() => {
|
||||||
|
refresh();
|
||||||
|
}, 0);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Form.Select.Option value='0'>
|
||||||
|
{t('全部')}
|
||||||
|
</Form.Select.Option>
|
||||||
|
<Form.Select.Option value='1'>
|
||||||
|
{t('充值')}
|
||||||
|
</Form.Select.Option>
|
||||||
|
<Form.Select.Option value='2'>
|
||||||
|
{t('消费')}
|
||||||
|
</Form.Select.Option>
|
||||||
|
<Form.Select.Option value='3'>
|
||||||
|
{t('管理')}
|
||||||
|
</Form.Select.Option>
|
||||||
|
<Form.Select.Option value='4'>
|
||||||
|
{t('系统')}
|
||||||
|
</Form.Select.Option>
|
||||||
|
<Form.Select.Option value='5'>
|
||||||
|
{t('错误')}
|
||||||
|
</Form.Select.Option>
|
||||||
|
</Form.Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-2 w-full sm:w-auto justify-end'>
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
htmlType='submit'
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('查询')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
onClick={() => {
|
||||||
|
if (formApi) {
|
||||||
|
formApi.reset();
|
||||||
|
setLogType(0);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, 0);
|
}, 100);
|
||||||
}}
|
}
|
||||||
size="small"
|
}}
|
||||||
>
|
size="small"
|
||||||
<Form.Select.Option value='0'>
|
>
|
||||||
{t('全部')}
|
{t('重置')}
|
||||||
</Form.Select.Option>
|
</Button>
|
||||||
<Form.Select.Option value='1'>
|
<Button
|
||||||
{t('充值')}
|
type='tertiary'
|
||||||
</Form.Select.Option>
|
onClick={() => setShowColumnSelector(true)}
|
||||||
<Form.Select.Option value='2'>
|
size="small"
|
||||||
{t('消费')}
|
>
|
||||||
</Form.Select.Option>
|
{t('列设置')}
|
||||||
<Form.Select.Option value='3'>
|
</Button>
|
||||||
{t('管理')}
|
|
||||||
</Form.Select.Option>
|
|
||||||
<Form.Select.Option value='4'>
|
|
||||||
{t('系统')}
|
|
||||||
</Form.Select.Option>
|
|
||||||
<Form.Select.Option value='5'>
|
|
||||||
{t('错误')}
|
|
||||||
</Form.Select.Option>
|
|
||||||
</Form.Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex gap-2 w-full sm:w-auto justify-end'>
|
|
||||||
<Button
|
|
||||||
type='tertiary'
|
|
||||||
htmlType='submit'
|
|
||||||
loading={loading}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{t('查询')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type='tertiary'
|
|
||||||
onClick={() => {
|
|
||||||
if (formApi) {
|
|
||||||
formApi.reset();
|
|
||||||
setLogType(0);
|
|
||||||
setTimeout(() => {
|
|
||||||
refresh();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
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()}
|
||||||
@@ -1450,7 +1444,7 @@ const LogsTable = () => {
|
|||||||
onPageChange: handlePageChange,
|
onPageChange: handlePageChange,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</CardPro>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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 = () => {
|
|||||||
</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 = () => {
|
|||||||
onPageSizeChange: handlePageSizeChange,
|
onPageSizeChange: handlePageSizeChange,
|
||||||
onPageChange: handlePageChange,
|
onPageChange: handlePageChange,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</CardPro>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
visible={isModalOpen}
|
visible={isModalOpen}
|
||||||
|
|||||||
@@ -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,148 +421,6 @@ const RedemptionsTable = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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-orange-500">
|
|
||||||
<Ticket size={16} 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 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">
|
|
||||||
<Button
|
|
||||||
type='primary'
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingRedemption({
|
|
||||||
id: undefined,
|
|
||||||
});
|
|
||||||
setShowEdit(true);
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{t('添加兑换码')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type='tertiary'
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
onClick={async () => {
|
|
||||||
if (selectedKeys.length === 0) {
|
|
||||||
showError(t('请至少选择一个兑换码!'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let keys = '';
|
|
||||||
for (let i = 0; i < selectedKeys.length; i++) {
|
|
||||||
keys +=
|
|
||||||
selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
|
|
||||||
}
|
|
||||||
await copyText(keys);
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{t('复制所选兑换码到剪贴板')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type='danger'
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
onClick={() => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: t('确定清除所有失效兑换码?'),
|
|
||||||
content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'),
|
|
||||||
onOk: async () => {
|
|
||||||
setLoading(true);
|
|
||||||
const res = await API.delete('/api/redemption/invalid');
|
|
||||||
const { success, message, data } = res.data;
|
|
||||||
if (success) {
|
|
||||||
showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data }));
|
|
||||||
await refresh();
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{t('清除失效兑换码')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
initValues={formInitValues}
|
|
||||||
getFormApi={(api) => setFormApi(api)}
|
|
||||||
onSubmit={() => {
|
|
||||||
setActivePage(1);
|
|
||||||
searchRedemptions(null, 1, pageSize);
|
|
||||||
}}
|
|
||||||
allowEmpty={true}
|
|
||||||
autoComplete="off"
|
|
||||||
layout="horizontal"
|
|
||||||
trigger="change"
|
|
||||||
stopValidateWithError={false}
|
|
||||||
className="w-full md:w-auto order-1 md:order-2"
|
|
||||||
>
|
|
||||||
<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="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);
|
|
||||||
loadRedemptions(1, pageSize);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex-1 md:flex-initial md:w-auto"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{t('重置')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EditRedemption
|
<EditRedemption
|
||||||
@@ -573,11 +430,144 @@ const RedemptionsTable = () => {
|
|||||||
handleClose={closeEdit}
|
handleClose={closeEdit}
|
||||||
></EditRedemption>
|
></EditRedemption>
|
||||||
|
|
||||||
<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-orange-500">
|
||||||
|
<Ticket size={16} 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 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">
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingRedemption({
|
||||||
|
id: undefined,
|
||||||
|
});
|
||||||
|
setShowEdit(true);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('添加兑换码')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={async () => {
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
showError(t('请至少选择一个兑换码!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let keys = '';
|
||||||
|
for (let i = 0; i < selectedKeys.length; i++) {
|
||||||
|
keys +=
|
||||||
|
selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
|
||||||
|
}
|
||||||
|
await copyText(keys);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('复制所选兑换码到剪贴板')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('确定清除所有失效兑换码?'),
|
||||||
|
content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'),
|
||||||
|
onOk: async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await API.delete('/api/redemption/invalid');
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data }));
|
||||||
|
await refresh();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('清除失效兑换码')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
initValues={formInitValues}
|
||||||
|
getFormApi={(api) => setFormApi(api)}
|
||||||
|
onSubmit={() => {
|
||||||
|
setActivePage(1);
|
||||||
|
searchRedemptions(null, 1, pageSize);
|
||||||
|
}}
|
||||||
|
allowEmpty={true}
|
||||||
|
autoComplete="off"
|
||||||
|
layout="horizontal"
|
||||||
|
trigger="change"
|
||||||
|
stopValidateWithError={false}
|
||||||
|
className="w-full md:w-auto order-1 md:order-2"
|
||||||
|
>
|
||||||
|
<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="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);
|
||||||
|
loadRedemptions(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}
|
||||||
@@ -615,7 +605,7 @@ const RedemptionsTable = () => {
|
|||||||
className="rounded-xl overflow-hidden"
|
className="rounded-xl overflow-hidden"
|
||||||
size="middle"
|
size="middle"
|
||||||
></Table>
|
></Table>
|
||||||
</Card>
|
</CardPro>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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">
|
||||||
<Form.Input
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
field='task_id'
|
{/* 时间选择器 */}
|
||||||
prefix={<IconSearch />}
|
<div className="col-span-1 lg:col-span-2">
|
||||||
placeholder={t('任务 ID')}
|
<Form.DatePicker
|
||||||
|
field='dateRange'
|
||||||
|
className="w-full"
|
||||||
|
type='dateTimeRange'
|
||||||
|
placeholder={[t('开始时间'), t('结束时间')]}
|
||||||
showClear
|
showClear
|
||||||
pure
|
pure
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 渠道 ID - 仅管理员可见 */}
|
|
||||||
{isAdminUser && (
|
|
||||||
<Form.Input
|
|
||||||
field='channel_id'
|
|
||||||
prefix={<IconSearch />}
|
|
||||||
placeholder={t('渠道 ID')}
|
|
||||||
showClear
|
|
||||||
pure
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮区域 */}
|
{/* 任务 ID */}
|
||||||
<div className="flex justify-between items-center">
|
<Form.Input
|
||||||
<div></div>
|
field='task_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('查询')}
|
{/* 渠道 ID - 仅管理员可见 */}
|
||||||
</Button>
|
{isAdminUser && (
|
||||||
<Button
|
<Form.Input
|
||||||
type='tertiary'
|
field='channel_id'
|
||||||
onClick={() => {
|
prefix={<IconSearch />}
|
||||||
if (formApi) {
|
placeholder={t('渠道 ID')}
|
||||||
formApi.reset();
|
showClear
|
||||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
pure
|
||||||
setTimeout(() => {
|
size="small"
|
||||||
refresh();
|
/>
|
||||||
}, 100);
|
)}
|
||||||
}
|
</div>
|
||||||
}}
|
|
||||||
size="small"
|
{/* 操作按钮区域 */}
|
||||||
>
|
<div className="flex justify-between items-center">
|
||||||
{t('重置')}
|
<div></div>
|
||||||
</Button>
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type='tertiary'
|
type='tertiary'
|
||||||
onClick={() => setShowColumnSelector(true)}
|
htmlType='submit'
|
||||||
size="small"
|
loading={loading}
|
||||||
>
|
size="small"
|
||||||
{t('列设置')}
|
>
|
||||||
</Button>
|
{t('查询')}
|
||||||
</div>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
onClick={() => {
|
||||||
|
if (formApi) {
|
||||||
|
formApi.reset();
|
||||||
|
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||||
|
setTimeout(() => {
|
||||||
|
refresh();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('重置')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
onClick={() => setShowColumnSelector(true)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('列设置')}
|
||||||
|
</Button>
|
||||||
</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 = () => {
|
|||||||
onPageChange: handlePageChange,
|
onPageChange: handlePageChange,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</CardPro>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
visible={isModalOpen}
|
visible={isModalOpen}
|
||||||
|
|||||||
@@ -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,177 +688,173 @@ const TokensTable = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderHeader = () => (
|
const renderDescriptionArea = () => (
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||||
<div className="mb-2">
|
<div className="flex items-center text-blue-500">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
<Key size={16} className="mr-2" />
|
||||||
<div className="flex items-center text-blue-500">
|
<Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
|
||||||
<Key size={16} className="mr-2" />
|
</div>
|
||||||
<Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
|
<Button
|
||||||
</div>
|
type="tertiary"
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
onClick={() => setCompactMode(!compactMode)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderActionsArea = () => (
|
||||||
|
<div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
className="flex-1 md:flex-initial"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingToken({
|
||||||
|
id: undefined,
|
||||||
|
});
|
||||||
|
setShowEdit(true);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('添加令牌')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
className="flex-1 md:flex-initial"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
showError(t('请至少选择一个令牌!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Modal.info({
|
||||||
|
title: t('复制令牌'),
|
||||||
|
icon: null,
|
||||||
|
content: t('请选择你的复制方式'),
|
||||||
|
footer: (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
onClick={async () => {
|
||||||
|
let content = '';
|
||||||
|
for (let i = 0; i < selectedKeys.length; i++) {
|
||||||
|
content +=
|
||||||
|
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||||
|
}
|
||||||
|
await copyText(content);
|
||||||
|
Modal.destroyAll();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('名称+密钥')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
let content = '';
|
||||||
|
for (let i = 0; i < selectedKeys.length; i++) {
|
||||||
|
content += 'sk-' + selectedKeys[i].key + '\n';
|
||||||
|
}
|
||||||
|
await copyText(content);
|
||||||
|
Modal.destroyAll();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('仅密钥')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('复制所选令牌')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
showError(t('请至少选择一个令牌!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('批量删除令牌'),
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
{t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onOk: () => batchDeleteTokens(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('删除所选令牌')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSearchArea = () => (
|
||||||
|
<Form
|
||||||
|
initValues={formInitValues}
|
||||||
|
getFormApi={(api) => setFormApi(api)}
|
||||||
|
onSubmit={searchTokens}
|
||||||
|
allowEmpty={true}
|
||||||
|
autoComplete="off"
|
||||||
|
layout="horizontal"
|
||||||
|
trigger="change"
|
||||||
|
stopValidateWithError={false}
|
||||||
|
className="w-full md:w-auto order-1 md:order-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
||||||
|
<div className="relative w-full md:w-56">
|
||||||
|
<Form.Input
|
||||||
|
field="searchKeyword"
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
placeholder={t('搜索关键字')}
|
||||||
|
showClear
|
||||||
|
pure
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full md:w-56">
|
||||||
|
<Form.Input
|
||||||
|
field="searchToken"
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
placeholder={t('密钥')}
|
||||||
|
showClear
|
||||||
|
pure
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 w-full md:w-auto">
|
||||||
<Button
|
<Button
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
className="w-full md:w-auto"
|
htmlType="submit"
|
||||||
onClick={() => setCompactMode(!compactMode)}
|
loading={loading || searching}
|
||||||
|
className="flex-1 md:flex-initial md:w-auto"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{compactMode ? t('自适应列表') : 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 flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
className="flex-1 md:flex-initial"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingToken({
|
|
||||||
id: undefined,
|
|
||||||
});
|
|
||||||
setShowEdit(true);
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{t('添加令牌')}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type='tertiary'
|
type='tertiary'
|
||||||
className="flex-1 md:flex-initial"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectedKeys.length === 0) {
|
if (formApi) {
|
||||||
showError(t('请至少选择一个令牌!'));
|
formApi.reset();
|
||||||
return;
|
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||||
|
setTimeout(() => {
|
||||||
|
searchTokens();
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
Modal.info({
|
|
||||||
title: t('复制令牌'),
|
|
||||||
icon: null,
|
|
||||||
content: t('请选择你的复制方式'),
|
|
||||||
footer: (
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
type='tertiary'
|
|
||||||
onClick={async () => {
|
|
||||||
let content = '';
|
|
||||||
for (let i = 0; i < selectedKeys.length; i++) {
|
|
||||||
content +=
|
|
||||||
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
|
||||||
}
|
|
||||||
await copyText(content);
|
|
||||||
Modal.destroyAll();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('名称+密钥')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
let content = '';
|
|
||||||
for (let i = 0; i < selectedKeys.length; i++) {
|
|
||||||
content += 'sk-' + selectedKeys[i].key + '\n';
|
|
||||||
}
|
|
||||||
await copyText(content);
|
|
||||||
Modal.destroyAll();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('仅密钥')}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
|
className="flex-1 md:flex-initial md:w-auto"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{t('复制所选令牌')}
|
{t('重置')}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type='danger'
|
|
||||||
className="w-full md:w-auto"
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedKeys.length === 0) {
|
|
||||||
showError(t('请至少选择一个令牌!'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Modal.confirm({
|
|
||||||
title: t('批量删除令牌'),
|
|
||||||
content: (
|
|
||||||
<div>
|
|
||||||
{t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
onOk: () => batchDeleteTokens(),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{t('删除所选令牌')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form
|
|
||||||
initValues={formInitValues}
|
|
||||||
getFormApi={(api) => setFormApi(api)}
|
|
||||||
onSubmit={searchTokens}
|
|
||||||
allowEmpty={true}
|
|
||||||
autoComplete="off"
|
|
||||||
layout="horizontal"
|
|
||||||
trigger="change"
|
|
||||||
stopValidateWithError={false}
|
|
||||||
className="w-full md:w-auto order-1 md:order-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
|
||||||
<div className="relative w-full md:w-56">
|
|
||||||
<Form.Input
|
|
||||||
field="searchKeyword"
|
|
||||||
prefix={<IconSearch />}
|
|
||||||
placeholder={t('搜索关键字')}
|
|
||||||
showClear
|
|
||||||
pure
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="relative w-full md:w-56">
|
|
||||||
<Form.Input
|
|
||||||
field="searchToken"
|
|
||||||
prefix={<IconSearch />}
|
|
||||||
placeholder={t('密钥')}
|
|
||||||
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确保表单重置完成
|
|
||||||
setTimeout(() => {
|
|
||||||
searchTokens();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex-1 md:flex-initial md:w-auto"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{t('重置')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|
||||||
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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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 = () => {
|
|||||||
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 = () => {
|
|||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
size="middle"
|
size="middle"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</CardPro>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
240
web/src/components/table/channels/ChannelsActions.jsx
Normal file
240
web/src/components/table/channels/ChannelsActions.jsx
Normal 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;
|
||||||
604
web/src/components/table/channels/ChannelsColumnDefs.js
Normal file
604
web/src/components/table/channels/ChannelsColumnDefs.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
140
web/src/components/table/channels/ChannelsFilters.jsx
Normal file
140
web/src/components/table/channels/ChannelsFilters.jsx
Normal 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;
|
||||||
138
web/src/components/table/channels/ChannelsTable.jsx
Normal file
138
web/src/components/table/channels/ChannelsTable.jsx
Normal 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;
|
||||||
70
web/src/components/table/channels/ChannelsTabs.jsx
Normal file
70
web/src/components/table/channels/ChannelsTabs.jsx
Normal 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;
|
||||||
49
web/src/components/table/channels/index.jsx
Normal file
49
web/src/components/table/channels/index.jsx
Normal 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;
|
||||||
41
web/src/components/table/channels/modals/BatchTagModal.jsx
Normal file
41
web/src/components/table/channels/modals/BatchTagModal.jsx
Normal 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;
|
||||||
114
web/src/components/table/channels/modals/ColumnSelectorModal.jsx
Normal file
114
web/src/components/table/channels/modals/ColumnSelectorModal.jsx
Normal 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;
|
||||||
256
web/src/components/table/channels/modals/ModelTestModal.jsx
Normal file
256
web/src/components/table/channels/modals/ModelTestModal.jsx
Normal 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;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }} />;
|
||||||
|
|||||||
917
web/src/hooks/channels/useChannelsData.js
Normal file
917
web/src/hooks/channels/useChannelsData.js
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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([]);
|
||||||
@@ -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:管理表格紧凑/自适应模式
|
||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
@@ -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();
|
||||||
@@ -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,
|
||||||
@@ -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 = () => {
|
||||||
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
|
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
|
||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user