♻️ refactor(components): refactor the components folder structure and related imports

This commit is contained in:
Apple\Apple
2025-06-04 00:42:06 +08:00
parent d27981bd34
commit 3f45153e75
38 changed files with 113 additions and 121 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,916 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
} from '../../helpers';
import {
Button,
Card,
Checkbox,
DatePicker,
Divider,
ImagePreview,
Input,
Layout,
Modal,
Progress,
Skeleton,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
IconSetting,
} from '@douyinfe/semi-icons';
const { Text } = Typography;
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// 定义列键值常量
const COLUMN_KEYS = {
SUBMIT_TIME: 'submit_time',
DURATION: 'duration',
CHANNEL: 'channel',
TYPE: 'type',
TASK_ID: 'task_id',
SUBMIT_RESULT: 'submit_result',
TASK_STATUS: 'task_status',
PROGRESS: 'progress',
IMAGE: 'image',
PROMPT: 'prompt',
PROMPT_EN: 'prompt_en',
FAIL_REASON: 'fail_reason',
};
const LogsTable = () => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContent, setModalContent] = useState('');
// 列可见性状态
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
const isAdminUser = isAdmin();
// 加载保存的列偏好设置
useEffect(() => {
const savedColumns = localStorage.getItem('mj-logs-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();
}
}, []);
// 获取默认列可见性
const getDefaultColumnVisibility = () => {
return {
[COLUMN_KEYS.SUBMIT_TIME]: true,
[COLUMN_KEYS.DURATION]: true,
[COLUMN_KEYS.CHANNEL]: isAdminUser,
[COLUMN_KEYS.TYPE]: true,
[COLUMN_KEYS.TASK_ID]: true,
[COLUMN_KEYS.SUBMIT_RESULT]: isAdminUser,
[COLUMN_KEYS.TASK_STATUS]: true,
[COLUMN_KEYS.PROGRESS]: true,
[COLUMN_KEYS.IMAGE]: true,
[COLUMN_KEYS.PROMPT]: true,
[COLUMN_KEYS.PROMPT_EN]: true,
[COLUMN_KEYS.FAIL_REASON]: true,
};
};
// 初始化默认列可见性
const initDefaultColumns = () => {
const defaults = getDefaultColumnVisibility();
setVisibleColumns(defaults);
localStorage.setItem('mj-logs-table-columns', JSON.stringify(defaults));
};
// 处理列可见性变化
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) => {
if ((key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.SUBMIT_RESULT) && !isAdminUser) {
updatedColumns[key] = false;
} else {
updatedColumns[key] = checked;
}
});
setVisibleColumns(updatedColumns);
};
// 更新表格时保存列可见性
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
localStorage.setItem('mj-logs-table-columns', JSON.stringify(visibleColumns));
}
}, [visibleColumns]);
function renderType(type) {
switch (type) {
case 'IMAGINE':
return (
<Tag color='blue' size='large' shape='circle'>
{t('绘图')}
</Tag>
);
case 'UPSCALE':
return (
<Tag color='orange' size='large' shape='circle'>
{t('放大')}
</Tag>
);
case 'VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
{t('变换')}
</Tag>
);
case 'HIGH_VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
{t('强变换')}
</Tag>
);
case 'LOW_VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
{t('弱变换')}
</Tag>
);
case 'PAN':
return (
<Tag color='cyan' size='large' shape='circle'>
{t('平移')}
</Tag>
);
case 'DESCRIBE':
return (
<Tag color='yellow' size='large' shape='circle'>
{t('图生文')}
</Tag>
);
case 'BLEND':
return (
<Tag color='lime' size='large' shape='circle'>
{t('图混合')}
</Tag>
);
case 'UPLOAD':
return (
<Tag color='blue' size='large' shape='circle'>
上传文件
</Tag>
);
case 'SHORTEN':
return (
<Tag color='pink' size='large' shape='circle'>
{t('缩词')}
</Tag>
);
case 'REROLL':
return (
<Tag color='indigo' size='large' shape='circle'>
{t('重绘')}
</Tag>
);
case 'INPAINT':
return (
<Tag color='violet' size='large' shape='circle'>
{t('局部重绘-提交')}
</Tag>
);
case 'ZOOM':
return (
<Tag color='teal' size='large' shape='circle'>
{t('变焦')}
</Tag>
);
case 'CUSTOM_ZOOM':
return (
<Tag color='teal' size='large' shape='circle'>
{t('自定义变焦-提交')}
</Tag>
);
case 'MODAL':
return (
<Tag color='green' size='large' shape='circle'>
{t('窗口处理')}
</Tag>
);
case 'SWAP_FACE':
return (
<Tag color='light-green' size='large' shape='circle'>
{t('换脸')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
}
function renderCode(code) {
switch (code) {
case 1:
return (
<Tag color='green' size='large' shape='circle'>
{t('已提交')}
</Tag>
);
case 21:
return (
<Tag color='lime' size='large' shape='circle'>
{t('等待中')}
</Tag>
);
case 22:
return (
<Tag color='orange' size='large' shape='circle'>
{t('重复提交')}
</Tag>
);
case 0:
return (
<Tag color='yellow' size='large' shape='circle'>
{t('未提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
}
function renderStatus(type) {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle'>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle'>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle'>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle'>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle'>
{t('失败')}
</Tag>
);
case 'MODAL':
return (
<Tag color='yellow' size='large' shape='circle'>
{t('窗口等待')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
}
const renderTimestamp = (timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
const year = date.getFullYear(); // 获取年份
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份从0开始需要+1并保证两位数
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
};
// 修改renderDuration函数以包含颜色逻辑
function renderDuration(submit_time, finishTime) {
if (!submit_time || !finishTime) return 'N/A';
const start = new Date(submit_time);
const finish = new Date(finishTime);
const durationMs = finish - start;
const durationSec = (durationMs / 1000).toFixed(1);
const color = durationSec > 60 ? 'red' : 'green';
return (
<Tag color={color} size='large' shape='circle'>
{durationSec} {t('秒')}
</Tag>
);
}
// 定义所有列
const allColumns = [
{
key: COLUMN_KEYS.SUBMIT_TIME,
title: t('提交时间'),
dataIndex: 'submit_time',
width: 180,
render: (text, record, index) => {
return <div>{renderTimestamp(text / 1000)}</div>;
},
},
{
key: COLUMN_KEYS.DURATION,
title: t('花费时间'),
dataIndex: 'finish_time',
width: 120,
render: (finish, record) => {
return renderDuration(record.submit_time, finish);
},
},
{
key: COLUMN_KEYS.CHANNEL,
title: t('渠道'),
dataIndex: 'channel_id',
width: 100,
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? (
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
onClick={() => {
copyText(text);
}}
>
{' '}
{text}{' '}
</Tag>
</div>
) : (
<></>
);
},
},
{
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'action',
width: 120,
render: (text, record, index) => {
return <div>{renderType(text)}</div>;
},
},
{
key: COLUMN_KEYS.TASK_ID,
title: t('任务ID'),
dataIndex: 'mj_id',
width: 200,
render: (text, record, index) => {
return <div>{text}</div>;
},
},
{
key: COLUMN_KEYS.SUBMIT_RESULT,
title: t('提交结果'),
dataIndex: 'code',
width: 120,
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? <div>{renderCode(text)}</div> : <></>;
},
},
{
key: COLUMN_KEYS.TASK_STATUS,
title: t('任务状态'),
dataIndex: 'status',
width: 120,
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
},
{
key: COLUMN_KEYS.PROGRESS,
title: t('进度'),
dataIndex: 'progress',
width: 160,
render: (text, record, index) => {
return (
<div>
{
<Progress
stroke={
record.status === 'FAILURE'
? 'var(--semi-color-warning)'
: null
}
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='drawing progress'
/>
}
</div>
);
},
},
{
key: COLUMN_KEYS.IMAGE,
title: t('结果图片'),
dataIndex: 'image_url',
width: 120,
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Button
onClick={() => {
setModalImageUrl(text);
setIsModalOpenurl(true);
}}
>
{t('查看图片')}
</Button>
);
},
},
{
key: COLUMN_KEYS.PROMPT,
title: 'Prompt',
dataIndex: 'prompt',
width: 200,
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
{
key: COLUMN_KEYS.PROMPT_EN,
title: 'PromptEn',
dataIndex: 'prompt_en',
width: 200,
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
{
key: COLUMN_KEYS.FAIL_REASON,
title: t('失败原因'),
dataIndex: 'fail_reason',
width: 160,
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
];
// 根据可见性设置过滤列
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType, setLogType] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [showBanner, setShowBanner] = useState(false);
// 定义模态框图片URL的状态和更新函数
const [modalImageUrl, setModalImageUrl] = useState('');
let now = new Date();
// 初始化start_timestamp为前一天
const [inputs, setInputs] = useState({
channel_id: '',
mj_id: '',
start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
});
const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;
const [stat, setStat] = useState({
quota: 0,
token: 0,
});
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const setLogsFormat = (logs) => {
for (let i = 0; i < logs.length; i++) {
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
logs[i].key = '' + logs[i].id;
}
// data.key = '' + data.id
setLogs(logs);
setLogCount(logs.length + pageSize);
// console.log(logCount);
};
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
setLoading(true);
let url = '';
let localStartTimestamp = Date.parse(start_timestamp);
let localEndTimestamp = Date.parse(end_timestamp);
if (isAdminUser) {
url = `/api/mj/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} else {
url = `/api/mj/self/?p=${startIdx}&page_size=${pageSize}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
}
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setLogsFormat(data);
} else {
let newLogs = [...logs];
newLogs.splice(startIdx * pageSize, data.length, ...data);
setLogsFormat(newLogs);
}
} else {
showError(message);
}
setLoading(false);
};
const pageData = logs.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(logs.length / pageSize) + 1) {
// In this case we have to load more data and then append them.
loadLogs(page - 1, pageSize).then((r) => { });
}
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('mj-page-size', size + '');
setPageSize(size);
setActivePage(1);
await loadLogs(0, size);
};
const refresh = async () => {
// setLoading(true);
setActivePage(1);
await loadLogs(0, pageSize);
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制:') + text);
} else {
// setSearchKeyword(text);
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
useEffect(() => {
const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(0, localPageSize).then();
}, [logType]);
useEffect(() => {
const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
if (mjNotifyEnabled !== 'true') {
setShowBanner(true);
}
}, []);
// 列选择器模态框
const renderColumnSelector = () => {
return (
<Modal
title={t('列设置')}
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{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) => {
// 为非管理员用户跳过管理员专用列
if (
!isAdminUser &&
(column.key === COLUMN_KEYS.CHANNEL ||
column.key === COLUMN_KEYS.SUBMIT_RESULT)
) {
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>
);
};
return (
<>
{renderColumnSelector()}
<Layout>
<Card
className="!rounded-2xl overflow-hidden mb-4"
title={
<div className="flex flex-col w-full">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="flex items-center text-orange-500 mb-2 md:mb-0">
<IconEyeOpened className="mr-2" />
{loading ? (
<Skeleton.Title
style={{
width: 300,
marginBottom: 0,
marginTop: 0
}}
/>
) : (
<Text>
{isAdminUser && showBanner
? t('当前未开启Midjourney回调部分项目可能无法获得绘图结果可在运营设置中开启。')
: t('Midjourney 任务记录')}
</Text>
)}
</div>
</div>
<Divider margin="12px" />
{/* 搜索表单区域 */}
<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">
<DatePicker
className="w-full"
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
onChange={(value) => {
if (Array.isArray(value) && value.length === 2) {
handleInputChange(value[0], 'start_timestamp');
handleInputChange(value[1], 'end_timestamp');
}
}}
/>
</div>
{/* 任务 ID */}
<Input
prefix={<IconSearch />}
placeholder={t('任务 ID')}
value={mj_id}
onChange={(value) => handleInputChange(value, 'mj_id')}
className="!rounded-full"
showClear
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Input
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel_id}
onChange={(value) => handleInputChange(value, 'channel_id')}
className="!rounded-full"
showClear
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center pt-2">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
onClick={refresh}
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</div>
}
shadows='hover'
>
<Table
columns={getVisibleColumns()}
dataSource={pageData}
rowKey='key'
loading={loading}
className="rounded-xl overflow-hidden"
size="middle"
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: logCount,
}),
currentPage: activePage,
pageSize: pageSize,
total: logCount,
pageSizeOptions: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
},
onPageChange: handlePageChange,
}}
/>
</Card>
<Modal
visible={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
width={800} // 设置模态框宽度
>
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
</Modal>
<ImagePreview
src={modalImageUrl}
visible={isModalOpenurl}
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</Layout>
</>
);
};
export default LogsTable;

