diff --git a/web/src/App.jsx b/web/src/App.jsx index 1b2dab4..b57da89 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -46,6 +46,7 @@ import ModelPage from './pages/Model'; import ModelDeploymentPage from './pages/ModelDeployment'; import ModelHeatPage from './pages/ModelHeat'; import Playground from './pages/Playground'; +import ApiTester from './pages/ApiTester'; import Subscription from './pages/Subscription'; import OAuth2Callback from './components/auth/OAuth2Callback'; import PersonalSetting from './components/settings/PersonalSetting'; @@ -58,6 +59,10 @@ import Suppliers from './pages/SupplierAdmin/list'; import Setup from './pages/Setup'; import SetupCheck from './components/layout/SetupCheck'; import OperationLog from './pages/OperationLog'; +import LuckyBag from './pages/LuckyBag'; +import Drawing from './pages/Drawing'; +import ProfitDashboard from './pages/ProfitDashboard'; +import SettingsAntiAbuse from './pages/Setting/Operation/SettingsAntiAbuse'; const Home = lazy(() => import('./pages/Home')); const Dashboard = lazy(() => import('./pages/Dashboard')); @@ -177,6 +182,14 @@ function App() { } /> + + + + } + /> } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Activity, CreditCard, Hash, Layers, Gauge } from 'lucide-react'; +import { renderQuota } from '../../helpers'; + +const formatNumber = (n) => { + if (n == null || isNaN(n)) return '—'; + const v = Number(n); + if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + 'M'; + if (v >= 1_000) return (v / 1_000).toFixed(1) + 'K'; + return v.toLocaleString(); +}; + +const formatLatency = (v) => { + if (v == null) return '—'; + if (v < 1) return `${Math.round(v * 1000)}ms`; + return `${v.toFixed(2)}s`; +}; + +const BentoStat = ({ label, value, unit, sub, delta, icon: Icon, className = '' }) => { + return ( +
+
+
+ +
+
{label}
+
+ +
+
+ + {value} + + {unit && {unit}} +
+
+ +
+ {delta != null && ( +
0 + ? 'text-red-500 dark:text-red-400' + : 'text-slate-500 dark:text-white/50' + }`} + > + + {delta > 0 ? '↑' : delta < 0 ? '↓' : '·'} {Math.abs(delta).toFixed(0)}% + + 对比上月 +
+ )} + {sub && ( +
{sub}
+ )} +
+
+ ); +}; + +const BentoStats = ({ metrics, monthDelta }) => { + return ( + <> + + + {formatNumber(metrics.today.tokens)} + {' '} + tokens + · + {renderQuota(metrics.today.cost)} + + } + icon={Activity} + /> + + + + + {metrics.month.requests} + {' '} + 次调用 + · + {formatNumber(metrics.month.tokens)} tokens + + } + delta={monthDelta} + icon={CreditCard} + /> + + + ); +}; + +export default BentoStats; diff --git a/web/src/components/dashboard/BentoTrend.jsx b/web/src/components/dashboard/BentoTrend.jsx new file mode 100644 index 0000000..26752d6 --- /dev/null +++ b/web/src/components/dashboard/BentoTrend.jsx @@ -0,0 +1,136 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useMemo } from 'react'; +import { TrendingUp } from 'lucide-react'; +import Sparkline from './Sparkline'; +import { renderQuota } from '../../helpers'; + +const WEEKDAYS_SHORT = ['日', '一', '二', '三', '四', '五', '六']; + +const BentoTrend = ({ + dailyStats = [], + topModels = [], + className = '', +}) => { + const counts = useMemo(() => dailyStats.map((d) => d.count), [dailyStats]); + const costs = useMemo(() => dailyStats.map((d) => d.cost), [dailyStats]); + + const totalCalls = counts.reduce((s, v) => s + v, 0); + const totalCost = costs.reduce((s, v) => s + v, 0); + const avg = totalCalls > 0 ? (totalCalls / Math.max(counts.length, 1)).toFixed(1) : '0.0'; + const peak = counts.length > 0 ? Math.max(...counts) : 0; + const topMax = topModels[0]?.count || 0; + + return ( +
+
+
+
+ +
+
+
Token 使用趋势
+
+ 近 7 天 + {totalCalls} 次 + · 日均{' '} + {avg} +
+
+
+
+
+ 花费 +
+
+ {totalCost > 0 ? renderQuota(totalCost) : '—'} +
+
+
+ +
+ +
+ +
+
+ {dailyStats.map((d, i) => ( + 0 + ? 'font-semibold text-slate-900 dark:text-white' + : 'text-slate-300 dark:text-white/30' + }`} + title={`周${WEEKDAYS_SHORT[d.date.getDay()]} ${d.count} 次`} + > + {WEEKDAYS_SHORT[d.date.getDay()]} + + ))} +
+
+ 峰值{' '} + {peak} 次 +
+
+ + {topModels.length > 0 && ( +
+
+ Top 5 模型 + 近 30 日 +
+
    + {topModels.map((m, i) => { + const pct = topMax > 0 ? (m.count / topMax) * 100 : 0; + return ( +
  • + + {i + 1} + + + {m.name} + +
    +
    +
    + + {m.count} + +
  • + ); + })} +
+
+ )} +
+ ); +}; + +export default BentoTrend; diff --git a/web/src/components/dashboard/Hero.jsx b/web/src/components/dashboard/Hero.jsx new file mode 100644 index 0000000..1599d53 --- /dev/null +++ b/web/src/components/dashboard/Hero.jsx @@ -0,0 +1,56 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useMemo } from 'react'; + +const getGreeting = () => { + const h = new Date().getHours(); + if (h < 5) return '深夜好'; + if (h < 12) return '早上好'; + if (h < 14) return '中午好'; + if (h < 18) return '下午好'; + return '晚上好'; +}; + +const Hero = ({ user }) => { + const dateStr = useMemo(() => { + const now = new Date(); + const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + return `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 · ${weekdays[now.getDay()]}`; + }, []); + + return ( +
+
+
+ + {dateStr} +
+

+ {getGreeting()}, + + {user?.username || '访客'} + +

+
+
+ ); +}; + +export default Hero; \ No newline at end of file diff --git a/web/src/components/dashboard/MonthlyForecast.jsx b/web/src/components/dashboard/MonthlyForecast.jsx new file mode 100644 index 0000000..7c0421f --- /dev/null +++ b/web/src/components/dashboard/MonthlyForecast.jsx @@ -0,0 +1,130 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Receipt, TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import { renderQuota } from '../../helpers'; + +const SubStat = ({ label, value, hint }) => ( +
+
+ {label} +
+
+ {value} +
+ {hint && ( +
+ {hint} +
+ )} +
+); + +const MonthlyForecast = ({ forecast = {}, className = '' }) => { + const { + spent = 0, + balance = 0, + dailyAvg = 0, + daysInMonth = 0, + daysElapsed = 0, + projected = 0, + monthDelta = null, + } = forecast; + + const daysRemaining = Math.max(0, daysInMonth - daysElapsed); + const hasData = spent > 0; + const overBudget = hasData && balance > 0 && projected > balance; + + const DeltaIcon = + monthDelta == null ? Minus : monthDelta > 0 ? TrendingUp : TrendingDown; + const deltaTone = + monthDelta == null + ? 'text-slate-500 dark:text-white/50' + : monthDelta > 5 + ? 'text-red-500 dark:text-red-400' + : monthDelta < -5 + ? 'text-emerald-600 dark:text-emerald-400' + : 'text-slate-500 dark:text-white/50'; + + return ( +
+
+
+ +
+
+
+ 本月账单预测 +
+
+ 已过 {daysElapsed} / {daysInMonth} 天 · 剩 {daysRemaining} 天 +
+
+
+ +
+
+ 月底预计 +
+
+ {hasData ? renderQuota(projected) : '—'} +
+ {monthDelta != null && ( +
+ + + {monthDelta > 0 ? '+' : ''} + {monthDelta.toFixed(0)}% + + 较上月 +
+ )} +
+ +
+ + + 0 ? renderQuota(balance) : '—'} + hint={overBudget ? '预计不足' : '可继续使用'} + /> +
+
+ ); +}; + +export default MonthlyForecast; diff --git a/web/src/components/dashboard/RecentActivity.jsx b/web/src/components/dashboard/RecentActivity.jsx new file mode 100644 index 0000000..2703738 --- /dev/null +++ b/web/src/components/dashboard/RecentActivity.jsx @@ -0,0 +1,137 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useMemo } from 'react'; +import { ArrowUpRight, Clock, Inbox } from 'lucide-react'; +import { renderQuota, renderNumber, timestamp2string } from '../../helpers'; + +const isSuccess = (l) => + l?.type === 0 || l?.status === 0 || l?.type === 'success' || l?.type === undefined; + +const RecentActivity = ({ logs = [], className = '' }) => { + const recent = useMemo(() => logs.slice(0, 10), [logs]); + + return ( +
+
+
+
+ +
+
+
最近调用
+
+ 共{' '} + + {recent.length} + {' '} + 条记录 +
+
+
+ + 查看全部 + +
+ + {recent.length === 0 ? ( +
+
+ +
+

暂无使用记录

+

+ 开始使用 API 后,您的使用历史将显示在这里 +

+
+ ) : ( +
+ + + + + + + + + + + + {recent.map((l) => { + const ok = isSuccess(l); + return ( + + + + + + + + ); + })} + +
+ time + + model + + tokens + + cost + + status +
+ {timestamp2string(l.created_at).slice(5, 16)} + + {l.model_name || '—'} + + {renderNumber(l.token_used || 0)} + + {renderQuota(l.quota || 0)} + + + + {ok ? '成功' : '失败'} + +
+
+ )} +
+ ); +}; + +export default RecentActivity; diff --git a/web/src/components/dashboard/Sparkline.jsx b/web/src/components/dashboard/Sparkline.jsx new file mode 100644 index 0000000..1b7929a --- /dev/null +++ b/web/src/components/dashboard/Sparkline.jsx @@ -0,0 +1,70 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; + +const Sparkline = ({ + data = [], + width = 200, + height = 40, + stroke = 'currentColor', + className = '', +}) => { + if (!data || data.length === 0) return null; + const max = Math.max(...data, 1); + const step = data.length > 1 ? width / (data.length - 1) : 0; + const points = data + .map((v, i) => { + const x = i * step; + const y = height - (v / max) * (height - 4) - 2; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); + + const areaPoints = `0,${height} ${points} ${width},${height}`; + + return ( + + + + + + + + + + + ); +}; + +export default Sparkline; diff --git a/web/src/components/dashboard/TopBar.jsx b/web/src/components/dashboard/TopBar.jsx new file mode 100644 index 0000000..2b3aceb --- /dev/null +++ b/web/src/components/dashboard/TopBar.jsx @@ -0,0 +1,75 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Search, RefreshCw, Wallet } from 'lucide-react'; +import { renderQuota } from '../../helpers'; + +const TopBar = ({ onSearch, onRefresh, loading, user, unreadCount = 0 }) => { + const initial = (user?.username || '?').charAt(0).toUpperCase(); + const balance = Number(user?.quota || 0); + return ( +
+
+ + TokenFactory + +
+ +
+ + + + + + {renderQuota(balance)} + + +
+ {initial} + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} +
+
+
+ ); +}; + +export default TopBar; diff --git a/web/src/components/dashboard/index.jsx b/web/src/components/dashboard/index.jsx index 3f50c5f..15481c7 100644 --- a/web/src/components/dashboard/index.jsx +++ b/web/src/components/dashboard/index.jsx @@ -17,260 +17,72 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useContext, useEffect } from 'react'; -import { getRelativeTime, userIsDistributorUser } from '../../helpers'; +import React, { useContext } from 'react'; import { UserContext } from '../../context/User'; -import { StatusContext } from '../../context/Status'; - -import DashboardHeader from './DashboardHeader'; -import StatsCards from './StatsCards'; -import ChartsPanel from './ChartsPanel'; -import ApiInfoPanel from './ApiInfoPanel'; -import AnnouncementsPanel from './AnnouncementsPanel'; -import FaqPanel from './FaqPanel'; -import UptimePanel from './UptimePanel'; -import SearchModal from './modals/SearchModal'; -import DistributorAnalyticsBoard from '../distributor/DistributorAnalyticsBoard'; - +import { useUserMessageUnreadCount } from '../../hooks/common/useUserMessageUnreadCount'; import { useDashboardData } from '../../hooks/dashboard/useDashboardData'; -import { useDashboardStats } from '../../hooks/dashboard/useDashboardStats'; -import { useDashboardCharts } from '../../hooks/dashboard/useDashboardCharts'; -import { - CHART_CONFIG, - CARD_PROPS, - FLEX_CENTER_GAP2, - ILLUSTRATION_SIZE, - ANNOUNCEMENT_LEGEND_DATA, - UPTIME_STATUS_MAP, -} from '../../constants/dashboard.constants'; -import { - getTrendSpec, - handleCopyUrl, - handleSpeedTest, - getUptimeStatusColor, - getUptimeStatusText, - renderMonitorList, -} from '../../helpers/dashboard'; +import TopBar from './TopBar'; +import Hero from './Hero'; +import BentoStats from './BentoStats'; +import BentoTrend from './BentoTrend'; +import MonthlyForecast from './MonthlyForecast'; +import RecentActivity from './RecentActivity'; const Dashboard = () => { - // ========== Context ========== - const [userState, userDispatch] = useContext(UserContext); - const [statusState, statusDispatch] = useContext(StatusContext); + const [userState] = useContext(UserContext); + const user = userState?.user; - // ========== 主要数据管理 ========== - const dashboardData = useDashboardData(userState, userDispatch, statusState); - - // ========== 图表管理 ========== - const dashboardCharts = useDashboardCharts( - dashboardData.dataExportDefaultTime, - dashboardData.setTrendData, - dashboardData.setConsumeQuota, - dashboardData.setTimes, - dashboardData.setConsumeTokens, - dashboardData.setPieData, - dashboardData.setLineData, - dashboardData.setModelColors, - dashboardData.t, - ); - - // ========== 统计数据 ========== - const { groupedStatsData } = useDashboardStats( - userState, - dashboardData.consumeQuota, - dashboardData.consumeTokens, - dashboardData.times, - dashboardData.trendData, - dashboardData.performanceMetrics, - dashboardData.navigate, - dashboardData.t, - ); - - // ========== 数据处理 ========== - const initChart = async () => { - await dashboardData.loadQuotaData().then((data) => { - if (data && data.length > 0) { - dashboardCharts.updateChartData(data); - } - }); - await dashboardData.loadUptimeData(); - }; - - const handleRefresh = async () => { - const data = await dashboardData.refresh(); - if (data && data.length > 0) { - dashboardCharts.updateChartData(data); - } - }; - - const handleSearchConfirm = async () => { - await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData); - }; - - // ========== 数据准备 ========== - const apiInfoData = statusState?.status?.api_info || []; - const announcementData = (statusState?.status?.announcements || []).map( - (item) => { - const pubDate = item?.publishDate ? new Date(item.publishDate) : null; - const absoluteTime = - pubDate && !isNaN(pubDate.getTime()) - ? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}` - : item?.publishDate || ''; - const relativeTime = getRelativeTime(item.publishDate); - return { - ...item, - time: absoluteTime, - relative: relativeTime, - }; - }, - ); - const faqData = statusState?.status?.faq || []; - - const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map( - ([status, info]) => ({ - status: Number(status), - color: info.color, - label: dashboardData.t(info.label), - }), - ); - - // ========== Effects ========== - useEffect(() => { - initChart(); - }, []); + const dashboard = useDashboardData(userState); + const { unreadCount } = useUserMessageUnreadCount(user); return ( -
- +
+
+ {}} + onRefresh={dashboard.refresh} + loading={dashboard.loading} + user={user} + unreadCount={unreadCount} + /> - +
+ - - - {userIsDistributorUser(userState?.user) ? ( - - ) : null} - - {/* API信息和图表面板 */} -
-
- - - {dashboardData.hasApiInfoPanel && ( - handleCopyUrl(url, dashboardData.t)} - handleSpeedTest={handleSpeedTest} - CARD_PROPS={CARD_PROPS} - FLEX_CENTER_GAP2={FLEX_CENTER_GAP2} - ILLUSTRATION_SIZE={ILLUSTRATION_SIZE} - t={dashboardData.t} + {/* 第一行:5 个核心指标卡 */} +
+ - )} +
+ + {/* 第二行:七日用量趋势 + 本月账单预测 */} +
+ + +
+ + {/* 第三行:最近调用 */} + +
+ +
+ TokenFactory · 2026
- - {/* 系统公告和常见问答卡片 */} - {dashboardData.hasInfoPanels && ( -
-
- {/* 公告卡片 */} - {dashboardData.announcementsEnabled && ( - ({ - ...item, - label: dashboardData.t(item.label), - }), - )} - CARD_PROPS={CARD_PROPS} - ILLUSTRATION_SIZE={ILLUSTRATION_SIZE} - t={dashboardData.t} - /> - )} - - {/* 常见问答卡片 */} - {dashboardData.faqEnabled && ( - - )} - - {/* 大模型部署定制服务 / 可用性监控卡片 */} - {dashboardData.uptimeEnabled && ( - - renderMonitorList( - monitors, - (status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP), - (status) => - getUptimeStatusText( - status, - UPTIME_STATUS_MAP, - dashboardData.t, - ), - dashboardData.t, - ) - } - CARD_PROPS={CARD_PROPS} - ILLUSTRATION_SIZE={ILLUSTRATION_SIZE} - t={dashboardData.t} - /> - )} -
-
- )}
); }; -export default Dashboard; +export default Dashboard; \ No newline at end of file diff --git a/web/src/pages/ApiTester/index.jsx b/web/src/pages/ApiTester/index.jsx new file mode 100644 index 0000000..4b3e3af --- /dev/null +++ b/web/src/pages/ApiTester/index.jsx @@ -0,0 +1,665 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useMemo, useState } from 'react'; +import { + Send, + Copy, + Check, + RefreshCcw, + Loader2, + KeyRound, + Cpu, + MessageSquare, + Zap, + Eye, + EyeOff, + Globe, + Sparkles, + Bookmark, + Plus, + X, +} from 'lucide-react'; +import { Toast } from '@douyinfe/semi-ui'; +import { useTranslation } from 'react-i18next'; + +const DEFAULT_PATH = '/v1/chat/completions'; +const DEFAULT_MAX_TOKENS = 1024; + +// 推断协议格式:路径以 /v1/messages 结尾 → Anthropic Messages API +const detectFormat = (rawPath) => + /\/v1\/messages\/?$/i.test((rawPath || '').trim()) ? 'anthropic' : 'openai'; + +const CUSTOM_SCENES_KEY = 'api-tester-custom-scenes'; + +const SCENES = [ + // 本平台 + { id: 'platform-chat', group: '本平台', label: '本平台 Chat', path: '/v1/chat/completions', Icon: MessageSquare }, + { id: 'platform-embedding', group: '本平台', label: '本平台 Embedding', path: '/v1/embeddings', Icon: Zap }, + { id: 'platform-image', group: '本平台', label: '本平台 图像生成', path: '/v1/images/generations', Icon: Cpu }, + // 国产 · OpenAI 兼容 + { id: 'deepseek-openai', group: '国产 · OpenAI 兼容', label: 'DeepSeek', base: 'https://api.deepseek.com', path: '/v1/chat/completions', Icon: Sparkles }, + { id: 'qwen', group: '国产 · OpenAI 兼容', label: '通义千问 Qwen', base: 'https://dashscope.aliyuncs.com/compatible-mode', path: '/v1/chat/completions', Icon: Sparkles }, + { id: 'kimi', group: '国产 · OpenAI 兼容', label: 'Kimi 月之暗面', base: 'https://api.moonshot.cn', path: '/v1/chat/completions', Icon: Sparkles }, + { id: 'glm', group: '国产 · OpenAI 兼容', label: '智谱 GLM', base: 'https://open.bigmodel.cn/api/paas/v4', path: '/chat/completions', Icon: Sparkles }, + { id: 'doubao', group: '国产 · OpenAI 兼容', label: '豆包 Doubao', base: 'https://ark.cn-beijing.volces.com/api/v3', path: '/chat/completions', Icon: Sparkles }, + // Anthropic 兼容 + { id: 'deepseek-anthropic', group: 'Anthropic 兼容', label: 'DeepSeek · Anthropic', base: 'https://api.deepseek.com/anthropic', path: '/v1/messages', Icon: Sparkles }, + { id: 'anthropic', group: 'Anthropic 兼容', label: 'Anthropic 官方', base: 'https://api.anthropic.com', path: '/v1/messages', Icon: Sparkles }, + // 本地部署 + { id: 'ollama', group: '本地部署', label: 'Ollama', base: 'http://localhost:11434', path: '/v1/chat/completions', Icon: Cpu }, +]; + +// 清洗用户粘贴的 API Key: +// 1) 去首尾空白和引号 +// 2) 去掉 Bearer 前缀 +// 3) 找到 sk- 起点后,只保留 Key 字符(字母数字 _-),避免 "sk-xxx 备注" 这种污染 +const sanitizeKey = (raw) => { + if (!raw) return ''; + let s = String(raw).trim(); + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + s = s.slice(1, -1).trim(); + } + s = s.replace(/^Bearer\s+/i, '').trim(); + const idx = s.search(/sk-[A-Za-z0-9_-]/); + if (idx >= 0) { + return s.slice(idx).match(/sk-[A-Za-z0-9_-]+/)?.[0] || ''; + } + const first = s.split(/\s+/).find(Boolean) || ''; + return first.replace(/[^\w.\-]/g, ''); +}; + +const buildPayload = (format, model, prompt, systemPrompt, maxTokens) => { + if (format === 'anthropic') { + return { + model, + max_tokens: Number(maxTokens) || DEFAULT_MAX_TOKENS, + ...(systemPrompt ? { system: systemPrompt } : {}), + messages: [{ role: 'user', content: prompt }], + }; + } + return { + model, + messages: [ + ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []), + { role: 'user', content: prompt }, + ], + stream: false, + }; +}; + +const formatJSON = (data) => { + if (typeof data === 'string') return data; + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } +}; + +const ApiTester = () => { + const { t } = useTranslation(); + const [apiKey, setApiKey] = useState(''); + const [showKey, setShowKey] = useState(false); + const [endpointPath, setEndpointPath] = useState(DEFAULT_PATH); + const [endpointBase, setEndpointBase] = useState(''); + const [showCustomBase, setShowCustomBase] = useState(false); + const [model, setModel] = useState('deepseek-v4-pro'); + const [maxTokens, setMaxTokens] = useState(DEFAULT_MAX_TOKENS); + const [systemPrompt, setSystemPrompt] = useState(''); + const [prompt, setPrompt] = useState('用一句话介绍你自己。'); + + const [response, setResponse] = useState(''); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [elapsed, setElapsed] = useState(0); + const [copied, setCopied] = useState(false); + const [customScenes, setCustomScenes] = useState([]); + + const userRaw = useMemo(() => localStorage.getItem('user'), []); + const user = useMemo(() => { + try { + return JSON.parse(userRaw); + } catch { + return null; + } + }, [userRaw]); + + useEffect(() => { + if (user?.token) setApiKey(sanitizeKey(user.token)); + }, [user?.token]); + + const handleApiKeyChange = (e) => { + setApiKey(sanitizeKey(e.target.value)); + }; + + useEffect(() => { + const apiBase = + import.meta.env?.VITE_API_BASE || + (window.location.port === '3000' + ? `${window.location.protocol}//${window.location.hostname}:3001` + : window.location.origin); + setEndpointBase(apiBase); + }, []); + + useEffect(() => { + try { + const saved = JSON.parse(localStorage.getItem(CUSTOM_SCENES_KEY) || '[]'); + if (Array.isArray(saved)) setCustomScenes(saved); + } catch { + // 忽略解析失败 + } + }, []); + + const persistCustomScenes = (list) => { + setCustomScenes(list); + try { + localStorage.setItem(CUSTOM_SCENES_KEY, JSON.stringify(list)); + } catch { + // 忽略写入失败(quota 等) + } + }; + + const format = useMemo(() => detectFormat(endpointPath), [endpointPath]); + + const endpoint = useMemo(() => { + const base = (endpointBase || window.location.origin).replace(/\/+$/, ''); + const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`; + return `${base}${path}`; + }, [endpointBase, endpointPath]); + + const isAbsoluteEndpoint = useMemo( + () => /^https?:\/\//i.test(endpointPath), + [endpointPath], + ); + const finalEndpoint = useMemo( + () => (isAbsoluteEndpoint ? endpointPath : endpoint), + [isAbsoluteEndpoint, endpoint, endpointPath], + ); + + const applyScene = (scene) => { + setShowCustomBase(false); + setEndpointPath(scene.path); + setEndpointBase(scene.base || window.location.origin); + }; + + const saveCurrentAsCustom = () => { + const label = window.prompt('给当前目标起个名字', '我的目标'); + if (!label) return; + const trimmed = label.trim(); + if (!trimmed) return; + persistCustomScenes([ + ...customScenes, + { + id: `custom-${Date.now()}`, + label: trimmed, + base: endpointBase, + path: endpointPath, + }, + ]); + Toast.success('已保存到我的目标'); + }; + + const deleteCustomScene = (id) => { + persistCustomScenes(customScenes.filter((s) => s.id !== id)); + }; + + const applyFullUrl = (raw) => { + const trimmed = raw.trim(); + if (!trimmed) { + setEndpointPath(DEFAULT_PATH); + setEndpointBase(window.location.origin); + return; + } + const m = trimmed.match(/^(https?:\/\/[^\s/?#]+)(.*)$/i); + if (m) { + setEndpointBase(m[1]); + const tail = m[2] || DEFAULT_PATH; + setEndpointPath(tail.startsWith('/') ? tail : `/${tail}`); + } else { + setEndpointBase(window.location.origin); + setEndpointPath(trimmed.startsWith('/') ? trimmed : `/${trimmed}`); + } + }; + + const cURL = useMemo(() => { + const body = buildPayload(format, model, prompt, systemPrompt, maxTokens); + return `curl ${finalEndpoint} \\ + -H "Authorization: Bearer ${apiKey || ''}" \\ + -H "Content-Type: application/json" \\ + -d '${JSON.stringify(body)}'`; + }, [finalEndpoint, apiKey, format, model, prompt, systemPrompt, maxTokens]); + + const send = async () => { + if (!apiKey) { + Toast.warning('请先填写 API Key'); + return; + } + if (!prompt.trim()) { + Toast.warning('请输入 Prompt'); + return; + } + setLoading(true); + setResponse(''); + setStatus(null); + const start = performance.now(); + try { + const res = await fetch(finalEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify( + buildPayload(format, model, prompt, systemPrompt, maxTokens), + ), + }); + const ms = Math.round(performance.now() - start); + setElapsed(ms); + setStatus({ code: res.status, ok: res.ok }); + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + const data = await res.json(); + setResponse(formatJSON(data)); + } else { + const text = await res.text(); + setResponse(text); + } + } catch (err) { + setStatus({ code: 0, ok: false }); + setResponse(`请求失败: ${err?.message || String(err)}`); + } finally { + setLoading(false); + } + }; + + const copyCurl = async () => { + try { + await navigator.clipboard.writeText(cURL); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + Toast.error('复制失败'); + } + }; + + const reset = () => { + setPrompt('用一句话介绍你自己。'); + setSystemPrompt(''); + setResponse(''); + setStatus(null); + setElapsed(0); + }; + + return ( +
+
+
+ {/* 页面标题区 */} +
+
+

