From 006e723ac5344eebe80cf6466972604cb54e4d81 Mon Sep 17 00:00:00 2001 From: wxj Date: Wed, 1 Jul 2026 15:43:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E5=8F=AF=E8=A7=86=E5=8C=96=E3=80=81=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=97=A5=E5=BF=97=E9=A1=B5=E9=9D=A2=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E3=80=81CSV=E5=AF=BC=E5=87=BA=20-=20RechargeCard=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E9=A2=9D=E5=BA=A6=E4=BD=BF=E7=94=A8=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E6=9D=A1=E4=B8=8E=E5=AE=9E=E6=97=B6=20RPM/TPM=20=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=EF=BC=885=E5=88=97=E7=BD=91=E6=A0=BC=E5=B8=83?= =?UTF-8?q?=E5=B1=80=EF=BC=89=20-=20=E7=BB=9F=E4=B8=80=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E9=A1=B5=E9=9D=A2=E5=B8=83=E5=B1=80=E9=A3=8E?= =?UTF-8?q?=E6=A0=BC=EF=BC=8C=E4=B8=8E=E5=85=B6=E4=BB=96=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=8F=B0=E9=A1=B5=E9=9D=A2=E4=B8=80=E8=87=B4=20-=20=E5=85=85?= =?UTF-8?q?=E5=80=BC=E8=B4=A6=E5=8D=95=E4=B8=8E=E4=BD=BF=E7=94=A8=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E5=9D=87=E6=94=AF=E6=8C=81=20CSV=20=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=20-=20=E6=96=B0=E5=A2=9E=20helpers/export.js=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/deploy.py | 76 +++++++++++++++++ .../table/usage-logs/UsageLogsActions.jsx | 27 ++++-- web/src/components/topup/RechargeCard.jsx | 82 +++++++++++++++++-- .../topup/modals/TopupHistoryModal.jsx | 55 ++++++++++++- web/src/helpers/export.js | 56 +++++++++++++ web/src/hooks/usage-logs/useUsageLogsData.jsx | 64 ++++++++++++++- web/src/pages/OperationLog/index.jsx | 4 +- 7 files changed, 345 insertions(+), 19 deletions(-) create mode 100644 web/deploy.py create mode 100644 web/src/helpers/export.js 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 = ({ - + + + + ); }; 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 }) => { > {/* 筛选条件单行排列;宽度不足时横向滚动,避免折成两行 */}
+
+ +
{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 ( -
+
+
{
)} +
); };