View File

@@ -0,0 +1,611 @@
import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
import { API, copy, showError, showInfo, showSuccess } from '../../helpers/index.js';
import { useTranslation } from 'react-i18next';
import {
Input,
Layout,
Modal,
Space,
Table,
Tag,
Tooltip,
Popover,
ImagePreview,
Button,
Card,
Tabs,
TabPane,
Dropdown,
} from '@douyinfe/semi-ui';
import {
IconVerify,
IconHelpCircle,
IconSearch,
IconCopy,
IconInfoCircle,
IconCrown,
} from '@douyinfe/semi-icons';
import { UserContext } from '../../context/User/index.js';
import { AlertCircle } from 'lucide-react';
import { MODEL_CATEGORIES } from '../../constants/index.js';
const ModelPricing = () => {
const { t } = useTranslation();
const [filteredValue, setFilteredValue] = useState([]);
const compositionRef = useRef({ isComposition: false });
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [selectedGroup, setSelectedGroup] = useState('default');
const [activeKey, setActiveKey] = useState('all');
const [pageSize, setPageSize] = useState(10);
const rowSelection = useMemo(
() => ({
onChange: (selectedRowKeys, selectedRows) => {
setSelectedRowKeys(selectedRowKeys);
},
}),
[],
);
const handleChange = (value) => {
if (compositionRef.current.isComposition) {
return;
}
const newFilteredValue = value ? [value] : [];
setFilteredValue(newFilteredValue);
};
const handleCompositionStart = () => {
compositionRef.current.isComposition = true;
};
const handleCompositionEnd = (event) => {
compositionRef.current.isComposition = false;
const value = event.target.value;
const newFilteredValue = value ? [value] : [];
setFilteredValue(newFilteredValue);
};
function renderQuotaType(type) {
switch (type) {
case 1:
return (
<Tag color='teal' size='large' shape='circle'>
{t('按次计费')}
</Tag>
);
case 0:
return (
<Tag color='violet' size='large' shape='circle'>
{t('按量计费')}
</Tag>
);
default:
return t('未知');
}
}
function renderAvailable(available) {
return available ? (
<Popover
content={
<div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
}
position='top'
key={available}
className="bg-green-50"
>
<IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
</Popover>
) : null;
}
const columns = [
{
title: t('可用性'),
dataIndex: 'available',
render: (text, record, index) => {
return renderAvailable(record.enable_groups.includes(selectedGroup));
},
sorter: (a, b) => {
const aAvailable = a.enable_groups.includes(selectedGroup);
const bAvailable = b.enable_groups.includes(selectedGroup);
return Number(aAvailable) - Number(bAvailable);
},
defaultSortOrder: 'descend',
width: 100,
},
{
title: t('模型名称'),
dataIndex: 'model_name',
render: (text, record, index) => {
return (
<Tag
color='green'
size='large'
shape='circle'
onClick={() => {
copyText(text);
}}
>
{text}
</Tag>
);
},
onFilter: (value, record) =>
record.model_name.toLowerCase().includes(value.toLowerCase()),
filteredValue,
width: 200,
},
{
title: t('计费类型'),
dataIndex: 'quota_type',
render: (text, record, index) => {
return renderQuotaType(parseInt(text));
},
sorter: (a, b) => a.quota_type - b.quota_type,
width: 120,
},
{
title: t('可用分组'),
dataIndex: 'enable_groups',
render: (text, record, index) => {
return (
<Space wrap>
{text.map((group) => {
if (usableGroup[group]) {
if (group === selectedGroup) {
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<IconVerify />}>
{group}
</Tag>
);
} else {
return (
<Tag
color='blue'
size='large'
onClick={() => {
setSelectedGroup(group);
showInfo(
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group,
ratio: groupRatio[group],
}),
);
}}
className="cursor-pointer hover:opacity-80 transition-opacity !rounded-full"
>
{group}
</Tag>
);
}
}
})}
</Space>
);
},
},
{
title: () => (
<div className="flex items-center space-x-1">
<span>{t('倍率')}</span>
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
<IconHelpCircle
className="text-blue-500 cursor-pointer"
onClick={() => {
setModalImageUrl('/ratio.png');
setIsModalOpenurl(true);
}}
/>
</Tooltip>
</div>
),
dataIndex: 'model_ratio',
render: (text, record, index) => {
let content = text;
let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
content = (
<div className="space-y-1">
<div className="text-gray-700">
{t('模型倍率')}{record.quota_type === 0 ? text : t('无')}
</div>
<div className="text-gray-700">
{t('补全倍率')}
{record.quota_type === 0 ? completionRatio : t('无')}
</div>
<div className="text-gray-700">
{t('分组倍率')}{groupRatio[selectedGroup]}
</div>
</div>
);
return content;
},
width: 200,
},
{
title: t('模型价格'),
dataIndex: 'model_price',
render: (text, record, index) => {
let content = text;
if (record.quota_type === 0) {
let inputRatioPrice =
record.model_ratio * 2 * groupRatio[selectedGroup];
let completionRatioPrice =
record.model_ratio *
record.completion_ratio *
2 *
groupRatio[selectedGroup];
content = (
<div className="space-y-1">
<div className="text-gray-700">
{t('提示')} ${inputRatioPrice.toFixed(3)} / 1M tokens
</div>
<div className="text-gray-700">
{t('补全')} ${completionRatioPrice.toFixed(3)} / 1M tokens
</div>
</div>
);
} else {
let price = parseFloat(text) * groupRatio[selectedGroup];
content = (
<div className="text-gray-700">
${t('模型价格')}${price.toFixed(3)}
</div>
);
}
return content;
},
width: 250,
},
];
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
const [userState, userDispatch] = useContext(UserContext);
const [groupRatio, setGroupRatio] = useState({});
const [usableGroup, setUsableGroup] = useState({});
const setModelsFormat = (models, groupRatio) => {
for (let i = 0; i < models.length; i++) {
models[i].key = models[i].model_name;
models[i].group_ratio = groupRatio[models[i].model_name];
}
models.sort((a, b) => {
return a.quota_type - b.quota_type;
});
models.sort((a, b) => {
if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
return -1;
} else if (
!a.model_name.startsWith('gpt') &&
b.model_name.startsWith('gpt')
) {
return 1;
} else {
return a.model_name.localeCompare(b.model_name);
}
});
setModels(models);
};
const loadPricing = async () => {
setLoading(true);
let url = '/api/pricing';
const res = await API.get(url);
const { success, message, data, group_ratio, usable_group } = res.data;
if (success) {
setGroupRatio(group_ratio);
setUsableGroup(usable_group);
setSelectedGroup(userState.user ? userState.user.group : 'default');
setModelsFormat(data, group_ratio);
} else {
showError(message);
}
setLoading(false);
};
const refresh = async () => {
await loadPricing();
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制:') + text);
} else {
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
useEffect(() => {
refresh().then();
}, []);
const modelCategories = MODEL_CATEGORIES(t);
const renderArrow = (items, pos, handleArrowClick) => {
const style = {
width: 32,
height: 32,
margin: '0 12px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '100%',
background: 'rgba(var(--semi-grey-1), 1)',
color: 'var(--semi-color-text)',
cursor: 'pointer',
};
return (
<Dropdown
render={
<Dropdown.Menu>
{items.map(item => (
<Dropdown.Item
key={item.itemKey}
onClick={() => setActiveKey(item.itemKey)}
icon={modelCategories[item.itemKey]?.icon}
>
{modelCategories[item.itemKey]?.label || item.itemKey}
</Dropdown.Item>
))}
</Dropdown.Menu>
}
>
<div style={style} onClick={handleArrowClick}>
{pos === 'start' ? '←' : '→'}
</div>
</Dropdown>
);
};
// 检查分类是否有对应的模型
const availableCategories = useMemo(() => {
if (!models.length) return ['all'];
return Object.entries(modelCategories).filter(([key, category]) => {
if (key === 'all') return true;
return models.some(model => category.filter(model));
}).map(([key]) => key);
}, [models]);
// 渲染标签页
const renderTabs = () => {
return (
<Tabs
renderArrow={renderArrow}
activeKey={activeKey}
type="card"
collapsible
onChange={key => setActiveKey(key)}
className="mt-2"
>
{Object.entries(modelCategories)
.filter(([key]) => availableCategories.includes(key))
.map(([key, category]) => (
<TabPane
tab={
<span className="flex items-center gap-2">
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
{category.label}
</span>
}
itemKey={key}
key={key}
/>
))}
</Tabs>
);
};
// 优化过滤逻辑
const filteredModels = useMemo(() => {
let result = models;
// 先按分类过滤
if (activeKey !== 'all') {
result = result.filter(model => modelCategories[activeKey].filter(model));
}
// 再按搜索词过滤
if (filteredValue.length > 0) {
const searchTerm = filteredValue[0].toLowerCase();
result = result.filter(model =>
model.model_name.toLowerCase().includes(searchTerm)
);
}
return result;
}, [activeKey, models, filteredValue]);
// 搜索和操作区组件
const SearchAndActions = useMemo(() => (
<Card className="!rounded-xl mb-6" shadows='hover'>
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[200px]">
<Input
prefix={<IconSearch />}
placeholder={t('模糊搜索模型名称')}
className="!rounded-lg"
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
size="large"
/>
</div>
<Button
theme='light'
type='primary'
icon={<IconCopy />}
onClick={() => copyText(selectedRowKeys)}
disabled={selectedRowKeys.length === 0}
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 text-white"
size="large"
>
{t('复制选中模型')}
</Button>
</div>
</Card>
), [selectedRowKeys, t]);
// 表格组件
const ModelTable = useMemo(() => (
<Card className="!rounded-xl overflow-hidden" shadows='hover'>
<Table
columns={columns}
dataSource={filteredModels}
loading={loading}
rowSelection={rowSelection}
className="custom-table"
pagination={{
defaultPageSize: 10,
pageSize: pageSize,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: filteredModels.length,
}),
onPageSizeChange: (size) => setPageSize(size),
}}
/>
</Card>
), [filteredModels, loading, columns, rowSelection, pageSize, t]);
return (
<div className="min-h-screen bg-gray-50">
<Layout>
<Layout.Content>
<div className="flex justify-center p-4 sm:p-6 md:p-8">
<div className="w-full">
{/* 主卡片容器 */}
<Card className="!rounded-2xl shadow-lg border-0">
{/* 顶部状态卡片 */}
<Card
className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
position: 'relative'
}}
bodyStyle={{ padding: 0 }}
>
{/* 装饰性背景元素 */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
</div>
<div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
<div className="flex items-start">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
<IconCrown size="large" className="text-white" />
</div>
<div className="flex-1 min-w-0">
<div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
{t('模型定价')}
</div>
<div className="text-sm text-white/80">
{userState.user ? (
<div className="flex items-center">
<IconVerify className="mr-1.5 flex-shrink-0" size="small" />
<span className="truncate">
{t('当前分组')}: {userState.user.group}{t('倍率')}: {groupRatio[userState.user.group]}
</span>
</div>
) : (
<div className="flex items-center">
<AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
<span className="truncate">
{t('未登录,使用默认分组倍率')}: {groupRatio['default']}
</span>
</div>
)}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
<div
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
style={{ backdropFilter: 'blur(10px)' }}
>
<div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
<div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
</div>
<div
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
style={{ backdropFilter: 'blur(10px)' }}
>
<div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
<div className="text-sm sm:text-base font-semibold">
{models.filter(m => m.enable_groups.includes(selectedGroup)).length}
</div>
</div>
<div
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
style={{ backdropFilter: 'blur(10px)' }}
>
<div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
<div className="text-sm sm:text-base font-semibold">2</div>
</div>
</div>
</div>
{/* 计费说明 */}
<div className="mt-4 sm:mt-5">
<div className="flex items-start">
<div
className="w-full flex items-start space-x-2 px-3 py-2 sm:px-4 sm:py-2.5 rounded-lg text-xs sm:text-sm"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(10px)'
}}
>
<IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
<span>
{t('按量计费费用 = 分组倍率 × 模型倍率 × 提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
</span>
</div>
</div>
</div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
</div>
</Card>
{/* 模型分类 Tabs */}
<div className="mb-6">
{renderTabs()}
{/* 搜索和表格区域 */}
{SearchAndActions}
{ModelTable}
</div>
{/* 倍率说明图预览 */}
<ImagePreview
src={modalImageUrl}
visible={isModalOpenurl}
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</Card>
</div>
</div>
</Layout.Content>
</Layout>
</div>
);
};
export default ModelPricing;

