新增功能:用量可视化、操作日志页面优化、CSV导出

- RechargeCard 增加额度使用进度条与实时 RPM/TPM 展示(5列网格布局)
- 统一操作日志页面布局风格,与其他控制台页面一致
- 充值账单与使用日志均支持 CSV 导出
- 新增 helpers/export.js 导出工具函数
This commit is contained in:
wangxiaoji 2026-07-01 15:43:41 +08:00
parent 6a210b9667
commit 006e723ac5
7 changed files with 345 additions and 19 deletions

76
web/deploy.py vendored Normal file
View File

@ -0,0 +1,76 @@
import paramiko
import os
import sys
host = "26.0.12.13"
user = "root"
password = "root@1234"
local_dist = r"C:\Users\wangxj\tokenFactory\web\dist"
remote_dist = "/root/tokenFactory/web/dist"
print("Connecting...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(host, username=user, password=password)
# Clean old dist files AND rebuild Go
print("Cleaning old dist and rebuilding...")
stdin, stdout, stderr = ssh.exec_command(
"rm -rf /root/tokenFactory/web/dist && mkdir -p /root/tokenFactory/web/dist"
)
stdout.read()
sftp = ssh.open_sftp()
uploaded = 0
for root, dirs, files in os.walk(local_dist):
rel_path = os.path.relpath(root, local_dist)
if rel_path == ".":
remote_dir = remote_dist
else:
remote_dir = remote_dist + "/" + rel_path.replace("\\", "/")
stdin, stdout, stderr = ssh.exec_command(f"mkdir -p {remote_dir}")
stdout.read()
for f in files:
local_file = os.path.join(root, f)
remote_file = (remote_dir + "/" + f).replace("\\", "/")
try:
sftp.put(local_file, remote_file)
uploaded += 1
if uploaded % 30 == 0:
print(f" Uploaded {uploaded} files...")
except Exception as e:
print(f" FAILED: {f}: {e}")
sftp.close()
print(f"Uploaded {uploaded} files.")
# Rebuild Go
print("Building Go...")
stdin, stdout, stderr = ssh.exec_command(
"export PATH=$PATH:/usr/local/go/bin && cd /root/tokenFactory && go build -o tf . 2>&1"
)
out = stdout.read().decode()
err = stderr.read().decode()
if out.strip():
print("stdout:", out[-300:].strip())
if err.strip():
print("stderr:", err[-300:].strip())
# Replace binary file (not in a subdirectory — Docker mounts ./token-factory as a file)
print("Replacing binary...")
stdin, stdout, stderr = ssh.exec_command(
"rm -rf /root/tokenFactory/token-factory && cp /root/tokenFactory/tf /root/tokenFactory/token-factory && ls -la /root/tokenFactory/token-factory"
)
print(stdout.read().decode().strip())
# Docker restart
print("Restarting Docker...")
stdin, stdout, stderr = ssh.exec_command(
"cd /root/tokenFactory && docker compose down 2>&1 && docker compose up -d 2>&1"
)
print(stdout.read().decode()[-500:].strip())
ssh.close()
print("Done!")

View File

@ -18,10 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React from 'react';
import { Tag, Space, Skeleton } from '@douyinfe/semi-ui'; import { Tag, Space, Skeleton, Button } from '@douyinfe/semi-ui';
import { renderQuota } from '../../../helpers'; import { renderQuota } from '../../../helpers';
import CompactModeToggle from '../../common/ui/CompactModeToggle'; import CompactModeToggle from '../../common/ui/CompactModeToggle';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
import { Download } from 'lucide-react';
const LogsActions = ({ const LogsActions = ({
stat, stat,
@ -29,6 +30,8 @@ const LogsActions = ({
showStat, showStat,
compactMode, compactMode,
setCompactMode, setCompactMode,
exporting,
handleExportLogs,
t, t,
}) => { }) => {
const showSkeleton = useMinimumLoadingTime(loadingStat); const showSkeleton = useMinimumLoadingTime(loadingStat);
@ -83,11 +86,23 @@ const LogsActions = ({
</Space> </Space>
</Skeleton> </Skeleton>
<Space>
<Button
icon={<Download size={14} />}
size='small'
theme='outline'
type='tertiary'
loading={exporting}
onClick={handleExportLogs}
>
{t('导出 CSV')}
</Button>
<CompactModeToggle <CompactModeToggle
compactMode={compactMode} compactMode={compactMode}
setCompactMode={setCompactMode} setCompactMode={setCompactMode}
t={t} t={t}
/> />
</Space>
</div> </div>
); );
}; };

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { import {
Typography, Typography,
Tag, Tag,
@ -9,6 +9,7 @@ import {
Tooltip, Tooltip,
Input, Input,
InputNumber, InputNumber,
Progress,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'; import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
import { import {
@ -21,7 +22,10 @@ import {
DollarSign, DollarSign,
Ticket, Ticket,
ChevronRight, ChevronRight,
Gauge,
TrendingUp,
} from 'lucide-react'; } from 'lucide-react';
import { API } from '../../helpers';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime'; import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { getCurrencyConfig } from '../../helpers/render'; import { getCurrencyConfig } from '../../helpers/render';
import SubscriptionPlansCard from './SubscriptionPlansCard'; import SubscriptionPlansCard from './SubscriptionPlansCard';
@ -80,6 +84,7 @@ const RechargeCard = ({
}) => { }) => {
const [activeTab, setActiveTab] = useState('topup'); const [activeTab, setActiveTab] = useState('topup');
const [customAmount, setCustomAmount] = useState(10); const [customAmount, setCustomAmount] = useState(10);
const [rpmTpmData, setRpmTpmData] = useState(null);
const showAmountSkeleton = useMinimumLoadingTime(amountLoading); const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
const shouldShowSubscription = const shouldShowSubscription =
!subscriptionLoading && subscriptionPlans.length > 0; !subscriptionLoading && subscriptionPlans.length > 0;
@ -133,6 +138,26 @@ const RechargeCard = ({
} }
}, [shouldShowSubscription, subscriptionLoading]); }, [shouldShowSubscription, subscriptionLoading]);
const fetchRpmTpm = useCallback(async () => {
try {
const now = Math.floor(Date.now() / 1000);
const res = await API.get(
`/api/log/self/stat?start_timestamp=${now - 3600}&end_timestamp=${now}`,
);
if (res?.data?.success) {
setRpmTpmData(res.data.data);
}
} catch {
//
}
}, []);
useEffect(() => {
fetchRpmTpm();
const interval = setInterval(fetchRpmTpm, 30000);
return () => clearInterval(interval);
}, [fetchRpmTpm]);
const isAnyPayEnabled = const isAnyPayEnabled =
enableOnlineTopUp || enableOnlineTopUp ||
enableStripeTopUp || enableStripeTopUp ||
@ -146,6 +171,9 @@ const RechargeCard = ({
{ key: 'subscription', label: t('订阅'), desc: t('享更低单价') }, { key: 'subscription', label: t('订阅'), desc: t('享更低单价') },
]; ];
const totalQuota = (userState?.user?.used_quota || 0) + (userState?.user?.quota || 0);
const usagePercent = totalQuota > 0 ? Math.round(((userState?.user?.used_quota || 0) / totalQuota) * 100) : 0;
// //
const statCards = [ const statCards = [
{ {
@ -153,7 +181,7 @@ const RechargeCard = ({
icon: WalletCards, icon: WalletCards,
label: t('当前余额'), label: t('当前余额'),
value: userState?.user?.quota, value: userState?.user?.quota,
sublabel: t('剩余配额'), sublabel: `${t('已用')} ${usagePercent}%`,
highlight: true, highlight: true,
}, },
{ {
@ -171,6 +199,24 @@ const RechargeCard = ({
sublabel: t('总请求数'), sublabel: t('总请求数'),
raw: true, raw: true,
}, },
{
key: 'rpm',
icon: Gauge,
label: t('RPM'),
value: rpmTpmData?.rpm,
sublabel: t('最近1分钟'),
raw: true,
live: true,
},
{
key: 'tpm',
icon: TrendingUp,
label: t('TPM'),
value: rpmTpmData?.tpm,
sublabel: t('最近1分钟'),
raw: true,
live: true,
},
]; ];
const topupContent = ( const topupContent = (
@ -456,12 +502,12 @@ const RechargeCard = ({
return ( return (
<div className='flex w-full flex-col gap-5'> <div className='flex w-full flex-col gap-5'>
{/* 统计卡片 */} {/* 统计卡片 */}
<div className='grid grid-cols-3 gap-0 overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.02]'> <div className='grid grid-cols-3 md:grid-cols-5 gap-0 overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.02]'>
{statCards.map((card, i) => ( {statCards.map((card, i) => (
<div <div
key={card.key} key={card.key}
className={`relative flex flex-col px-5 py-4 ${ className={`relative flex flex-col px-4 py-4 ${
i < 2 ? 'border-r border-slate-100 dark:border-white/5' : '' i < 4 ? 'border-r border-slate-100 dark:border-white/5' : ''
}`} }`}
> >
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
@ -469,24 +515,31 @@ const RechargeCard = ({
className={`h-3.5 w-3.5 ${ className={`h-3.5 w-3.5 ${
card.highlight card.highlight
? 'text-emerald-500 dark:text-emerald-400' ? 'text-emerald-500 dark:text-emerald-400'
: card.live
? 'text-blue-500 dark:text-blue-400'
: 'text-slate-400 dark:text-white/40' : 'text-slate-400 dark:text-white/40'
}`} }`}
/> />
<span className='text-xs font-medium text-slate-400 dark:text-white/40'> <span className='text-xs font-medium text-slate-400 dark:text-white/40'>
{card.label} {card.label}
{card.live && (
<span className='ml-1 inline-block h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse' />
)}
</span> </span>
</div> </div>
<div <div
className={`mt-2 font-mono text-2xl font-bold tabular-nums ${ className={`mt-2 font-mono text-2xl font-bold tabular-nums ${
card.highlight card.highlight
? 'text-emerald-600 dark:text-emerald-400' ? 'text-emerald-600 dark:text-emerald-400'
: card.live
? 'text-blue-600 dark:text-blue-400'
: 'text-slate-900 dark:text-white' : 'text-slate-900 dark:text-white'
}`} }`}
> >
{statusLoading ? ( {statusLoading && !card.live ? (
<Skeleton.Title style={{ width: 60, height: 28, margin: 0 }} /> <Skeleton.Title style={{ width: 60, height: 28, margin: 0 }} />
) : card.raw ? ( ) : card.raw ? (
card.value card.value != null ? card.value : '—'
) : ( ) : (
renderQuota(card.value) renderQuota(card.value)
)} )}
@ -494,6 +547,17 @@ const RechargeCard = ({
<div className='mt-1 text-xs text-slate-400 dark:text-white/40'> <div className='mt-1 text-xs text-slate-400 dark:text-white/40'>
{card.sublabel} {card.sublabel}
</div> </div>
{card.key === 'balance' && (
<div className='mt-3'>
<Progress
percent={usagePercent}
strokeWidth={6}
stroke='var(--semi-color-success)'
aria-label='usage percent'
showInfo={false}
/>
</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@ -28,15 +28,17 @@ import {
Input, Input,
Tag, Tag,
Select, Select,
Space,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
IllustrationNoResult, IllustrationNoResult,
IllustrationNoResultDark, IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations'; } from '@douyinfe/semi-illustrations';
import { Coins } from 'lucide-react'; import { Coins, Download } from 'lucide-react';
import { IconSearch } from '@douyinfe/semi-icons'; import { IconSearch } from '@douyinfe/semi-icons';
import { API, timestamp2string } from '../../../helpers'; import { API, timestamp2string } from '../../../helpers';
import { isAdmin } from '../../../helpers/utils'; import { isAdmin } from '../../../helpers/utils';
import { exportCSV } from '../../../helpers/export';
import { useIsMobile } from '../../../hooks/common/useIsMobile'; import { useIsMobile } from '../../../hooks/common/useIsMobile';
const { Text } = Typography; const { Text } = Typography;
@ -202,6 +204,45 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
} }
}; };
const [exporting, setExporting] = useState(false);
const handleExport = async () => {
setExporting(true);
try {
const adminNow = isAdmin();
const base = adminNow ? '/api/user/topup' : '/api/user/topup/self';
const tn = tradeNoFilter.trim();
const un = usernameFilter.trim();
const params = { p: 1, page_size: 10000 };
if (tn) params.trade_no = tn;
if (adminNow && un) params.username = un;
if (statusFilter && statusFilter !== TOPUP_STATUS_ALL) {
params.status = statusFilter;
}
const res = await API.get(base, { params, disableDuplicate: true });
const { success, data } = res.data;
if (success && data?.items?.length) {
const columns = [
{ title: t('订单号'), dataIndex: 'trade_no' },
{ title: t('用户名'), dataIndex: 'username' },
{ title: t('支付方式'), dataIndex: 'payment_method' },
{ title: t('充值额度'), dataIndex: 'amount' },
{ title: t('支付金额'), dataIndex: 'money' },
{ title: t('状态'), dataIndex: 'status' },
{ title: t('创建时间'), dataIndex: 'create_time', render: (v) => v ? timestamp2string(v) : '' },
];
exportCSV(data.items, columns, `topup-records-${new Date().toISOString().slice(0, 10)}`);
Toast.success({ content: t('导出成功') });
} else {
Toast.info({ content: t('无数据可导出') });
}
} catch {
Toast.error({ content: t('导出失败') });
} finally {
setExporting(false);
}
};
const confirmAdminComplete = (tradeNo) => { const confirmAdminComplete = (tradeNo) => {
Modal.confirm({ Modal.confirm({
title: t('确认补单'), title: t('确认补单'),
@ -332,6 +373,18 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
> >
{/* 筛选条件单行排列;宽度不足时横向滚动,避免折成两行 */} {/* 筛选条件单行排列;宽度不足时横向滚动,避免折成两行 */}
<div className='mb-3 flex w-full flex-row flex-nowrap items-center gap-2 overflow-x-auto'> <div className='mb-3 flex w-full flex-row flex-nowrap items-center gap-2 overflow-x-auto'>
<div className='shrink-0'>
<Button
icon={<Download size={14} />}
size='small'
theme='outline'
type='tertiary'
loading={exporting}
onClick={handleExport}
>
{t('导出 CSV')}
</Button>
</div>
{userIsAdmin ? ( {userIsAdmin ? (
<Input <Input
className='min-w-[104px] flex-1' className='min-w-[104px] flex-1'

56
web/src/helpers/export.js vendored Normal file
View File

@ -0,0 +1,56 @@
/**
* 导出数据为 CSV 文件并触发浏览器下载
* @param {Array<object>} rows 数据行
* @param {Array<{title: string, dataIndex: string, render?: function}>} columns 列定义
* @param {string} filename 文件名不含扩展名
*/
export function exportCSV(rows, columns, filename = 'export') {
if (!rows || !rows.length) return;
const BOM = '';
const header = columns.map((col) => `"${(col.title || col.dataIndex).replace(/"/g, '""')}"`).join(',');
const body = rows
.map((row) =>
columns
.map((col) => {
let val = row[col.dataIndex];
if (typeof col.render === 'function') {
// render 函数返回 React 元素时提取文本
const rendered = col.render(val, row);
if (rendered == null || rendered === '') return '""';
if (typeof rendered === 'string' || typeof rendered === 'number') {
val = String(rendered).replace(/"/g, '""');
} else if (rendered && typeof rendered === 'object' && rendered.props) {
// 简单提取 React 元素文本
val = extractText(rendered).replace(/"/g, '""');
} else {
val = String(rendered).replace(/"/g, '""');
}
} else {
val = val != null ? String(val).replace(/"/g, '""') : '';
}
return `"${val}"`;
})
.join(','),
)
.join('\n');
const csv = BOM + header + '\n' + body;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function extractText(element) {
if (element == null) return '';
if (typeof element === 'string' || typeof element === 'number') return String(element);
if (Array.isArray(element)) return element.map(extractText).join('');
if (element.props && element.props.children) return extractText(element.props.children);
return '';
}

View File

@ -17,9 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Modal, Tag } from '@douyinfe/semi-ui'; import { Modal, Tag, Toast } from '@douyinfe/semi-ui';
import { import {
API, API,
getTodayStartTimestamp, getTodayStartTimestamp,
@ -38,6 +38,7 @@ import {
} from '../../helpers'; } from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode'; import { useTableCompactMode } from '../common/useTableCompactMode';
import { exportCSV } from '../../helpers/export';
import ParamOverrideEntry from '../../components/table/usage-logs/components/ParamOverrideEntry'; import ParamOverrideEntry from '../../components/table/usage-logs/components/ParamOverrideEntry';
export const useLogsData = () => { export const useLogsData = () => {
@ -550,6 +551,7 @@ export const useLogsData = () => {
visible: false, visible: false,
record: null, record: null,
}); });
const [exporting, setExporting] = useState(false);
// Initialize default column visibility // Initialize default column visibility
const initDefaultColumns = () => { const initDefaultColumns = () => {
@ -1307,6 +1309,60 @@ export const useLogsData = () => {
await loadLogs(1, pageSize); await loadLogs(1, pageSize);
}; };
const handleExportLogs = useCallback(async () => {
setExporting(true);
try {
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
group,
request_id,
logType: formLogType,
} = getFormValues();
const currentLogType =
formLogType !== undefined ? formLogType : logType;
const localStartTimestamp = Date.parse(start_timestamp) / 1000;
const localEndTimestamp = Date.parse(end_timestamp) / 1000;
let url;
if (isAdminUser) {
url = `/api/log/?p=1&page_size=10000&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}&request_id=${request_id}`;
} else {
url = `/api/log/self/?p=1&page_size=10000&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}&request_id=${request_id}`;
}
url = encodeURI(url);
const res = await API.get(url);
const { success, data } = res.data;
if (success && data?.items?.length) {
const typeMap = { 0: t('未知'), 1: t('充值'), 2: t('消费'), 3: t('管理'), 4: t('系统'), 5: t('错误'), 6: t('退款') };
const columns = [
{ title: t('时间'), dataIndex: 'created_at', render: (v) => v ? timestamp2string(v) : '' },
{ title: t('渠道'), dataIndex: 'channel' },
{ title: t('用户名'), dataIndex: 'username' },
{ title: t('令牌'), dataIndex: 'token_name' },
{ title: t('分组'), dataIndex: 'group' },
{ title: t('类型'), dataIndex: 'type', render: (v) => typeMap[v] || t('未知') },
{ title: t('模型'), dataIndex: 'model_name' },
{ title: t('输入Tokens'), dataIndex: 'prompt_tokens' },
{ title: t('输出Tokens'), dataIndex: 'completion_tokens' },
{ title: t('花费'), dataIndex: 'quota', render: (v) => renderQuota(v) },
{ title: t('IP'), dataIndex: 'ip' },
];
exportCSV(data.items, columns, `usage-logs-${new Date().toISOString().slice(0, 10)}`);
Toast.success({ content: t('导出成功') });
} else {
Toast.info({ content: t('无数据可导出') });
}
} catch {
Toast.error({ content: t('导出失败') });
} finally {
setExporting(false);
}
}, [getFormValues, logType, isAdminUser, t]);
// Copy text function // Copy text function
const copyText = async (e, text) => { const copyText = async (e, text) => {
e.stopPropagation(); e.stopPropagation();
@ -1408,6 +1464,10 @@ export const useLogsData = () => {
setLogType, setLogType,
openParamOverrideModal, openParamOverrideModal,
// Export
exporting,
handleExportLogs,
// Translation // Translation
t, t,
}; };

View File

@ -239,7 +239,8 @@ const OperationLog = () => {
]; ];
return ( return (
<div style={{ padding: '20px' }}> <div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<Card> <Card>
<div <div
style={{ style={{
@ -402,6 +403,7 @@ const OperationLog = () => {
)} )}
</Modal> </Modal>
</div> </div>
</div>
); );
}; };