diff --git a/web/deploy.py b/web/deploy.py
new file mode 100644
index 0000000..8642189
--- /dev/null
+++ b/web/deploy.py
@@ -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!")
diff --git a/web/src/components/table/usage-logs/UsageLogsActions.jsx b/web/src/components/table/usage-logs/UsageLogsActions.jsx
index d921483..32952df 100644
--- a/web/src/components/table/usage-logs/UsageLogsActions.jsx
+++ b/web/src/components/table/usage-logs/UsageLogsActions.jsx
@@ -18,10 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
*/
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 CompactModeToggle from '../../common/ui/CompactModeToggle';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
+import { Download } from 'lucide-react';
const LogsActions = ({
stat,
@@ -29,6 +30,8 @@ const LogsActions = ({
showStat,
compactMode,
setCompactMode,
+ exporting,
+ handleExportLogs,
t,
}) => {
const showSkeleton = useMinimumLoadingTime(loadingStat);
@@ -83,11 +86,23 @@ const LogsActions = ({
-
+
+ }
+ size='small'
+ theme='outline'
+ type='tertiary'
+ loading={exporting}
+ onClick={handleExportLogs}
+ >
+ {t('导出 CSV')}
+
+
+
);
};
diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx
index 05e1743..60d9594 100644
--- a/web/src/components/topup/RechargeCard.jsx
+++ b/web/src/components/topup/RechargeCard.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useCallback } from 'react';
import {
Typography,
Tag,
@@ -9,6 +9,7 @@ import {
Tooltip,
Input,
InputNumber,
+ Progress,
} from '@douyinfe/semi-ui';
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
import {
@@ -21,7 +22,10 @@ import {
DollarSign,
Ticket,
ChevronRight,
+ Gauge,
+ TrendingUp,
} from 'lucide-react';
+import { API } from '../../helpers';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { getCurrencyConfig } from '../../helpers/render';
import SubscriptionPlansCard from './SubscriptionPlansCard';
@@ -80,6 +84,7 @@ const RechargeCard = ({
}) => {
const [activeTab, setActiveTab] = useState('topup');
const [customAmount, setCustomAmount] = useState(10);
+ const [rpmTpmData, setRpmTpmData] = useState(null);
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
const shouldShowSubscription =
!subscriptionLoading && subscriptionPlans.length > 0;
@@ -133,6 +138,26 @@ const RechargeCard = ({
}
}, [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 =
enableOnlineTopUp ||
enableStripeTopUp ||
@@ -146,6 +171,9 @@ const RechargeCard = ({
{ 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 = [
{
@@ -153,7 +181,7 @@ const RechargeCard = ({
icon: WalletCards,
label: t('当前余额'),
value: userState?.user?.quota,
- sublabel: t('剩余配额'),
+ sublabel: `${t('已用')} ${usagePercent}%`,
highlight: true,
},
{
@@ -171,6 +199,24 @@ const RechargeCard = ({
sublabel: t('总请求数'),
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 = (
@@ -456,12 +502,12 @@ const RechargeCard = ({
return (
{/* 统计卡片 */}
-
+
{statCards.map((card, i) => (
@@ -469,24 +515,31 @@ const RechargeCard = ({
className={`h-3.5 w-3.5 ${
card.highlight
? 'text-emerald-500 dark:text-emerald-400'
- : 'text-slate-400 dark:text-white/40'
+ : card.live
+ ? 'text-blue-500 dark:text-blue-400'
+ : 'text-slate-400 dark:text-white/40'
}`}
/>
{card.label}
+ {card.live && (
+
+ )}
- {statusLoading ? (
+ {statusLoading && !card.live ? (
) : card.raw ? (
- card.value
+ card.value != null ? card.value : '—'
) : (
renderQuota(card.value)
)}
@@ -494,6 +547,17 @@ const RechargeCard = ({
{card.sublabel}
+ {card.key === 'balance' && (
+
+ )}
))}
diff --git a/web/src/components/topup/modals/TopupHistoryModal.jsx b/web/src/components/topup/modals/TopupHistoryModal.jsx
index 3e37c22..b994d26 100644
--- a/web/src/components/topup/modals/TopupHistoryModal.jsx
+++ b/web/src/components/topup/modals/TopupHistoryModal.jsx
@@ -28,15 +28,17 @@ import {
Input,
Tag,
Select,
+ Space,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
-import { Coins } from 'lucide-react';
+import { Coins, Download } from 'lucide-react';
import { IconSearch } from '@douyinfe/semi-icons';
import { API, timestamp2string } from '../../../helpers';
import { isAdmin } from '../../../helpers/utils';
+import { exportCSV } from '../../../helpers/export';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
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) => {
Modal.confirm({
title: t('确认补单'),
@@ -332,6 +373,18 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
>
{/* 筛选条件单行排列;宽度不足时横向滚动,避免折成两行 */}
+
+ }
+ size='small'
+ theme='outline'
+ type='tertiary'
+ loading={exporting}
+ onClick={handleExport}
+ >
+ {t('导出 CSV')}
+
+
{userIsAdmin ? (
} 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 '';
+}
diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx
index fab58b7..775d396 100644
--- a/web/src/hooks/usage-logs/useUsageLogsData.jsx
+++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx
@@ -17,9 +17,9 @@ along with this program. If not, see
.
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 { Modal, Tag } from '@douyinfe/semi-ui';
+import { Modal, Tag, Toast } from '@douyinfe/semi-ui';
import {
API,
getTodayStartTimestamp,
@@ -38,6 +38,7 @@ import {
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
+import { exportCSV } from '../../helpers/export';
import ParamOverrideEntry from '../../components/table/usage-logs/components/ParamOverrideEntry';
export const useLogsData = () => {
@@ -550,6 +551,7 @@ export const useLogsData = () => {
visible: false,
record: null,
});
+ const [exporting, setExporting] = useState(false);
// Initialize default column visibility
const initDefaultColumns = () => {
@@ -1307,6 +1309,60 @@ export const useLogsData = () => {
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
const copyText = async (e, text) => {
e.stopPropagation();
@@ -1408,6 +1464,10 @@ export const useLogsData = () => {
setLogType,
openParamOverrideModal,
+ // Export
+ exporting,
+ handleExportLogs,
+
// Translation
t,
};
diff --git a/web/src/pages/OperationLog/index.jsx b/web/src/pages/OperationLog/index.jsx
index 13d54dd..7f924e1 100644
--- a/web/src/pages/OperationLog/index.jsx
+++ b/web/src/pages/OperationLog/index.jsx
@@ -239,7 +239,8 @@ const OperationLog = () => {
];
return (
-