View File

@@ -0,0 +1,543 @@
import React, { useEffect, useState } from 'react';
import {
API,
copy,
showError,
showSuccess,
timestamp2string,
renderQuota
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import {
Button,
Card,
Divider,
Dropdown,
Input,
Modal,
Popover,
Space,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconCopy,
IconSearch,
IconEyeOpened,
IconEdit,
IconDelete,
IconStop,
IconPlay,
IconMore,
} from '@douyinfe/semi-icons';
import EditRedemption from '../../pages/Redemption/EditRedemption';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
const RedemptionsTable = () => {
const { t } = useTranslation();
const renderStatus = (status) => {
switch (status) {
case 1:
return (
<Tag color='green' size='large' shape='circle'>
{t('未使用')}
</Tag>
);
case 2:
return (
<Tag color='red' size='large' shape='circle'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='grey' size='large' shape='circle'>
{t('已使用')}
</Tag>
);
default:
return (
<Tag color='black' size='large' shape='circle'>
{t('未知状态')}
</Tag>
);
}
};
const columns = [
{
title: t('ID'),
dataIndex: 'id',
width: 50,
},
{
title: t('名称'),
dataIndex: 'name',
width: 120,
},
{
title: t('状态'),
dataIndex: 'status',
key: 'status',
width: 100,
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
},
{
title: t('额度'),
dataIndex: 'quota',
width: 100,
render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>;
},
},
{
title: t('创建时间'),
dataIndex: 'created_time',
width: 180,
render: (text, record, index) => {
return <div>{renderTimestamp(text)}</div>;
},
},
{
title: t('兑换人ID'),
dataIndex: 'used_user_id',
width: 100,
render: (text, record, index) => {
return <div>{text === 0 ? t('无') : text}</div>;
},
},
{
title: '',
dataIndex: 'operate',
width: 300,
render: (text, record, index) => {
// 创建更多操作的下拉菜单项
const moreMenuItems = [
{
node: 'item',
name: t('删除'),
icon: <IconDelete />,
type: 'danger',
onClick: () => {
Modal.confirm({
title: t('确定是否要删除此兑换码?'),
content: t('此修改将不可逆'),
onOk: () => {
manageRedemption(record.id, 'delete', record).then(() => {
removeRecord(record.key);
});
},
});
},
}
];
// 动态添加启用/禁用按钮
if (record.status === 1) {
moreMenuItems.push({
node: 'item',
name: t('禁用'),
icon: <IconStop />,
type: 'warning',
onClick: () => {
manageRedemption(record.id, 'disable', record);
},
});
} else {
moreMenuItems.push({
node: 'item',
name: t('启用'),
icon: <IconPlay />,
type: 'secondary',
onClick: () => {
manageRedemption(record.id, 'enable', record);
},
disabled: record.status === 3,
});
}
return (
<Space>
<Popover content={record.key} style={{ padding: 20 }} position='top'>
<Button
icon={<IconEyeOpened />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
>
{t('查看')}
</Button>
</Popover>
<Button
icon={<IconCopy />}
theme='light'
type='secondary'
size="small"
className="!rounded-full"
onClick={async () => {
await copyText(record.key);
}}
>
{t('复制')}
</Button>
<Button
icon={<IconEdit />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
onClick={() => {
setEditingRedemption(record);
setShowEdit(true);
}}
disabled={record.status !== 1}
>
{t('编辑')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
>
<Button
icon={<IconMore />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
/>
</Dropdown>
</Space>
);
},
},
];
const [redemptions, setRedemptions] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
const [selectedKeys, setSelectedKeys] = useState([]);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [editingRedemption, setEditingRedemption] = useState({
id: undefined,
});
const [showEdit, setShowEdit] = useState(false);
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
setEditingRedemption({
id: undefined,
});
}, 500);
};
const setRedemptionFormat = (redeptions) => {
setRedemptions(redeptions);
};
const loadRedemptions = async (startIdx, pageSize) => {
const res = await API.get(
`/api/redemption/?p=${startIdx}&page_size=${pageSize}`,
);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setTokenCount(data.total);
setRedemptionFormat(newPageData);
} else {
showError(message);
}
setLoading(false);
};
const removeRecord = (key) => {
let newDataSource = [...redemptions];
if (key != null) {
let idx = newDataSource.findIndex((data) => data.key === key);
if (idx > -1) {
newDataSource.splice(idx, 1);
setRedemptions(newDataSource);
}
}
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制到剪贴板!'));
} else {
Modal.error({
title: t('无法复制到剪贴板,请手动复制'),
content: text,
size: 'large'
});
}
};
const onPaginationChange = (e, { activePage }) => {
(async () => {
if (activePage === Math.ceil(redemptions.length / pageSize) + 1) {
await loadRedemptions(activePage - 1, pageSize);
}
setActivePage(activePage);
})();
};
useEffect(() => {
loadRedemptions(0, pageSize)
.then()
.catch((reason) => {
showError(reason);
});
}, [pageSize]);
const refresh = async () => {
await loadRedemptions(activePage - 1, pageSize);
};
const manageRedemption = async (id, action, record) => {
setLoading(true);
let data = { id };
let res;
switch (action) {
case 'delete':
res = await API.delete(`/api/redemption/${id}/`);
break;
case 'enable':
data.status = 1;
res = await API.put('/api/redemption/?status_only=true', data);
break;
case 'disable':
data.status = 2;
res = await API.put('/api/redemption/?status_only=true', data);
break;
}
const { success, message } = res.data;
if (success) {
showSuccess(t('操作成功完成!'));
let redemption = res.data.data;
let newRedemptions = [...redemptions];
if (action === 'delete') {
} else {
record.status = redemption.status;
}
setRedemptions(newRedemptions);
} else {
showError(message);
}
setLoading(false);
};
const searchRedemptions = async (keyword, page, pageSize) => {
if (searchKeyword === '') {
await loadRedemptions(page, pageSize);
return;
}
setSearching(true);
const res = await API.get(
`/api/redemption/search?keyword=${keyword}&p=${page}&page_size=${pageSize}`,
);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setTokenCount(data.total);
setRedemptionFormat(newPageData);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const sortRedemption = (key) => {
if (redemptions.length === 0) return;
setLoading(true);
let sortedRedemptions = [...redemptions];
sortedRedemptions.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]);
});
if (sortedRedemptions[0].id === redemptions[0].id) {
sortedRedemptions.reverse();
}
setRedemptions(sortedRedemptions);
setLoading(false);
};
const handlePageChange = (page) => {
setActivePage(page);
if (searchKeyword === '') {
loadRedemptions(page, pageSize).then();
} else {
searchRedemptions(searchKeyword, page, pageSize).then();
}
};
let pageData = redemptions;
const rowSelection = {
onSelect: (record, selected) => { },
onSelectAll: (selected, selectedRows) => { },
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
};
const handleRow = (record, index) => {
if (record.status !== 1) {
return {
style: {
background: 'var(--semi-color-disabled-border)',
},
};
} else {
return {};
}
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-orange-500">
<IconEyeOpened className="mr-2" />
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
</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
theme='light'
type='primary'
icon={<IconPlus />}
className="!rounded-full w-full md:w-auto"
onClick={() => {
setEditingRedemption({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加兑换码')}
</Button>
<Button
type='warning'
icon={<IconCopy />}
className="!rounded-full w-full md: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);
}}
>
{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">
<div className="relative w-full md:w-64">
<Input
prefix={<IconSearch />}
placeholder={t('关键字(id或者名称)')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
</div>
<Button
type="primary"
onClick={() => {
searchRedemptions(searchKeyword, 1, pageSize).then();
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</div>
</div>
);
return (
<>
<EditRedemption
refresh={refresh}
editingRedemption={editingRedemption}
visiable={showEdit}
handleClose={closeEdit}
></EditRedemption>
<Card
className="!rounded-2xl overflow-hidden"
title={renderHeader()}
shadows='hover'
>
<Table
columns={columns}
dataSource={pageData}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: tokenCount,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: tokenCount,
}),
onPageSizeChange: (size) => {
setPageSize(size);
setActivePage(1);
if (searchKeyword === '') {
loadRedemptions(1, size).then();
} else {
searchRedemptions(searchKeyword, 1, size).then();
}
},
onPageChange: handlePageChange,
}}
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
className="rounded-xl overflow-hidden"
size="middle"
></Table>
</Card>
</>
);
};
export default RedemptionsTable;

View File

@@ -0,0 +1,749 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
} from '../../helpers';
import {
Button,
Card,
Checkbox,
DatePicker,
Divider,
Input,
Layout,
Modal,
Progress,
Skeleton,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
IconSetting,
} from '@douyinfe/semi-icons';
const { Text } = Typography;
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// 定义列键值常量
const COLUMN_KEYS = {
SUBMIT_TIME: 'submit_time',
FINISH_TIME: 'finish_time',
DURATION: 'duration',
CHANNEL: 'channel',
PLATFORM: 'platform',
TYPE: 'type',
TASK_ID: 'task_id',
TASK_STATUS: 'task_status',
PROGRESS: 'progress',
FAIL_REASON: 'fail_reason',
};
const renderTimestamp = (timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
const year = date.getFullYear(); // 获取年份
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份从0开始需要+1并保证两位数
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
};
function renderDuration(submit_time, finishTime) {
// 确保startTime和finishTime都是有效的时间戳
if (!submit_time || !finishTime) return 'N/A';
// 将时间戳转换为Date对象
const start = new Date(submit_time);
const finish = new Date(finishTime);
// 计算时间差(毫秒)
const durationMs = finish - start;
// 将时间差转换为秒,并保留一位小数
const durationSec = (durationMs / 1000).toFixed(1);
// 设置颜色大于60秒则为红色小于等于60秒则为绿色
const color = durationSec > 60 ? 'red' : 'green';
// 返回带有样式的颜色标签
return (
<Tag color={color} size='large'>
{durationSec}
</Tag>
);
}
const LogsTable = () => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContent, setModalContent] = useState('');
// 列可见性状态
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
const isAdminUser = isAdmin();
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
// 加载保存的列偏好设置
useEffect(() => {
const savedColumns = localStorage.getItem('task-logs-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();
}
}, []);
// 获取默认列可见性
const getDefaultColumnVisibility = () => {
return {
[COLUMN_KEYS.SUBMIT_TIME]: true,
[COLUMN_KEYS.FINISH_TIME]: true,
[COLUMN_KEYS.DURATION]: true,
[COLUMN_KEYS.CHANNEL]: isAdminUser,
[COLUMN_KEYS.PLATFORM]: true,
[COLUMN_KEYS.TYPE]: true,
[COLUMN_KEYS.TASK_ID]: true,
[COLUMN_KEYS.TASK_STATUS]: true,
[COLUMN_KEYS.PROGRESS]: true,
[COLUMN_KEYS.FAIL_REASON]: true,
};
};
// 初始化默认列可见性
const initDefaultColumns = () => {
const defaults = getDefaultColumnVisibility();
setVisibleColumns(defaults);
localStorage.setItem('task-logs-table-columns', JSON.stringify(defaults));
};
// 处理列可见性变化
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) => {
if (key === COLUMN_KEYS.CHANNEL && !isAdminUser) {
updatedColumns[key] = false;
} else {
updatedColumns[key] = checked;
}
});
setVisibleColumns(updatedColumns);
};
// 更新表格时保存列可见性
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
localStorage.setItem('task-logs-table-columns', JSON.stringify(visibleColumns));
}
}, [visibleColumns]);
const renderType = (type) => {
switch (type) {
case 'MUSIC':
return (
<Tag color='grey' size='large' shape='circle'>
{t('生成音乐')}
</Tag>
);
case 'LYRICS':
return (
<Tag color='pink' size='large' shape='circle'>
{t('生成歌词')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
};
const renderPlatform = (type) => {
switch (type) {
case 'suno':
return (
<Tag color='green' size='large' shape='circle'>
Suno
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
};
const renderStatus = (type) => {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle'>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle'>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle'>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle'>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle'>
{t('失败')}
</Tag>
);
case 'QUEUED':
return (
<Tag color='orange' size='large' shape='circle'>
{t('排队中')}
</Tag>
);
case 'UNKNOWN':
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
case '':
return (
<Tag color='grey' size='large' shape='circle'>
{t('正在提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
};
// 定义所有列
const allColumns = [
{
key: COLUMN_KEYS.SUBMIT_TIME,
title: t('提交时间'),
dataIndex: 'submit_time',
width: 180,
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
key: COLUMN_KEYS.FINISH_TIME,
title: t('结束时间'),
dataIndex: 'finish_time',
width: 180,
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
key: COLUMN_KEYS.DURATION,
title: t('花费时间'),
dataIndex: 'finish_time',
width: 120,
render: (finish, record) => {
return <>{finish ? renderDuration(record.submit_time, finish) : '-'}</>;
},
},
{
key: COLUMN_KEYS.CHANNEL,
title: t('渠道'),
dataIndex: 'channel_id',
width: 100,
className: isAdminUser ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? (
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
onClick={() => {
copyText(text);
}}
>
{text}
</Tag>
</div>
) : (
<></>
);
},
},
{
key: COLUMN_KEYS.PLATFORM,
title: t('平台'),
dataIndex: 'platform',
width: 120,
render: (text, record, index) => {
return <div>{renderPlatform(text)}</div>;
},
},
{
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'action',
width: 120,
render: (text, record, index) => {
return <div>{renderType(text)}</div>;
},
},
{
key: COLUMN_KEYS.TASK_ID,
title: t('任务ID'),
dataIndex: 'task_id',
width: 200,
render: (text, record, index) => {
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
onClick={() => {
setModalContent(JSON.stringify(record, null, 2));
setIsModalOpen(true);
}}
>
<div>{text}</div>
</Typography.Text>
);
},
},
{
key: COLUMN_KEYS.TASK_STATUS,
title: t('任务状态'),
dataIndex: 'status',
width: 120,
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
},
{
key: COLUMN_KEYS.PROGRESS,
title: t('进度'),
dataIndex: 'progress',
width: 160,
render: (text, record, index) => {
return (
<div>
{
isNaN(text?.replace('%', '')) ? (
text || '-'
) : (
<Progress
stroke={
record.status === 'FAILURE'
? 'var(--semi-color-warning)'
: null
}
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='task progress'
/>
)
}
</div>
);
},
},
{
key: COLUMN_KEYS.FAIL_REASON,
title: t('失败原因'),
dataIndex: 'fail_reason',
width: 160,
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
];
// 根据可见性设置过滤列
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType] = useState(0);
let now = new Date();
// 初始化start_timestamp为前一天
let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const [inputs, setInputs] = useState({
channel_id: '',
task_id: '',
start_timestamp: timestamp2string(zeroNow.getTime() / 1000),
end_timestamp: '',
});
const { channel_id, task_id, start_timestamp, end_timestamp } = inputs;
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const setLogsFormat = (logs) => {
for (let i = 0; i < logs.length; i++) {
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
logs[i].key = '' + logs[i].id;
}
// data.key = '' + data.id
setLogs(logs);
setLogCount(logs.length + ITEMS_PER_PAGE);
// console.log(logCount);
};
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
setLoading(true);
let url = '';
let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
if (isAdminUser) {
url = `/api/task/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} else {
url = `/api/task/self?p=${startIdx}&page_size=${pageSize}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
}
const res = await API.get(url);
let { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setLogsFormat(data);
} else {
let newLogs = [...logs];
newLogs.splice(startIdx * pageSize, data.length, ...data);
setLogsFormat(newLogs);
}
} else {
showError(message);
}
setLoading(false);
};
const pageData = logs.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(logs.length / pageSize) + 1) {
loadLogs(page - 1, pageSize).then((r) => { });
}
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('task-page-size', size + '');
setPageSize(size);
setActivePage(1);
await loadLogs(0, size);
};
const refresh = async () => {
setActivePage(1);
await loadLogs(0, pageSize);
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制:') + text);
} else {
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
useEffect(() => {
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(0, localPageSize).then();
}, [logType]);
// 列选择器模态框
const renderColumnSelector = () => {
return (
<Modal
title={t('列设置')}
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{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) => {
// 为非管理员用户跳过管理员专用列
if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) {
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>
);
};
return (
<>
{renderColumnSelector()}
<Layout>
<Card
className="!rounded-2xl overflow-hidden mb-4"
title={
<div className="flex flex-col w-full">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="flex items-center text-orange-500 mb-2 md:mb-0">
<IconEyeOpened className="mr-2" />
{loading ? (
<Skeleton.Title
style={{
width: 300,
marginBottom: 0,
marginTop: 0
}}
/>
) : (
<Text>{t('任务记录')}</Text>
)}
</div>
</div>
<Divider margin="12px" />
{/* 搜索表单区域 */}
<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">
<DatePicker
className="w-full"
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
onChange={(value) => {
if (Array.isArray(value) && value.length === 2) {
handleInputChange(value[0], 'start_timestamp');
handleInputChange(value[1], 'end_timestamp');
}
}}
/>
</div>
{/* 任务 ID */}
<Input
prefix={<IconSearch />}
placeholder={t('任务 ID')}
value={task_id}
onChange={(value) => handleInputChange(value, 'task_id')}
className="!rounded-full"
showClear
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Input
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel_id}
onChange={(value) => handleInputChange(value, 'channel_id')}
className="!rounded-full"
showClear
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center pt-2">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
onClick={refresh}
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</div>
}
shadows='hover'
>
<Table
columns={getVisibleColumns()}
dataSource={pageData}
rowKey='key'
loading={loading}
className="rounded-xl overflow-hidden"
size="middle"
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: logCount,
}),
currentPage: activePage,
pageSize: pageSize,
total: logCount,
pageSizeOptions: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
},
onPageChange: handlePageChange,
}}
/>
</Card>
<Modal
visible={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
width={800} // 设置模态框宽度
>
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
</Modal>
</Layout>
</>
);
};
export default LogsTable;