+ API 快速测试 +

+

+ 当前协议:{format === 'anthropic' ? 'Anthropic Messages' : 'OpenAI Chat Completions'} · 请求地址 {finalEndpoint} +

+
+ +
+ + {/* 第一行:API Key / 测试目标 / 模型 */} +
+ {/* API Key 输入卡片 */} +
+
+
+ +
+
API Key
+
+
+ + +
+
+ {user?.token ? '已自动填充当前登录用户的 Token' : '请填写你的 API Key'} +
+
+ + {/* 测试目标选择卡片 */} +
+
+
+
+ +
+
+ 测试目标 +
+
+ +
+ + {(() => { + const grouped = SCENES.reduce((acc, s) => { + if (!acc[s.group]) acc[s.group] = []; + acc[s.group].push(s); + return acc; + }, {}); + return ( +
+ {Object.entries(grouped).map(([group, items]) => ( +
+
+ {group} +
+
+ {items.map((s) => ( + + ))} +
+
+ ))} + +
+
+
+ 我的目标 +
+ +
+ {customScenes.length === 0 ? ( +
+ 调整完地址后,点右上「保存当前」即可收藏到本地 +
+ ) : ( +
+ {customScenes.map((s) => ( + + + + + ))} +
+ )} +
+
+ ); + })()} + +
+ + applyFullUrl(e.target.value)} + placeholder='https://你的平台域名/v1/chat/completions' + className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-[11px] text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]' + /> +
+ + {showCustomBase && ( +
+ setEndpointBase(e.target.value)} + placeholder='https://api.example.com' + className='rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2 font-mono text-[11px] text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]' + /> + setEndpointPath(e.target.value)} + placeholder='/v1/chat/completions' + className='rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2 font-mono text-[11px] text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]' + /> +
+ )} + +
+ 实际请求: + {finalEndpoint} +
+
+ + {/* 模型选择卡片 */} +
+
+
+ +
+
模型
+
+ setModel(e.target.value)} + placeholder='例如 gpt-4o-mini / claude-3-5-sonnet ...' + className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]' + /> + {format === 'anthropic' && ( +
+ + setMaxTokens(Number(e.target.value) || 1)} + className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]' + /> +
+ )} +
+
+ + {/* 第二行:请求参数 + 响应结果 */} +
+ {/* 请求参数卡片 */} +
+
+
+
+ +
+
+ 请求参数 +
+
+ +
+ +
+
+ +