View File

@@ -0,0 +1,760 @@
import React, { useEffect, useState, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import {
API,
copy,
showError,
showSuccess,
timestamp2string,
renderGroup,
renderQuota
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import {
Button,
Card,
Dropdown,
Modal,
Space,
SplitButtonGroup,
Table,
Tag,
Input,
Divider,
Avatar,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconCopy,
IconSearch,
IconTreeTriangleDown,
IconEyeOpened,
IconEdit,
IconDelete,
IconStop,
IconPlay,
IconMore,
IconMoneyExchangeStroked,
IconHistogram,
IconRotate,
} from '@douyinfe/semi-icons';
import EditToken from '../../pages/Token/EditToken';
import { useTranslation } from 'react-i18next';
import { UserContext } from '../../context/User';
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
const TokensTable = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [userState, userDispatch] = useContext(UserContext);
const renderStatus = (status, model_limits_enabled = false) => {
switch (status) {
case 1:
if (model_limits_enabled) {
return (
<Tag color='green' size='large' shape='circle'>
{t('已启用:限制模型')}
</Tag>
);
} else {
return (
<Tag color='green' size='large' shape='circle'>
{t('已启用')}
</Tag>
);
}
case 2:
return (
<Tag color='red' size='large' shape='circle'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='yellow' size='large' shape='circle'>
{t('已过期')}
</Tag>
);
case 4:
return (
<Tag color='grey' size='large' shape='circle'>
{t('已耗尽')}
</Tag>
);
default:
return (
<Tag color='black' size='large' shape='circle'>
{t('未知状态')}
</Tag>
);
}
};
const columns = [
{
title: t('名称'),
dataIndex: 'name',
width: 180,
},
{
title: t('状态'),
dataIndex: 'status',
key: 'status',
width: 200,
render: (text, record, index) => {
return (
<div>
<Space>
{renderStatus(text, record.model_limits_enabled)}
{renderGroup(record.group)}
</Space>
</div>
);
},
},
{
title: t('已用额度'),
dataIndex: 'used_quota',
width: 120,
render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>;
},
},
{
title: t('剩余额度'),
dataIndex: 'remain_quota',
width: 120,
render: (text, record, index) => {
return (
<div>
{record.unlimited_quota ? (
<Tag size={'large'} color={'white'} shape='circle'>
{t('无限制')}
</Tag>
) : (
<Tag size={'large'} color={'light-blue'} shape='circle'>
{renderQuota(parseInt(text))}
</Tag>
)}
</div>
);
},
},
{
title: t('创建时间'),
dataIndex: 'created_time',
width: 180,
render: (text, record, index) => {
return <div>{renderTimestamp(text)}</div>;
},
},
{
title: t('过期时间'),
dataIndex: 'expired_time',
width: 180,
render: (text, record, index) => {
return (
<div>
{record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)}
</div>
);
},
},
{
title: '',
dataIndex: 'operate',
width: 320,
render: (text, record, index) => {
let chats = localStorage.getItem('chats');
let chatsArray = [];
let shouldUseCustom = true;
if (shouldUseCustom) {
try {
chats = JSON.parse(chats);
if (Array.isArray(chats)) {
for (let i = 0; i < chats.length; i++) {
let chat = {};
chat.node = 'item';
for (let key in chats[i]) {
if (chats[i].hasOwnProperty(key)) {
chat.key = i;
chat.name = key;
chat.onClick = () => {
onOpenLink(key, chats[i][key], record);
};
}
}
chatsArray.push(chat);
}
}
} catch (e) {
console.log(e);
showError(t('聊天链接配置错误,请联系管理员'));
}
}
// 创建更多操作的下拉菜单项
const moreMenuItems = [
{
node: 'item',
name: t('查看'),
icon: <IconEyeOpened />,
onClick: () => {
Modal.info({
title: t('令牌详情'),
content: 'sk-' + record.key,
size: 'large',
});
},
},
{
node: 'item',
name: t('删除'),
icon: <IconDelete />,
type: 'danger',
onClick: () => {
Modal.confirm({
title: t('确定是否要删除此令牌?'),
content: t('此修改将不可逆'),
onOk: () => {
manageToken(record.id, 'delete', record).then(() => {
removeRecord(record.key);
});
},
});
},
}
];
// 动态添加启用/禁用按钮
if (record.status === 1) {
moreMenuItems.push({
node: 'item',
name: t('禁用'),
icon: <IconStop />,
type: 'warning',
onClick: () => {
manageToken(record.id, 'disable', record);
},
});
} else {
moreMenuItems.push({
node: 'item',
name: t('启用'),
icon: <IconPlay />,
type: 'secondary',
onClick: () => {
manageToken(record.id, 'enable', record);
},
});
}
return (
<Space wrap>
<SplitButtonGroup
className="!rounded-full overflow-hidden"
aria-label={t('项目操作按钮组')}
>
<Button
theme='light'
size="small"
style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
onClick={() => {
if (chatsArray.length === 0) {
showError(t('请联系管理员配置聊天链接'));
} else {
onOpenLink(
'default',
chats[0][Object.keys(chats[0])[0]],
record,
);
}
}}
>
{t('聊天')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={chatsArray}
>
<Button
style={{
padding: '4px 4px',
color: 'rgba(var(--semi-teal-7), 1)',
}}
type='primary'
icon={<IconTreeTriangleDown />}
size="small"
></Button>
</Dropdown>
</SplitButtonGroup>
<Button
icon={<IconCopy />}
theme='light'
type='secondary'
size="small"
className="!rounded-full"
onClick={async (text) => {
await copyText('sk-' + record.key);
}}
>
{t('复制')}
</Button>
<Button
icon={<IconEdit />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
onClick={() => {
setEditingToken(record);
setShowEdit(true);
}}
>
{t('编辑')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
>
<Button
icon={<IconMore />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
/>
</Dropdown>
</Space>
);
},
},
];
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [showEdit, setShowEdit] = useState(false);
const [tokens, setTokens] = useState([]);
const [selectedKeys, setSelectedKeys] = useState([]);
const [tokenCount, setTokenCount] = useState(pageSize);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchToken, setSearchToken] = useState('');
const [searching, setSearching] = useState(false);
const [chats, setChats] = useState([]);
const [editingToken, setEditingToken] = useState({
id: undefined,
});
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
setEditingToken({
id: undefined,
});
}, 500);
};
const setTokensFormat = (tokens) => {
setTokens(tokens);
if (tokens.length >= pageSize) {
setTokenCount(tokens.length + pageSize);
} else {
setTokenCount(tokens.length);
}
};
let pageData = tokens.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const loadTokens = async (startIdx) => {
setLoading(true);
const res = await API.get(`/api/token/?p=${startIdx}&size=${pageSize}`);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setTokensFormat(data);
} else {
let newTokens = [...tokens];
newTokens.splice(startIdx * pageSize, data.length, ...data);
setTokensFormat(newTokens);
}
} else {
showError(message);
}
setLoading(false);
};
const refresh = async () => {
await loadTokens(activePage - 1);
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制到剪贴板!'));
} else {
Modal.error({
title: t('无法复制到剪贴板,请手动复制'),
content: text,
size: 'large',
});
}
};
const onOpenLink = async (type, url, record) => {
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
status = JSON.parse(status);
serverAddress = status.server_address;
}
if (serverAddress === '') {
serverAddress = window.location.origin;
}
let encodedServerAddress = encodeURIComponent(serverAddress);
url = url.replaceAll('{address}', encodedServerAddress);
url = url.replaceAll('{key}', 'sk-' + record.key);
window.open(url, '_blank');
};
// 获取用户数据
const getUserData = async () => {
try {
const res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
} else {
showError(message);
}
} catch (error) {
console.error('获取用户数据失败:', error);
showError(t('获取用户数据失败'));
}
};
useEffect(() => {
// 获取用户数据以确保显示正确的余额和使用量
getUserData();
loadTokens(0)
.then()
.catch((reason) => {
showError(reason);
});
}, [pageSize]);
const removeRecord = (key) => {
let newDataSource = [...tokens];
if (key != null) {
let idx = newDataSource.findIndex((data) => data.key === key);
if (idx > -1) {
newDataSource.splice(idx, 1);
setTokensFormat(newDataSource);
}
}
};
const manageToken = async (id, action, record) => {
setLoading(true);
let data = { id };
let res;
switch (action) {
case 'delete':
res = await API.delete(`/api/token/${id}/`);
break;
case 'enable':
data.status = 1;
res = await API.put('/api/token/?status_only=true', data);
break;
case 'disable':
data.status = 2;
res = await API.put('/api/token/?status_only=true', data);
break;
}
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
let token = res.data.data;
let newTokens = [...tokens];
if (action === 'delete') {
} else {
record.status = token.status;
}
setTokensFormat(newTokens);
} else {
showError(message);
}
setLoading(false);
};
const searchTokens = async () => {
if (searchKeyword === '' && searchToken === '') {
await loadTokens(0);
setActivePage(1);
return;
}
setSearching(true);
const res = await API.get(
`/api/token/search?keyword=${searchKeyword}&token=${searchToken}`,
);
const { success, message, data } = res.data;
if (success) {
setTokensFormat(data);
setActivePage(1);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const handleSearchTokenChange = async (value) => {
setSearchToken(value.trim());
};
const sortToken = (key) => {
if (tokens.length === 0) return;
setLoading(true);
let sortedTokens = [...tokens];
sortedTokens.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]);
});
if (sortedTokens[0].id === tokens[0].id) {
sortedTokens.reverse();
}
setTokens(sortedTokens);
setLoading(false);
};
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(tokens.length / pageSize) + 1) {
loadTokens(page - 1).then((r) => { });
}
};
const rowSelection = {
onSelect: (record, selected) => { },
onSelectAll: (selected, selectedRows) => { },
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
};
const handleRow = (record, index) => {
if (record.status !== 1) {
return {
style: {
background: 'var(--semi-color-disabled-border)',
},
};
} else {
return {};
}
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card
shadows='hover'
className="bg-blue-50 border-0 !rounded-2xl w-full"
headerLine={false}
onClick={() => navigate('/console/topup')}
>
<div className="flex items-center">
<Avatar
className="mr-3"
size="medium"
color="blue"
>
<IconMoneyExchangeStroked size="large" />
</Avatar>
<div>
<div className="text-sm text-gray-500">{t('当前余额')}</div>
<div className="text-xl font-semibold">{renderQuota(userState?.user?.quota)}</div>
</div>
</div>
</Card>
<Card
shadows='hover'
className="bg-purple-50 border-0 !rounded-2xl w-full"
headerLine={false}
>
<div className="flex items-center">
<Avatar
className="mr-3"
size="medium"
color="purple"
>
<IconHistogram size="large" />
</Avatar>
<div>
<div className="text-sm text-gray-500">{t('累计消费')}</div>
<div className="text-xl font-semibold">{renderQuota(userState?.user?.used_quota)}</div>
</div>
</div>
</Card>
<Card
shadows='hover'
className="bg-green-50 border-0 !rounded-2xl w-full"
headerLine={false}
>
<div className="flex items-center">
<Avatar
className="mr-3"
size="medium"
color="green"
>
<IconRotate size="large" />
</Avatar>
<div>
<div className="text-sm text-gray-500">{t('请求次数')}</div>
<div className="text-xl font-semibold">{userState?.user?.request_count || 0}</div>
</div>
</div>
</Card>
</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
theme="light"
type="primary"
icon={<IconPlus />}
className="!rounded-full w-full md:w-auto"
onClick={() => {
setEditingToken({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加令牌')}
</Button>
<Button
theme="light"
type="warning"
icon={<IconCopy />}
className="!rounded-full w-full md: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 + ' sk-' + selectedKeys[i].key + '\n';
}
await copyText(keys);
}}
>
{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">
<div className="relative w-full md:w-56">
<Input
prefix={<IconSearch />}
placeholder={t('搜索关键字')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
</div>
<div className="relative w-full md:w-56">
<Input
prefix={<IconSearch />}
placeholder={t('密钥')}
value={searchToken}
onChange={handleSearchTokenChange}
className="!rounded-full"
showClear
/>
</div>
<Button
type="primary"
onClick={searchTokens}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</div>
</div>
);
return (
<>
<EditToken
refresh={refresh}
editingToken={editingToken}
visiable={showEdit}
handleClose={closeEdit}
></EditToken>
<Card
className="!rounded-2xl overflow-hidden"
title={renderHeader()}
shadows='hover'
>
<Table
columns={columns}
dataSource={pageData}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: tokenCount,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: tokens.length,
}),
onPageSizeChange: (size) => {
setPageSize(size);
setActivePage(1);
},
onPageChange: handlePageChange,
}}
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
className="rounded-xl overflow-hidden"
size="middle"
></Table>
</Card>
</>
);
};
export default TokensTable;

View File

@@ -0,0 +1,586 @@
import React, { useEffect, useState } from 'react';
import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers';
import {
Button,
Card,
Divider,
Dropdown,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconSearch,
IconEdit,
IconDelete,
IconStop,
IconPlay,
IconMore,
IconUserAdd,
IconArrowUp,
IconArrowDown,
} from '@douyinfe/semi-icons';
import { ITEMS_PER_PAGE } from '../../constants';
import AddUser from '../../pages/User/AddUser';
import EditUser from '../../pages/User/EditUser';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
const UsersTable = () => {
const { t } = useTranslation();
function renderRole(role) {
switch (role) {
case 1:
return (
<Tag size='large' color='blue' shape='circle'>
{t('普通用户')}
</Tag>
);
case 10:
return (
<Tag color='yellow' size='large' shape='circle'>
{t('管理员')}
</Tag>
);
case 100:
return (
<Tag color='orange' size='large' shape='circle'>
{t('超级管理员')}
</Tag>
);
default:
return (
<Tag color='red' size='large' shape='circle'>
{t('未知身份')}
</Tag>
);
}
}
const renderStatus = (status) => {
switch (status) {
case 1:
return <Tag size='large' color='green' shape='circle'>{t('已激活')}</Tag>;
case 2:
return (
<Tag size='large' color='red' shape='circle'>
{t('已封禁')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle'>
{t('未知状态')}
</Tag>
);
}
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
width: 50,
},
{
title: t('用户名'),
dataIndex: 'username',
width: 100,
},
{
title: t('分组'),
dataIndex: 'group',
width: 100,
render: (text, record, index) => {
return <div>{renderGroup(text)}</div>;
},
},
{
title: t('统计信息'),
dataIndex: 'info',
width: 280,
render: (text, record, index) => {
return (
<div>
<Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{t('剩余')}: {renderQuota(record.quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{t('已用')}: {renderQuota(record.used_quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{t('调用')}: {renderNumber(record.request_count)}
</Tag>
</Space>
</div>
);
},
},
{
title: t('邀请信息'),
dataIndex: 'invite',
width: 250,
render: (text, record, index) => {
return (
<div>
<Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{t('邀请')}: {renderNumber(record.aff_count)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{t('收益')}: {renderQuota(record.aff_history_quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
</Tag>
</Space>
</div>
);
},
},
{
title: t('角色'),
dataIndex: 'role',
width: 120,
render: (text, record, index) => {
return <div>{renderRole(text)}</div>;
},
},
{
title: t('状态'),
dataIndex: 'status',
width: 100,
render: (text, record, index) => {
return (
<div>
{record.DeletedAt !== null ? (
<Tag color='red' shape='circle'>{t('已注销')}</Tag>
) : (
renderStatus(text)
)}
</div>
);
},
},
{
title: '',
dataIndex: 'operate',
width: 150,
render: (text, record, index) => {
if (record.DeletedAt !== null) {
return <></>;
}
// 创建更多操作的下拉菜单项
const moreMenuItems = [
{
node: 'item',
name: t('提升'),
icon: <IconArrowUp />,
type: 'warning',
onClick: () => {
Modal.confirm({
title: t('确定要提升此用户吗?'),
content: t('此操作将提升用户的权限级别'),
onOk: () => {
manageUser(record.id, 'promote', record);
},
});
},
},
{
node: 'item',
name: t('降级'),
icon: <IconArrowDown />,
type: 'secondary',
onClick: () => {
Modal.confirm({
title: t('确定要降级此用户吗?'),
content: t('此操作将降低用户的权限级别'),
onOk: () => {
manageUser(record.id, 'demote', record);
},
});
},
},
{
node: 'item',
name: t('注销'),
icon: <IconDelete />,
type: 'danger',
onClick: () => {
Modal.confirm({
title: t('确定是否要注销此用户?'),
content: t('相当于删除用户,此修改将不可逆'),
onOk: () => {
manageUser(record.id, 'delete', record).then(() => {
removeRecord(record.id);
});
},
});
},
}
];
// 动态添加启用/禁用按钮
if (record.status === 1) {
moreMenuItems.splice(-1, 0, {
node: 'item',
name: t('禁用'),
icon: <IconStop />,
type: 'warning',
onClick: () => {
manageUser(record.id, 'disable', record);
},
});
} else {
moreMenuItems.splice(-1, 0, {
node: 'item',
name: t('启用'),
icon: <IconPlay />,
type: 'secondary',
onClick: () => {
manageUser(record.id, 'enable', record);
},
disabled: record.status === 3,
});
}
return (
<Space>
<Button
icon={<IconEdit />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
onClick={() => {
setEditingUser(record);
setShowEditUser(true);
}}
>
{t('编辑')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
>
<Button
icon={<IconMore />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
/>
</Dropdown>
</Space>
);
},
},
];
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [searchGroup, setSearchGroup] = useState('');
const [groupOptions, setGroupOptions] = useState([]);
const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
const [showAddUser, setShowAddUser] = useState(false);
const [showEditUser, setShowEditUser] = useState(false);
const [editingUser, setEditingUser] = useState({
id: undefined,
});
const removeRecord = (key) => {
let newDataSource = [...users];
if (key != null) {
let idx = newDataSource.findIndex((data) => data.id === key);
if (idx > -1) {
// update deletedAt
newDataSource[idx].DeletedAt = new Date();
setUsers(newDataSource);
}
}
};
const setUserFormat = (users) => {
for (let i = 0; i < users.length; i++) {
users[i].key = users[i].id;
}
setUsers(users);
};
const loadUsers = async (startIdx, pageSize) => {
const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setUserCount(data.total);
setUserFormat(newPageData);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadUsers(0, pageSize)
.then()
.catch((reason) => {
showError(reason);
});
fetchGroups().then();
}, []);
const manageUser = async (userId, action, record) => {
const res = await API.post('/api/user/manage', {
id: userId,
action,
});
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
let user = res.data.data;
let newUsers = [...users];
if (action === 'delete') {
} else {
record.status = user.status;
record.role = user.role;
}
setUsers(newUsers);
} else {
showError(message);
}
};
const searchUsers = async (
startIdx,
pageSize,
searchKeyword,
searchGroup,
) => {
if (searchKeyword === '' && searchGroup === '') {
// if keyword is blank, load files instead.
await loadUsers(startIdx, pageSize);
return;
}
setSearching(true);
const res = await API.get(
`/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`,
);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setUserCount(data.total);
setUserFormat(newPageData);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const handlePageChange = (page) => {
setActivePage(page);
if (searchKeyword === '' && searchGroup === '') {
loadUsers(page, pageSize).then();
} else {
searchUsers(page, pageSize, searchKeyword, searchGroup).then();
}
};
const closeAddUser = () => {
setShowAddUser(false);
};
const closeEditUser = () => {
setShowEditUser(false);
setEditingUser({
id: undefined,
});
};
const refresh = async () => {
setActivePage(1);
if (searchKeyword === '') {
await loadUsers(activePage, pageSize);
} else {
await searchUsers(activePage, pageSize, searchKeyword, searchGroup);
}
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
// add 'all' option
// res.data.data.unshift('all');
if (res === undefined) {
return;
}
setGroupOptions(
res.data.data.map((group) => ({
label: group,
value: group,
})),
);
} catch (error) {
showError(error.message);
}
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('page-size', size + '');
setPageSize(size);
setActivePage(1);
loadUsers(activePage, size)
.then()
.catch((reason) => {
showError(reason);
});
};
const handleRow = (record, index) => {
if (record.DeletedAt !== null || record.status !== 1) {
return {
style: {
background: 'var(--semi-color-disabled-border)',
},
};
} else {
return {};
}
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<IconUserAdd className="mr-2" />
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
</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
theme='light'
type='primary'
icon={<IconPlus />}
className="!rounded-full w-full md:w-auto"
onClick={() => {
setShowAddUser(true);
}}
>
{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">
<div className="relative w-full md:w-64">
<Input
prefix={<IconSearch />}
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
</div>
<div className="w-full md:w-48">
<Select
placeholder={t('选择分组')}
optionList={groupOptions}
value={searchGroup}
onChange={(value) => {
setSearchGroup(value);
searchUsers(activePage, pageSize, searchKeyword, value);
}}
className="!rounded-full w-full"
showClear
/>
</div>
<Button
type="primary"
onClick={() => {
searchUsers(activePage, pageSize, searchKeyword, searchGroup);
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</div>
</div>
);
return (
<>
<AddUser
refresh={refresh}
visible={showAddUser}
handleClose={closeAddUser}
></AddUser>
<EditUser
refresh={refresh}
visible={showEditUser}
handleClose={closeEditUser}
editingUser={editingUser}
></EditUser>
<Card
className="!rounded-2xl overflow-hidden"
title={renderHeader()}
shadows='hover'
>
<Table
columns={columns}
dataSource={users}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: userCount,
}),
currentPage: activePage,
pageSize: pageSize,
total: userCount,
pageSizeOpts: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
},
onPageChange: handlePageChange,
}}
loading={loading}
onRow={handleRow}
className="rounded-xl overflow-hidden"
size="middle"
/>
</Card>
</>
);
};
export default UsersTable;