feat():learning后台管理前端页面初始化

This commit is contained in:
yuj
2025-12-04 17:51:24 +08:00
commit 83a614bd75
97 changed files with 23324 additions and 0 deletions

214
src/App.css Normal file
View File

@@ -0,0 +1,214 @@
/* --- Query form styles (aligned with gateway UI tone) --- */
.form-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.form-item { display: inline-flex; align-items: center; gap: 6px; }
.form-label { font-size: 12px; color: var(--muted); white-space: nowrap; }
.form-input, .form-select {
height: 32px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--soft);
color: var(--text);
font-size: 14px;
}
.form-select[multiple] {
height: 120px;
padding: 6px 8px;
}
.dropdown-panel {
background: var(--soft);
border: 1px solid var(--border);
border-radius: 8px;
min-width: 220px;
box-shadow: 0 8px 20px rgba(0,0,0,0.25);
}
.form-split { color: var(--muted); }
:root{
--bg: #f5f7fb;
--panel: #ffffff;
--soft: #f1f3f7;
--text: #171a1f;
--muted: #4d5562;
--accent: #5f97d2;
--good: #2fb167;
--warn: #caa410;
--bad: #b6404a;
--border: #e5e7ef;
--shadow: 0 10px 25px rgba(10,16,25,.08);
}
:root.light{
--bg: #f5f7fb;
--panel: #ffffff;
--soft: #f1f3f7;
--text: #171a1f;
--muted: #4d5562;
--accent: #5f97d2;
--good: #2fb167;
--warn: #caa410;
--bad: #b6404a;
--border: #e5e7ef;
--shadow: 0 10px 25px rgba(10,16,25,.08);
}
*{box-sizing:border-box; margin:0; padding:0}
html,body{height:100%; width:100%; margin:0; padding:0}
#root{width:100%; height:100%; margin:0; padding:0}
body{
margin:0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
background:var(--bg);
color:var(--text);
display:flex;
min-height:100vh;
width:100%;
overflow:hidden;
}
a{color:inherit; text-decoration:none}
.container{display:flex; flex:1; width:100vw; height:100vh; max-width:none; margin:0; padding:0}
.sidebar{
border-right:1px solid var(--border);
background:linear-gradient(180deg, var(--panel), var(--soft));
padding:20px; height:100vh; flex-shrink:0;
display:flex; flex-direction:column;
overflow: hidden;
}
.brand{ display:flex; align-items:center; gap:10px; padding:8px 6px; margin-bottom:16px; }
.brand .logo{
width:28px; height:28px; border-radius:8px;
background: radial-gradient(120% 120% at 0% 0%, var(--accent) 0%, #9dc3e7 48%, #9394e7 100%);
box-shadow:var(--shadow);
}
.brand h1{font-size:16px; letter-spacing:.4px; margin:0; font-weight:700}
.sidegroup{margin-top:16px}
.sidegroup h2{ font-size:11px; text-transform:uppercase; color:var(--muted); letter-spacing:.8px; margin:12px 8px; }
.nav{display:flex; flex-direction:column; gap:4px}
.nav a{
display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:10px;
color:var(--text); border:1px solid transparent;
}
.nav a:hover{background:var(--soft)}
.nav a.active{background:var(--soft); border-color:var(--border)}
.header{
display:flex; align-items:center; justify-content:space-between; padding:16px 22px;
border-bottom:1px solid var(--border); background:var(--panel); z-index:6; flex-shrink:0;
}
.search{ display:flex; align-items:center; gap:10px; background:var(--soft); padding:8px 12px; border-radius:12px; border:1px solid var(--border); }
.search input{background:transparent; border:none; outline:none; color:var(--text); width:280px}
.actions{display:flex; align-items:center; gap:10px}
.btn{ padding:8px 12px; border-radius:10px; border:1px solid var(--border); background:var(--soft); color:var(--text); cursor:pointer; }
.btn.primary{background:var(--accent); border-color:transparent; color:white; box-shadow:var(--shadow)}
.main{flex:1; display:flex; flex-direction:column; min-width:0; height:100vh; overflow:hidden; width:100%}
.subnav{
background:var(--panel);
padding:12px 22px; border-bottom:1px solid var(--border); flex-shrink:0;
overflow-x:auto; -webkit-overflow-scrolling:touch;
}
.segment{
display:flex; gap:8px; background:var(--soft); padding:6px; border-radius:12px; border:1px solid var(--border); width:max-content;
}
.segment button{
border:none; background:transparent; color:var(--muted); padding:8px 12px; border-radius:8px; cursor:pointer;
}
.segment button.active{
background:var(--panel); color:var(--text); box-shadow:var(--shadow);
}
.content{padding:22px; display:flex; flex-direction:column; gap:16px; flex:1; overflow-y:auto; min-height:0; box-sizing:border-box}
.panel{
background:var(--panel); border:1px solid var(--border); border-radius:16px; padding:18px; box-shadow:var(--shadow);
width:100%;
}
.panel h2{margin:0 0 8px 0; font-size:16px}
.panel .desc{color:var(--muted); font-size:13px; margin-bottom:10px}
.flex-2{display:grid; grid-template-columns: 1.2fr 1fr; gap:16px}
.flex-3{display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap:16px}
.full{display:block; width:100%; box-sizing:border-box}
.kpis{display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:12px}
.kpi{
background:var(--soft); border:1px solid var(--border); border-radius:14px; padding:14px;
}
.kpi h3{margin:0; font-size:12px; color:var(--muted)}
.kpi .num{font-size:26px; font-weight:800; margin-top:6px}
.kpi .delta{font-size:12px; margin-left:8px; padding:2px 6px; border-radius:8px; background:var(--panel); border:1px solid var(--border)}
.table{width:100%; border-collapse:collapse; font-size:14px; min-width:800px; table-layout:fixed; box-sizing:border-box}
.table th, .table td{padding:10px 12px; border-bottom:1px solid var(--border); text-align:left; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
.table thead th{color:var(--muted); font-weight:600}
.table-container{overflow-x:auto; width:100%; max-width:100%; -webkit-overflow-scrolling:touch; box-sizing:border-box}
.table th:nth-child(1), .table td:nth-child(1){width:8%}
.table th:nth-child(2), .table td:nth-child(2){width:15%}
.table th:nth-child(3), .table td:nth-child(3){width:12%}
.table th:nth-child(4), .table td:nth-child(4){width:15%}
.table th:nth-child(5), .table td:nth-child(5){width:15%}
.table th:nth-child(6), .table td:nth-child(6){width:15%}
.table th:nth-child(7), .table td:nth-child(7){width:20%}
.badge{font-size:12px; padding:4px 8px; border-radius:999px; background:var(--soft); border:1px solid var(--border)}
.badge.good{background:rgba(47,177,103,.12); border-color:rgba(47,177,103,.35); color:var(--good)}
.badge.warn{background:rgba(202,164,16,.12); border-color:rgba(202,164,16,.35); color:var(--warn)}
.badge.bad{ background:rgba(182,64,74,.12); border-color:rgba(182,64,74,.35); color:var(--bad)}
.chart{ height:260px; border:1px dashed var(--border); border-radius:12px; display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:13px; }
.footer{padding:12px 22px; border-top:1px solid var(--border); color:var(--muted)}
.hidden{display:none}
@media (max-width: 1200px){
.kpis{grid-template-columns: repeat(2, minmax(0,1fr));}
.flex-3{grid-template-columns: repeat(2, minmax(0,1fr));}
.flex-2{grid-template-columns: 1fr;}
.table{font-size:13px; min-width:700px}
.table th, .table td{padding:8px 10px}
.table th:nth-child(1), .table td:nth-child(1){width:10%}
.table th:nth-child(2), .table td:nth-child(2){width:18%}
.table th:nth-child(3), .table td:nth-child(3){width:15%}
.table th:nth-child(4), .table td:nth-child(4){width:18%}
.table th:nth-child(5), .table td:nth-child(5){width:18%}
.table th:nth-child(6), .table td:nth-child(6){width:15%}
.table th:nth-child(7), .table td:nth-child(7){width:6%}
}
@media (max-width: 900px){
.sidebar{display:none}
.sidebar.mobile-open{display:flex; position:fixed; left:0; top:0; height:100vh; width:240px; z-index:1000;}
.mobile-backdrop{position:fixed; inset:0; background:rgba(0,0,0,.35); z-index:999}
.mobile-toggle{display:inline-flex; align-items:center; gap:6px}
.kpis{grid-template-columns: 1fr;}
.flex-3{grid-template-columns: 1fr;}
.table{font-size:12px; min-width:600px}
.table th, .table td{padding:6px 8px}
.search input{width:200px}
.header{padding:12px 16px}
.content{padding:16px; overflow-x:auto}
.table th:nth-child(1), .table td:nth-child(1){width:12%}
.table th:nth-child(2), .table td:nth-child(2){width:20%}
.table th:nth-child(3), .table td:nth-child(3){width:18%}
.table th:nth-child(4), .table td:nth-child(4){width:20%}
.table th:nth-child(5), .table td:nth-child(5){width:18%}
.table th:nth-child(6), .table td:nth-child(6){width:12%}
.table th:nth-child(7), .table td:nth-child(7){width:0%; display:none}
}
@media (max-width: 600px){
.table{min-width:500px}
.table th, .table td{padding:4px 6px; font-size:11px}
.kpi .num{font-size:20px}
.kpi h3{font-size:11px}
.table th:nth-child(1), .table td:nth-child(1){width:15%}
.table th:nth-child(2), .table td:nth-child(2){width:25%}
.table th:nth-child(3), .table td:nth-child(3){width:20%}
.table th:nth-child(4), .table td:nth-child(4){width:0%; display:none}
.table th:nth-child(5), .table td:nth-child(5){width:25%}
.table th:nth-child(6), .table td:nth-child(6){width:15%}
.table th:nth-child(7), .table td:nth-child(7){width:0%; display:none}
}

276
src/App.tsx Normal file
View File

@@ -0,0 +1,276 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import PagePermissionGuard from './components/PagePermissionGuard';
import { usePagePermissions } from './hooks/usePagePermissions';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Overview from './pages/Overview';
import Operations from './pages/Operations';
import Monitoring from './pages/Monitoring';
import Finance from './pages/Finance';
import FinanceTransactionLogs from './pages/FinanceTransactionLogs';
import TokenHistoryPage from './pages/TokenHistory';
import TokenAnalytics from './pages/TokenAnalytics';
import SystemHealth from './pages/SystemHealth';
import UserProjectQuotaWrapper from './pages/UserProjectQuota';
import UserManagement from './pages/UserManagement';
import RoleManagement from './pages/RoleManagement';
import GoalfyMaxUsers from './pages/GoalfyMaxUsers';
import UserFeedback from './pages/UserFeedback';
import MessagePush from './pages/MessagePush';
import VendorModelPricing from './pages/VendorModelPricing';
import MCPProviderPricing from './pages/MCPProviderPricing';
import VmPricing from './pages/VmPricing';
import SystemConfigs from './pages/SystemConfigs';
import UserLevelConfigs from './pages/UserLevelConfigs';
import AuditLogs from './pages/AuditLogs';
import InviteCodes from './pages/InviteCodes';
import NoPermission from './pages/NoPermission';
import FinanceSandboxCosts from './pages/FinanceSandboxCosts';
import FinanceTokenUsage from './pages/FinanceTokenUsage';
import FinanceMcpUsage from './pages/FinanceMcpUsage';
import FinancePaymentRecords from './pages/FinancePaymentRecords';
import FinanceMcpAccountRechargeRecords from './pages/FinanceMcpAccountRechargeRecords';
import FinanceModelAccountRechargeRecords from './pages/FinanceModelAccountRechargeRecords';
function App() {
const FirstAllowedRedirect: React.FC = () => {
const { getAccessiblePages } = usePagePermissions();
const pages = getAccessiblePages();
const target = pages[0] || '/no-permission';
console.log('↪️ [Router] default redirect to:', target, 'pages=', pages);
return <Navigate to={target} replace />;
};
return (
<Layout>
<Routes>
{/* 默认落地:首个可访问页面或无权限页 */}
<Route path="/" element={<FirstAllowedRedirect />} />
{/* 仪表盘(受页面权限控制) */}
<Route
path="/dashboard"
element={
<PagePermissionGuard pagePath="/dashboard" fallback={<Navigate to="/no-permission" replace />}>
<Dashboard />
</PagePermissionGuard>
}
/>
{/* 总览页面(受页面权限控制) */}
<Route
path="/overview"
element={
<PagePermissionGuard pagePath="/overview" fallback={<Navigate to="/no-permission" replace />}>
<Overview />
</PagePermissionGuard>
}
/>
{/* 运营页面 - 嵌套路由(父路径受控) */}
<Route path="/operations" element={<Navigate to="/operations/user-feedback" replace />} />
<Route
path="/operations/user-feedback"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<UserFeedback />
</PagePermissionGuard>
}
/>
<Route
path="/operations/message-push"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<MessagePush />
</PagePermissionGuard>
}
/>
<Route
path="/operations/vendor-model-pricing"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<VendorModelPricing />
</PagePermissionGuard>
}
/>
<Route
path="/operations/mcp-provider-pricing"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<MCPProviderPricing />
</PagePermissionGuard>
}
/>
<Route
path="/operations/vm-pricing"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<VmPricing />
</PagePermissionGuard>
}
/>
<Route
path="/operations/invite-codes"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<InviteCodes />
</PagePermissionGuard>
}
/>
{/* 监控页面 - 嵌套路由(父路径受控) */}
<Route path="/monitoring" element={<Navigate to="/monitoring/token-history" replace />} />
<Route
path="/monitoring/token-history"
element={
<PagePermissionGuard pagePath="/monitoring" fallback={<Navigate to="/no-permission" replace />}>
<TokenHistoryPage />
</PagePermissionGuard>
}
/>
<Route
path="/monitoring/token-analytics"
element={
<PagePermissionGuard pagePath="/monitoring" fallback={<Navigate to="/no-permission" replace />}>
<TokenAnalytics />
</PagePermissionGuard>
}
/>
<Route
path="/monitoring/system-health"
element={
<PagePermissionGuard pagePath="/monitoring" fallback={<Navigate to="/no-permission" replace />}>
<SystemHealth />
</PagePermissionGuard>
}
/>
{/* 财务页面(父路由重定向到子页面:用户流水) */}
<Route path="/finance" element={<Navigate to="/finance/transaction-logs" replace />} />
<Route
path="/finance/transaction-logs"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceTransactionLogs />
</PagePermissionGuard>
}
/>
<Route
path="/finance/sandbox-costs"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceSandboxCosts />
</PagePermissionGuard>
}
/>
<Route
path="/finance/token-usage"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceTokenUsage />
</PagePermissionGuard>
}
/>
<Route
path="/finance/mcp-usage"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceMcpUsage />
</PagePermissionGuard>
}
/>
<Route
path="/finance/payment-records"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinancePaymentRecords />
</PagePermissionGuard>
}
/>
<Route
path="/finance/mcp-account-recharge-records"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceMcpAccountRechargeRecords />
</PagePermissionGuard>
}
/>
<Route
path="/finance/model-account-recharge-records"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceModelAccountRechargeRecords />
</PagePermissionGuard>
}
/>
{/* 系统管理页面 - 嵌套路由 */}
<Route path="/system" element={<Navigate to="/system/user-management" replace />} />
<Route
path="/system/user-project-quota"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<UserProjectQuotaWrapper />
</PagePermissionGuard>
}
/>
<Route
path="/system/user-management"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<UserManagement />
</PagePermissionGuard>
}
/>
<Route
path="/system/role-management"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<RoleManagement />
</PagePermissionGuard>
}
/>
<Route
path="/system/goalfymax-users"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<GoalfyMaxUsers />
</PagePermissionGuard>
}
/>
<Route
path="/system/user-level-configs"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<UserLevelConfigs />
</PagePermissionGuard>
}
/>
<Route
path="/system/system-configs"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<SystemConfigs />
</PagePermissionGuard>
}
/>
<Route
path="/system/audit-logs"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<AuditLogs />
</PagePermissionGuard>
}
/>
{/* 无权限页面 */}
<Route path="/no-permission" element={<NoPermission />} />
{/* 404页面 */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Layout>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

36
src/atoms/auth.ts Normal file
View File

@@ -0,0 +1,36 @@
import { atom } from 'jotai';
export interface User {
sub: string;
name?: string;
email?: string;
preferred_username?: string;
permissions?: string[];
roles?: string[];
pages?: Array<{
id?: number;
name?: string;
path: string;
icon?: string;
sort_order?: number;
is_active?: boolean;
}>;
}
export interface AuthState {
user: User | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
loginUrl: string | null;
}
export const authStateAtom = atom<AuthState>({
user: null,
token: localStorage.getItem('token'),
refreshToken: localStorage.getItem('refreshToken'),
isAuthenticated: false,
loginUrl: null,
});
export const authLoadingAtom = atom<boolean>(false);

View File

@@ -0,0 +1,90 @@
import React, { useEffect, useRef, useState } from 'react';
import { Spin } from 'antd';
import { useAuth } from '../hooks/useAuth';
interface AuthGuardProps {
children: React.ReactNode;
}
const AuthGuard: React.FC<AuthGuardProps> = ({ children }) => {
const {
isAuthenticated,
initAuth,
loginUrl,
redirectToLogin,
loading,
logout,
} = useAuth();
const initedRef = useRef(false);
const [booting, setBooting] = useState(true);
// 调试信息
console.log('🔐 [AuthGuard] 状态:', {
booting,
isAuthenticated,
loading,
hasLoginUrl: !!loginUrl,
});
useEffect(() => {
if (initedRef.current) {
setBooting(false);
return;
}
initedRef.current = true;
(async () => {
try {
await initAuth();
} finally {
setBooting(false);
}
})();
}, [initAuth]);
// 未认证时主动触发SSO登录流程
useEffect(() => {
if (!booting && !isAuthenticated && !loading) {
redirectToLogin();
}
}, [booting, isAuthenticated, loading, redirectToLogin]);
if (booting) {
return (
<div
style={{
display: 'flex',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spin tip="Loading...">
<div className="w-[200px]" />
</Spin>
</div>
);
}
if (!isAuthenticated) {
// 未认证时显示加载等待自动跳转SSO
return (
<div
style={{
display: 'flex',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spin tip="Redirecting to SSO...">
<div className="w-[200px]" />
</Spin>
</div>
);
}
return <>{children}</>;
};
export default AuthGuard;

View File

@@ -0,0 +1,108 @@
/* Ant Design Menu 自定义样式 */
.dynamic-menu-wrapper {
width: 100%;
}
.dynamic-menu-wrapper .ant-menu-inline {
border: none !important;
background: transparent;
}
.dynamic-menu-wrapper .ant-menu-item,
.dynamic-menu-wrapper .ant-menu-submenu-title {
border-radius: 10px;
margin: 4px 0;
padding-left: 16px !important;
height: 40px;
line-height: 40px;
transition: all 0.2s;
border: 1px solid transparent;
}
.dynamic-menu-wrapper .ant-menu-item:hover,
.dynamic-menu-wrapper .ant-menu-submenu-title:hover {
background: var(--soft) !important;
}
.dynamic-menu-wrapper .ant-menu-item-selected {
background: var(--soft) !important;
border-color: var(--border) !important;
}
.dynamic-menu-wrapper .ant-menu-item-selected::after {
display: none;
}
/* 图标样式 */
.dynamic-menu-wrapper .ant-menu-item .anticon,
.dynamic-menu-wrapper .ant-menu-submenu-title .anticon {
font-size: 16px;
color: var(--text);
}
/* 折叠时的样式 */
.dynamic-menu-wrapper .ant-menu-inline-collapsed {
width: 100% !important;
}
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-item,
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-submenu > .ant-menu-submenu-title {
padding: 0 16px !important;
text-align: center;
margin: 4px 0;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.dynamic-menu-wrapper .ant-menu-inline-collapsed .ant-menu-item-icon {
margin-inline-end: 0 !important;
}
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-item .anticon,
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-submenu > .ant-menu-submenu-title .anticon {
font-size: 20px;
line-height: 40px;
margin: 0 !important;
}
/* 子菜单样式 */
.dynamic-menu-wrapper .ant-menu-submenu-title {
color: var(--text);
}
.dynamic-menu-wrapper .ant-menu-sub {
background: transparent !important;
}
.dynamic-menu-wrapper .ant-menu-submenu .ant-menu-item {
padding-left: 48px !important;
}
/* 移除默认的边框和阴影 */
.dynamic-menu-wrapper .ant-menu-item,
.dynamic-menu-wrapper .ant-menu-submenu {
border: none;
}
.dynamic-menu-wrapper .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
background-color: var(--soft);
}
/* 展开箭头样式 */
.dynamic-menu-wrapper .ant-menu-submenu-arrow {
color: var(--muted);
}
/* 子菜单展开时的样式 */
.dynamic-menu-wrapper .ant-menu-submenu-open > .ant-menu-submenu-title {
color: var(--text);
}
/* 文字超出隐藏 */
.dynamic-menu-wrapper .ant-menu-title-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,250 @@
import React from 'react';
import { Menu } from 'antd';
import {
PieChartOutlined,
TeamOutlined,
ControlOutlined,
MessageOutlined,
BellOutlined,
LineChartOutlined,
AccountBookOutlined,
SettingOutlined,
DatabaseOutlined,
UsergroupAddOutlined,
CrownOutlined
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { usePagePermissions } from '../hooks/usePagePermissions';
import './DynamicMenu.css';
interface MenuItem {
key: string;
label: string;
icon?: React.ReactNode;
path?: string;
children?: MenuItem[];
}
interface DynamicMenuProps {
collapsed?: boolean;
}
const DynamicMenu: React.FC<DynamicMenuProps> = ({ collapsed = false }) => {
const navigate = useNavigate();
const location = useLocation();
const { getAccessiblePages, getPageActions } = usePagePermissions();
const accessiblePages = getAccessiblePages();
console.log('🔍 [DynamicMenu] 用户可访问页面:', accessiblePages);
// 菜单配置 - 使用数据库中的页面路径
const menuItems: MenuItem[] = [
{
key: 'overview',
label: '总览',
icon: <PieChartOutlined />,
path: '/overview',
},
{
key: 'operations',
label: '运营',
icon: <TeamOutlined />,
path: '/operations',
children: [
{
key: 'operations-main',
label: '运营管理',
icon: <ControlOutlined />,
path: '/operations',
},
{
key: 'operations-user-feedback',
label: '用户反馈',
icon: <MessageOutlined />,
path: '/user-feedback',
},
{
key: 'operations-message-push',
label: '消息推送',
icon: <BellOutlined />,
path: '/message-push',
},
{
key: 'operations-mcp-pricing',
label: 'MCP价格配置',
icon: <DatabaseOutlined />,
path: '/mcp-provider-pricing',
},
],
},
{
key: 'monitoring',
label: '监控',
icon: <LineChartOutlined />,
path: '/monitoring',
},
{
key: 'finance',
label: '财务',
icon: <AccountBookOutlined />,
path: '/finance',
},
{
key: 'system',
label: '系统管理',
icon: <SettingOutlined />,
path: '/system/user-management',
children: [
{
key: 'system-quota',
label: '配额管理',
icon: <DatabaseOutlined />,
path: '/system',
},
{
key: 'system-goalfymax-users',
label: 'GoalfyMax用户',
icon: <UsergroupAddOutlined />,
path: '/goalfymax-users',
},
{
key: 'system-user-level-configs',
label: '用户等级管理',
icon: <CrownOutlined />,
path: '/user-level-configs',
},
],
},
];
// 过滤可访问的菜单项
const filterAccessibleMenus = (items: MenuItem[]): MenuItem[] => {
return items.map(item => {
// 创建新对象,避免修改原始数据
const newItem = { ...item };
// 移除所有子菜单,只显示一级菜单(子导航在页面内显示)
newItem.children = undefined;
// 父级"系统管理"权限检查
if (newItem.key === 'system' || newItem.path === '/system') {
const hasSystemAccess = accessiblePages.includes('/system');
if (!hasSystemAccess) {
console.log(`🚫 [DynamicMenu] 隐藏系统管理: 用户无 /system 权限`);
return null;
}
console.log(`✅ [DynamicMenu] 显示系统管理菜单项`);
return newItem;
}
// 父级"运营"权限检查
if (newItem.key === 'operations' || newItem.path === '/operations') {
const hasOperationsAccess = accessiblePages.includes('/operations');
if (!hasOperationsAccess) {
console.log(`🚫 [DynamicMenu] 隐藏运营: 用户无 /operations 权限`);
return null;
}
console.log(`✅ [DynamicMenu] 显示运营菜单项`);
return newItem;
}
// 对于有路径的项目,检查权限
if (newItem.path && !accessiblePages.includes(newItem.path)) {
console.log(`🚫 [DynamicMenu] 隐藏菜单项: ${newItem.label} (${newItem.path}) - 用户无权限`);
return null;
}
console.log(`✅ [DynamicMenu] 显示菜单项: ${newItem.label} (${newItem.path}) - 用户有权限`);
return newItem;
}).filter((item): item is MenuItem => item !== null);
};
const accessibleMenus = filterAccessibleMenus(menuItems);
console.log('🔍 [DynamicMenu] 原始菜单项:', menuItems.map(item => ({ key: item.key, label: item.label, path: item.path })));
console.log('🔍 [DynamicMenu] 过滤后的菜单项:', accessibleMenus.map(item => ({ key: item.key, label: item.label, path: item.path })));
const handleMenuClick = ({ key }: { key: string }) => {
const findMenuItem = (items: MenuItem[], targetKey: string): MenuItem | null => {
for (const item of items) {
if (item.key === targetKey) {
return item;
}
if (item.children) {
const found = findMenuItem(item.children, targetKey);
if (found) return found;
}
}
return null;
};
const menuItem = findMenuItem(accessibleMenus, key);
if (menuItem?.path) {
navigate(menuItem.path);
}
};
// 根据当前路径找到对应的菜单key
const getSelectedMenuKey = (): string => {
const currentPath = location.pathname;
// 遍历所有菜单项找到匹配当前路径的菜单key
const findKeyByPath = (items: MenuItem[]): string | null => {
for (const item of items) {
// 精确匹配路径
if (item.path === currentPath) {
return item.key;
}
// 检查是否是子路径(例如 /system/quota-rules 应该选中 system
if (item.path && currentPath.startsWith(item.path)) {
return item.key;
}
// 递归检查子菜单
if (item.children) {
const childKey = findKeyByPath(item.children);
if (childKey) return childKey;
}
}
return null;
};
const key = findKeyByPath(accessibleMenus);
return key || '';
};
// 转换为 Ant Design Menu 所需的格式
const convertToMenuItems = (items: MenuItem[]): any[] => {
return items.map(item => {
if (item.children && item.children.length > 0) {
return {
key: item.key,
label: item.label,
icon: item.icon,
children: convertToMenuItems(item.children),
};
}
return {
key: item.key,
label: item.label,
icon: item.icon,
};
});
};
const selectedKey = getSelectedMenuKey();
return (
<div className="dynamic-menu-wrapper">
<Menu
mode="inline"
inlineCollapsed={collapsed}
selectedKeys={selectedKey ? [selectedKey] : []}
items={convertToMenuItems(accessibleMenus)}
onClick={handleMenuClick}
/>
</div>
);
};
export default DynamicMenu;

391
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,391 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
MenuFoldOutlined,
MenuUnfoldOutlined
} from '@ant-design/icons';
import { useAuth } from '../hooks/useAuth';
import DynamicMenu from './DynamicMenu';
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
const navigate = useNavigate();
const { token, logout } = useAuth();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
// 从路径获取当前激活的标签
const getCurrentTab = () => {
const path = location.pathname;
if (path === '/dashboard' || path === '/overview') return 'overview';
if (path.startsWith('/operations')) return 'operations';
if (path.startsWith('/monitoring')) return 'monitoring';
if (path === '/finance' || path.startsWith('/finance/')) return 'finance';
if (path.startsWith('/system')) return 'admin';
return 'overview';
};
const getCurrentSubTab = () => {
const path = location.pathname;
// 运营页面子导航
if (path.startsWith('/operations/user-feedback')) return 'user-feedback';
if (path.startsWith('/operations/message-push')) return 'message-push';
if (path.startsWith('/operations/vendor-model-pricing')) return 'vendor-model-pricing';
if (path.startsWith('/operations/mcp-provider-pricing')) return 'mcp-provider-pricing';
if (path.startsWith('/operations/vm-pricing')) return 'vm-pricing';
if (path.startsWith('/operations/invite-codes')) return 'invite-codes';
// 监控页面子导航
if (path.startsWith('/monitoring/token-history')) return 'token-history';
if (path.startsWith('/monitoring/token-analytics')) return 'token-analytics';
if (path.startsWith('/monitoring/system-health')) return 'system-health';
// 财务页面子导航
if (path.startsWith('/finance/transaction-logs')) return 'transaction-logs';
if (path.startsWith('/finance/sandbox-costs')) return 'sandbox-costs';
if (path.startsWith('/finance/token-usage')) return 'token-usage';
if (path.startsWith('/finance/mcp-usage')) return 'mcp-usage';
if (path.startsWith('/finance/payment-records')) return 'payment-records';
if (path.startsWith('/finance/mcp-account-recharge-records')) return 'mcp-account-recharge-records';
if (path.startsWith('/finance/model-account-recharge-records')) return 'model-account-recharge-records';
// 系统管理页面子导航
if (path.startsWith('/system/user-project-quota')) return 'user-project-quota';
if (path.startsWith('/system/user-management')) return 'user-management';
if (path.startsWith('/system/role-management')) return 'role-management';
if (path.startsWith('/system/goalfymax-users')) return 'goalfymax-users';
if (path.startsWith('/system/user-level-configs')) return 'user-level-configs';
if (path.startsWith('/system/system-configs')) return 'system-configs';
if (path.startsWith('/system/audit-logs')) return 'audit-logs';
return '';
};
const activeTab = getCurrentTab();
const activeSubTab = getCurrentSubTab();
const handleLogout = async () => {
await logout();
};
return (
<div className="container">
{/* 侧边栏 */}
<aside className={`sidebar${mobileOpen ? ' mobile-open' : ''}`} style={{ width: collapsed ? '80px' : '240px', transition: 'width 0.2s' }}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: collapsed ? '16px 0' : '16px',
marginBottom: '8px',
gap: '12px'
}}>
{collapsed ? (
<>
<div className="logo" style={{ margin: 0 }}></div>
<button
onClick={() => setCollapsed(!collapsed)}
style={{
border: '1px solid var(--border)',
background: 'var(--soft)',
cursor: 'pointer',
fontSize: '16px',
color: 'var(--text)',
padding: '8px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
width: '40px',
height: '40px',
flexShrink: 0
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--panel)';
e.currentTarget.style.boxShadow = 'var(--shadow)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--soft)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<MenuUnfoldOutlined />
</button>
</>
) : (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%'
}}>
<div className="brand" style={{
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
flex: 1
}}>
<div className="logo"></div>
<h1>Goalfy Admin</h1>
</div>
<button
onClick={() => setCollapsed(!collapsed)}
style={{
border: '1px solid var(--border)',
background: 'var(--soft)',
cursor: 'pointer',
fontSize: '16px',
color: 'var(--text)',
padding: '8px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
width: '40px',
height: '40px',
flexShrink: 0
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--panel)';
e.currentTarget.style.boxShadow = 'var(--shadow)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--soft)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<MenuFoldOutlined />
</button>
</div>
)}
</div>
<div className="sidegroup" style={{ flex: 1, overflow: 'hidden' }}>
<DynamicMenu collapsed={collapsed} />
</div>
<div style={{
fontSize: '12px',
color: 'var(--muted)',
textAlign: collapsed ? 'center' : 'left',
padding: '16px',
borderTop: '1px solid var(--border)',
marginTop: 'auto'
}}>
{!collapsed ? 'v1.1 · React Admin' : 'v1.1'}
</div>
</aside>
{/* 主内容区 */}
<section className="main">
<header className="header">
<div style={{display:'flex', alignItems:'center', gap:'10px'}}>
<button
className="btn mobile-toggle"
onClick={() => setMobileOpen(true)}
aria-label="打开菜单"
>
<MenuUnfoldOutlined />
</button>
</div>
<div className="actions">
<div style={{display:'flex', alignItems:'center', gap:'8px'}}>
{token ? (
<>
<span style={{fontSize:12,color:'var(--muted)'}}></span>
<button className="btn" onClick={handleLogout}></button>
</>
) : null}
</div>
</div>
</header>
{/* 监控页面的子导航 */}
{activeTab === 'monitoring' && (
<nav className="subnav">
<div className="segment" data-tabs="mo">
<button
className={activeSubTab === 'token-history' ? 'active' : ''}
onClick={() => navigate('/monitoring/token-history')}
>
Token
</button>
<button
className={activeSubTab === 'token-analytics' ? 'active' : ''}
onClick={() => navigate('/monitoring/token-analytics')}
>
Token
</button>
<button
className={activeSubTab === 'system-health' ? 'active' : ''}
onClick={() => navigate('/monitoring/system-health')}
>
</button>
</div>
</nav>
)}
{/* 运营页面的子导航 */}
{activeTab === 'operations' && (
<nav className="subnav">
<div className="segment" data-tabs="operations">
<button
className={activeSubTab === 'user-feedback' ? 'active' : ''}
onClick={() => navigate('/operations/user-feedback')}
>
</button>
<button
className={activeSubTab === 'message-push' ? 'active' : ''}
onClick={() => navigate('/operations/message-push')}
>
</button>
<button
className={activeSubTab === 'vendor-model-pricing' ? 'active' : ''}
onClick={() => navigate('/operations/vendor-model-pricing')}
>
</button>
<button
className={activeSubTab === 'mcp-provider-pricing' ? 'active' : ''}
onClick={() => navigate('/operations/mcp-provider-pricing')}
>
MCP
</button>
<button
className={activeSubTab === 'vm-pricing' ? 'active' : ''}
onClick={() => navigate('/operations/vm-pricing')}
>
</button>
<button
className={activeSubTab === 'invite-codes' ? 'active' : ''}
onClick={() => navigate('/operations/invite-codes')}
>
</button>
</div>
</nav>
)}
{/* 财务页面的子导航 */}
{activeTab === 'finance' && (
<nav className="subnav">
<div className="segment" data-tabs="finance">
<button
className={activeSubTab === 'transaction-logs' ? 'active' : ''}
onClick={() => navigate('/finance/transaction-logs')}
>
</button>
<button
className={activeSubTab === 'sandbox-costs' ? 'active' : ''}
onClick={() => navigate('/finance/sandbox-costs')}
>
</button>
<button
className={activeSubTab === 'token-usage' ? 'active' : ''}
onClick={() => navigate('/finance/token-usage')}
>
Token费用
</button>
<button
className={activeSubTab === 'mcp-usage' ? 'active' : ''}
onClick={() => navigate('/finance/mcp-usage')}
>
MCP
</button>
<button
className={activeSubTab === 'payment-records' ? 'active' : ''}
onClick={() => navigate('/finance/payment-records')}
>
</button>
<button
className={activeSubTab === 'mcp-account-recharge-records' ? 'active' : ''}
onClick={() => navigate('/finance/mcp-account-recharge-records')}
>
MCP账号管理
</button>
<button
className={activeSubTab === 'model-account-recharge-records' ? 'active' : ''}
onClick={() => navigate('/finance/model-account-recharge-records')}
>
</button>
</div>
</nav>
)}
{/* 系统管理页面的子导航 */}
{activeTab === 'admin' && (
<nav className="subnav">
<div className="segment" data-tabs="admin">
{/* 暂时隐藏用户项目配额菜单 */}
{/* <button
className={activeSubTab === 'user-project-quota' ? 'active' : ''}
onClick={() => navigate('/system/user-project-quota')}
>
用户项目配额
</button> */}
<button
className={activeSubTab === 'user-management' ? 'active' : ''}
onClick={() => navigate('/system/user-management')}
>
</button>
<button
className={activeSubTab === 'role-management' ? 'active' : ''}
onClick={() => navigate('/system/role-management')}
>
</button>
<button
className={activeSubTab === 'goalfymax-users' ? 'active' : ''}
onClick={() => navigate('/system/goalfymax-users')}
>
GoalfyMax用户
</button>
<button
className={activeSubTab === 'user-level-configs' ? 'active' : ''}
onClick={() => navigate('/system/user-level-configs')}
>
</button>
<button
className={activeSubTab === 'system-configs' ? 'active' : ''}
onClick={() => navigate('/system/system-configs')}
>
</button>
<button
className={activeSubTab === 'audit-logs' ? 'active' : ''}
onClick={() => navigate('/system/audit-logs')}
>
</button>
</div>
</nav>
)}
<main className="content">
{children}
</main>
<footer className="footer">© 2025 Goalfy · Internal use only</footer>
</section>
{mobileOpen && (
<div
className="mobile-backdrop"
onClick={() => setMobileOpen(false)}
/>
)}
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { usePagePermissions } from '../hooks/usePagePermissions';
interface PagePermissionGuardProps {
pagePath: string;
action?: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}
const PagePermissionGuard: React.FC<PagePermissionGuardProps> = ({
pagePath,
action = 'access',
children,
fallback = null
}) => {
const { hasPagePermission } = usePagePermissions();
const hasAccess = hasPagePermission(pagePath, action);
return hasAccess ? <>{children}</> : <>{fallback}</>;
};
export default PagePermissionGuard;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { usePermissions } from '../hooks/usePermissions';
interface PermissionGuardProps {
permission?: string;
permissions?: string[];
requireAll?: boolean;
role?: string;
roles?: string[];
requireAllRoles?: boolean;
children: React.ReactNode;
fallback?: React.ReactNode;
}
const PermissionGuard: React.FC<PermissionGuardProps> = ({
permission,
permissions,
requireAll = false,
role,
roles,
requireAllRoles = false,
children,
fallback = null
}) => {
const {
hasPermission,
hasAnyPermission,
hasAllPermissions,
hasRole,
hasAnyRole,
hasAllRoles
} = usePermissions();
let hasAccess = false;
// 检查权限
if (permission) {
hasAccess = hasPermission(permission);
console.log(`🔐 [PermissionGuard] 检查权限 "${permission}": ${hasAccess}`);
} else if (permissions) {
hasAccess = requireAll ? hasAllPermissions(permissions) : hasAnyPermission(permissions);
console.log(`🔐 [PermissionGuard] 检查权限组 "${permissions}": ${hasAccess}`);
}
// 检查角色
if (role) {
hasAccess = hasRole(role);
console.log(`👤 [PermissionGuard] 检查角色 "${role}": ${hasAccess}`);
} else if (roles) {
hasAccess = requireAllRoles ? hasAllRoles(roles) : hasAnyRole(roles);
console.log(`👤 [PermissionGuard] 检查角色组 "${roles}": ${hasAccess}`);
}
return hasAccess ? <>{children}</> : <>{fallback}</>;
};
export default PermissionGuard;

View File

@@ -0,0 +1,142 @@
import React from 'react';
import { Card, Row, Col, Typography } from 'antd';
import { QuotaHistoryItem } from '../types/quota';
import dayjs from 'dayjs';
const { Title } = Typography;
interface QuotaChartsProps {
data: QuotaHistoryItem[];
loading?: boolean;
}
const QuotaCharts: React.FC<QuotaChartsProps> = ({
data,
loading = false
}) => {
// 计算每日使用量
const dailyUsage = React.useMemo(() => {
const dailyMap = new Map<string, number>();
data.forEach(item => {
const day = item.day;
dailyMap.set(day, (dailyMap.get(day) || 0) + item.quota_used);
});
return Array.from(dailyMap.entries())
.map(([date, quota]) => ({ date, quota }))
.sort((a, b) => dayjs(a.date).unix() - dayjs(b.date).unix());
}, [data]);
// 计算用户使用量排行
const topUsers = React.useMemo(() => {
const userMap = new Map<string, number>();
data.forEach(item => {
userMap.set(item.user_id, (userMap.get(item.user_id) || 0) + item.quota_used);
});
return Array.from(userMap.entries())
.map(([user_id, quota_used]) => ({ user_id, quota_used }))
.sort((a, b) => b.quota_used - a.quota_used)
.slice(0, 10);
}, [data]);
// 计算模型使用量排行
const topModels = React.useMemo(() => {
const modelMap = new Map<string, number>();
data.forEach(item => {
modelMap.set(item.model, (modelMap.get(item.model) || 0) + item.quota_used);
});
return Array.from(modelMap.entries())
.map(([model, quota_used]) => ({ model, quota_used }))
.sort((a, b) => b.quota_used - a.quota_used)
.slice(0, 10);
}, [data]);
return (
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card loading={loading}>
<Title level={5}> Token 使</Title>
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: '#999' }}>
<div>📈</div>
<div></div>
<div style={{ fontSize: '12px', marginTop: '8px' }}>
: {dailyUsage.length}
</div>
</div>
</div>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card loading={loading}>
<Title level={5}> Token 使</Title>
<div style={{ height: 300, overflowY: 'auto' }}>
{topUsers.map((user, index) => (
<div key={user.user_id} style={{
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: index < topUsers.length - 1 ? '1px solid #f0f0f0' : 'none'
}}>
<span>
<span style={{
display: 'inline-block',
width: '20px',
textAlign: 'center',
fontWeight: 'bold',
color: index < 3 ? '#ff4d4f' : '#666'
}}>
{index + 1}
</span>
{user.user_id}
</span>
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{user.quota_used.toLocaleString()}
</span>
</div>
))}
</div>
</Card>
</Col>
<Col xs={24}>
<Card loading={loading}>
<Title level={5}> Token 使</Title>
<div style={{ height: 300, overflowY: 'auto' }}>
{topModels.map((model, index) => (
<div key={model.model} style={{
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: index < topModels.length - 1 ? '1px solid #f0f0f0' : 'none'
}}>
<span>
<span style={{
display: 'inline-block',
width: '20px',
textAlign: 'center',
fontWeight: 'bold',
color: index < 3 ? '#ff4d4f' : '#666'
}}>
{index + 1}
</span>
{model.model}
</span>
<span style={{ fontWeight: 'bold', color: '#52c41a' }}>
{model.quota_used.toLocaleString()}
</span>
</div>
))}
</div>
</Card>
</Col>
</Row>
);
};
export default QuotaCharts;

View File

@@ -0,0 +1,162 @@
import React from 'react';
import {
Card,
Form,
DatePicker,
Input,
Select,
Button,
Space,
Row,
Col,
Typography
} from 'antd';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { Title } = Typography;
const { RangePicker } = DatePicker;
const { Option } = Select;
interface QuotaFiltersProps {
onSearch: (values: any) => void;
onReset: () => void;
loading?: boolean;
}
const QuotaFilters: React.FC<QuotaFiltersProps> = ({
onSearch,
onReset,
loading = false
}) => {
const [form] = Form.useForm();
const handleSearch = (values: any) => {
const searchParams = {
...values,
start_date: values.dateRange?.[0]?.format('YYYY-MM-DD'),
end_date: values.dateRange?.[1]?.format('YYYY-MM-DD'),
};
delete searchParams.dateRange;
onSearch(searchParams);
};
const handleReset = () => {
form.resetFields();
onReset();
};
return (
<Card style={{ marginBottom: 16 }}>
<Title level={4} style={{ marginBottom: 16 }}>
</Title>
<Form
form={form}
layout="vertical"
onFinish={handleSearch}
initialValues={{
dateRange: [dayjs().subtract(7, 'day'), dayjs()],
period: 'daily'
}}
>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="时间范围"
name="dateRange"
rules={[{ required: true, message: '请选择时间范围' }]}
>
<RangePicker
style={{ width: '100%' }}
format="YYYY-MM-DD"
placeholder={['开始日期', '结束日期']}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="统计周期"
name="period"
>
<Select placeholder="选择统计周期">
<Option value="daily"></Option>
<Option value="monthly"></Option>
</Select>
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="用户ID"
name="user_id"
>
<Input placeholder="输入用户ID" />
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="API组"
name="api_group"
>
<Input placeholder="输入API组" />
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="项目ID"
name="project_id"
>
<Input placeholder="输入项目ID" />
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="分组维度"
name="group_by"
>
<Select
mode="multiple"
placeholder="选择分组维度"
style={{ width: '100%' }}
>
<Option value="user_id"></Option>
<Option value="api_group">API组</Option>
<Option value="project_id"></Option>
<Option value="model"></Option>
<Option value="day"></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
icon={<SearchOutlined />}
loading={loading}
>
</Button>
<Button
onClick={handleReset}
icon={<ReloadOutlined />}
>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
);
};
export default QuotaFilters;

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { Table, Tag, Card, Typography, Space } from 'antd';
import { QuotaHistoryItem } from '../types/quota';
import dayjs from 'dayjs';
const { Title } = Typography;
interface QuotaHistoryTableProps {
data: QuotaHistoryItem[];
loading?: boolean;
}
const QuotaHistoryTable: React.FC<QuotaHistoryTableProps> = ({
data,
loading = false
}) => {
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户ID',
dataIndex: 'user_id',
key: 'user_id',
width: 120,
},
{
title: 'API组',
dataIndex: 'api_group',
key: 'api_group',
width: 120,
render: (apiGroup: string) => (
<Tag color="blue">{apiGroup}</Tag>
),
},
{
title: '项目ID',
dataIndex: 'project_id',
key: 'project_id',
width: 120,
},
{
title: '账户',
dataIndex: 'account',
key: 'account',
width: 120,
},
{
title: '模型',
dataIndex: 'model',
key: 'model',
width: 150,
render: (model: string) => (
<Tag color="green">{model}</Tag>
),
},
{
title: 'Token 使用量',
dataIndex: 'quota_used',
key: 'quota_used',
width: 120,
render: (quota: number) => (
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{quota.toLocaleString()}
</span>
),
sorter: (a: QuotaHistoryItem, b: QuotaHistoryItem) => a.quota_used - b.quota_used,
},
{
title: '日期',
dataIndex: 'day',
key: 'day',
width: 120,
render: (day: string) => dayjs(day).format('YYYY-MM-DD'),
sorter: (a: QuotaHistoryItem, b: QuotaHistoryItem) =>
dayjs(a.day).unix() - dayjs(b.day).unix(),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (createdAt: string) => dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss'),
sorter: (a: QuotaHistoryItem, b: QuotaHistoryItem) =>
dayjs(a.created_at).unix() - dayjs(b.created_at).unix(),
},
];
return (
<Card>
<Title level={4} style={{ marginBottom: 16 }}>
Token 使
</Title>
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
pagination={{
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} 条,共 ${total} 条记录`,
}}
scroll={{ x: 1200 }}
size="small"
/>
</Card>
);
};
export default QuotaHistoryTable;

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { Card, Row, Col, Statistic, Typography } from 'antd';
import {
UserOutlined,
ProjectOutlined,
ApiOutlined,
DatabaseOutlined
} from '@ant-design/icons';
const { Title } = Typography;
interface QuotaStatsProps {
totalQuota: number;
totalUsers: number;
totalProjects: number;
totalModels: number;
loading?: boolean;
}
const QuotaStats: React.FC<QuotaStatsProps> = ({
totalQuota,
totalUsers,
totalProjects,
totalModels,
loading = false
}) => {
return (
<div style={{ marginBottom: 24 }}>
<Title level={4} style={{ marginBottom: 16 }}>
Token 使
</Title>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="总 Token 使用量"
value={totalQuota}
precision={2}
loading={loading}
prefix={<DatabaseOutlined />}
suffix="tokens"
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="活跃用户数"
value={totalUsers}
loading={loading}
prefix={<UserOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="项目数量"
value={totalProjects}
loading={loading}
prefix={<ProjectOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="模型数量"
value={totalModels}
loading={loading}
prefix={<ApiOutlined />}
/>
</Card>
</Col>
</Row>
</div>
);
};
export default QuotaStats;

View File

@@ -0,0 +1,87 @@
import React, { useEffect, useState } from 'react'
import { QuotaApiService } from '../services/api'
import type { PageResponse, UserProjectQuotaItem } from '../types/userProjectQuota'
export default function UserProjectQuotaPage() {
const [items, setItems] = useState<UserProjectQuotaItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [userId, setUserId] = useState('')
const [enabled, setEnabled] = useState('')
const [form, setForm] = useState<UserProjectQuotaItem>({ id:0, user_id: '', project_limit: 0, vm_limit: 0, process_limit: 0, enabled: true, description: '' })
const [edit, setEdit] = useState<UserProjectQuotaItem | null>(null)
const load = async () => {
const res: PageResponse<UserProjectQuotaItem> = await QuotaApiService.listUserProjectQuota({ user_id: userId, enabled, page, size })
const list = res?.data?.data || res?.data?.items || []
setItems(Array.isArray(list) ? list : [])
setTotal(res?.data?.total || 0)
}
useEffect(()=>{ load() }, [page, size])
return (
<section className="panel">
<h2></h2>
<div className="desc">//</div>
<div style={{display:'flex', gap:8, alignItems:'center'}}>
<input placeholder="user_id" value={userId} onChange={e=>setUserId(e.target.value)} />
<select value={enabled} onChange={e=>setEnabled(e.target.value)}>
<option value="">enabled=</option>
<option value="true">enabled=true</option>
<option value="false">enabled=false</option>
</select>
<button className="btn" onClick={()=>{ setPage(1); load() }}></button>
</div>
<div className="panel" style={{marginTop:12}}>
<h3>{edit ? '编辑配额' : '创建配额'}</h3>
<div style={{display:'grid', gridTemplateColumns:'repeat(3, 1fr)', gap:8}}>
<input placeholder="user_id" value={edit ? edit.user_id : form.user_id} onChange={e=> edit? setEdit({...edit, user_id:e.target.value}) : setForm({...form, user_id:e.target.value}) } disabled={!!edit} />
<input placeholder="project_limit" type="number" value={edit? edit.project_limit : form.project_limit} onChange={e=> edit? setEdit({...edit, project_limit:Number(e.target.value)}) : setForm({...form, project_limit:Number(e.target.value)}) } />
<input placeholder="vm_limit" type="number" value={edit? edit.vm_limit : form.vm_limit} onChange={e=> edit? setEdit({...edit, vm_limit:Number(e.target.value)}) : setForm({...form, vm_limit:Number(e.target.value)}) } />
<input placeholder="process_limit" type="number" value={edit? edit.process_limit : form.process_limit} onChange={e=> edit? setEdit({...edit, process_limit:Number(e.target.value)}) : setForm({...form, process_limit:Number(e.target.value)}) } />
<label><input type="checkbox" checked={edit? !!edit.enabled : !!form.enabled} onChange={e=> edit? setEdit({...edit, enabled:e.target.checked}) : setForm({...form, enabled:e.target.checked}) } /> enabled</label>
<input placeholder="description" value={edit? (edit.description||'') : form.description} onChange={e=> edit? setEdit({...edit, description:e.target.value}) : setForm({...form, description:e.target.value}) } />
</div>
<div style={{display:'flex', gap:8, marginTop:8}}>
{!edit && (<button className="btn" onClick={async ()=>{ await QuotaApiService.createUserProjectQuota({ user_id:form.user_id, project_limit:form.project_limit, vm_limit:form.vm_limit, process_limit:form.process_limit, enabled:form.enabled, description:form.description }); setForm({ id:0, user_id:'', project_limit:0, vm_limit:0, process_limit:0, enabled:true, description:'' }); load() }}></button>)}
{edit && (<>
<button className="btn primary" onClick={async ()=>{ await QuotaApiService.updateUserProjectQuota(edit.id, { project_limit:edit.project_limit, vm_limit:edit.vm_limit, process_limit:edit.process_limit, enabled:edit.enabled, description:edit.description }); setEdit(null); load() }}></button>
<button className="btn" onClick={()=>setEdit(null)}></button>
</>)}
</div>
</div>
<div className="panel full" style={{marginTop:12}}>
<div className="table-container">
<table className="table">
<thead><tr><th>ID</th><th>user_id</th><th>project_limit</th><th>vm_limit</th><th>process_limit</th><th>enabled</th><th></th></tr></thead>
<tbody>
{items.map((it)=> (
<tr key={it.id}>
<td>{it.id}</td>
<td>{it.user_id}</td>
<td>{it.project_limit}</td>
<td>{it.vm_limit}</td>
<td>{it.process_limit}</td>
<td>{String(it.enabled)}</td>
<td>
<button className="btn" onClick={()=>setEdit({...it})}></button>
<button className="btn" onClick={async ()=>{ await QuotaApiService.deleteUserProjectQuota(it.id); load() }}></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
)
}

415
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,415 @@
// import { message } from 'antd'; // 暂时注释掉,未使用
import { useAtom } from 'jotai';
import { useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import { authStateAtom, authLoadingAtom } from '../atoms/auth';
import { SSOApiService } from '../services/api';
import type { AuthState } from '../atoms/auth';
export const useAuth = () => {
const [authState, setAuthState] = useAtom(authStateAtom);
const [loading, setLoading] = useAtom(authLoadingAtom);
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const refreshTimerRef = useRef<number | null>(null);
// 公共的token更新和定时器设置逻辑
const updateTokenAndSetTimer = (
tokenData: {
access_token: string;
refresh_token?: string;
expires_in?: number;
}
) => {
const { access_token, refresh_token, expires_in } = tokenData;
// 更新本地存储
localStorage.setItem('token', access_token);
if (refresh_token) {
localStorage.setItem('refreshToken', refresh_token);
}
// 更新状态
const updatedState: Partial<AuthState> = {
token: access_token,
};
if (refresh_token) {
updatedState.refreshToken = refresh_token;
}
setAuthState(prev => ({
...prev,
...updatedState,
}));
// 清除旧的定时器并设置新的定时器
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
if (expires_in) {
// 提前2分钟触发主动刷新
refreshTimerRef.current = setTimeout(
refreshToken,
expires_in * 1000 - 2 * 60000
);
}
};
// 跳转到登录页面若无URL则先请求再跳转
const redirectToLogin = async () => {
console.log('🔄 [Redirect] 开始跳转登录流程');
let loginUrl = authState.loginUrl;
console.log('🔍 [Redirect] 当前loginUrl:', loginUrl);
if (!loginUrl) {
console.log('🚀 [Redirect] 无登录URL获取新的登录URL');
try {
const resp = await SSOApiService.initiateLogin();
console.log('🚀 [Redirect] 登录URL响应:', {
success: resp?.success,
hasAuthUrl: !!resp?.auth_url,
authUrl: resp?.auth_url
});
if (resp?.auth_url) {
loginUrl = resp.auth_url;
setAuthState(prev => ({ ...prev, loginUrl }));
}
} catch (e) {
console.error('❌ [Redirect] 获取登录URL失败:', e);
}
}
if (loginUrl && loginUrl.startsWith('http')) {
console.log('🔄 [Redirect] 跳转到SSO登录页面:', loginUrl);
window.location.href = loginUrl;
} else {
console.error('❌ [Redirect] 无效的登录URL:', loginUrl);
}
};
// token 刷新
const refreshToken = async () => {
if (!authState.refreshToken) {
console.warn('No refresh token available');
return;
}
try {
const response = await SSOApiService.refreshToken(authState.refreshToken);
if (response?.access_token) {
updateTokenAndSetTimer({
access_token: response.access_token,
refresh_token: response.refresh_token,
expires_in: response.expires_in,
});
}
} catch (error) {
console.error('Token refresh failed:', error);
// 刷新失败,重新登录
logout();
}
};
// SSO登录
const ssoLogin = async (code?: string, state?: string) => {
try {
console.log('🔐 [SSO] 开始SSO登录流程', { hasCode: !!code, hasState: !!state });
setLoading(true);
if (code && state) {
console.log('📞 [SSO] 处理SSO回调调用 /api/sso/callback');
// 有code和state进行回调处理
const response = await SSOApiService.callback(code, state);
console.log('📞 [SSO] 回调响应:', {
success: response?.success,
hasToken: !!response?.access_token,
hasUserInfo: !!response?.user_info
});
if (response?.access_token) {
console.log('🔑 [SSO] 获取到access_token更新本地状态');
updateTokenAndSetTimer({
access_token: response.access_token,
refresh_token: response.refresh_token,
expires_in: response.expires_in,
});
// 获取用户信息
if (response.user_info) {
console.log('👤 [SSO] 回调中包含用户信息,直接设置');
setAuthState(prev => ({
...prev,
user: response.user_info || null,
isAuthenticated: true,
}));
localStorage.setItem('user', JSON.stringify(response.user_info));
// 角色判断:若非 sys_admin跳转无权限
try {
const rolesRaw: any = (response as any)?.user_info?.roles || [];
const roleNames: string[] = Array.isArray(rolesRaw)
? rolesRaw.map((r: any) => (typeof r === 'string' ? r : r?.name)).filter(Boolean)
: [];
const isSysAdmin = roleNames.includes('sys_admin');
console.log('🔎 [SSO] 角色检测:', { roleNames, isSysAdmin });
if (!isSysAdmin) {
console.warn('🚫 [Permission] 非 sys_admin跳转 /no-permission');
navigate('/no-permission', { replace: true });
}
} catch (e) {
console.warn('⚠️ [SSO] 角色解析失败:', e);
}
} else {
console.log('👤 [SSO] 回调中无用户信息,调用 /api/sso/userinfo');
await getCurrentUser();
}
}
} else {
console.log('🚀 [SSO] 获取登录URL调用 /api/sso/login');
// 无code和state获取登录URL
const response = await SSOApiService.initiateLogin();
console.log('🚀 [SSO] 登录URL响应:', {
success: response?.success,
hasAuthUrl: !!response?.auth_url,
authUrl: response?.auth_url
});
if (response?.auth_url) {
setAuthState(prev => ({
...prev,
loginUrl: response.auth_url,
}));
}
}
} catch (error) {
console.error('❌ [SSO] SSO登录失败:', error);
throw error;
} finally {
setLoading(false);
}
};
// 获取当前用户信息
const getCurrentUser = async () => {
try {
setLoading(true);
const response = await SSOApiService.userInfo();
if (response?.user_info) {
setAuthState(prev => ({
...prev,
user: response.user_info,
isAuthenticated: true,
}));
localStorage.setItem('user', JSON.stringify(response.user_info));
// 角色判断:若非 sys_admin跳转无权限
try {
const rolesRaw: any = (response as any)?.user_info?.roles || [];
const roleNames: string[] = Array.isArray(rolesRaw)
? rolesRaw.map((r: any) => (typeof r === 'string' ? r : r?.name)).filter(Boolean)
: [];
const isSysAdmin = roleNames.includes('sys_admin');
console.log('🔎 [Auth] 角色检测:', { roleNames, isSysAdmin });
if (!isSysAdmin) {
console.warn('🚫 [Permission] 非 sys_admin跳转 /no-permission');
navigate('/no-permission', { replace: true });
}
} catch (e) {
console.warn('⚠️ [Auth] 角色解析失败:', e);
}
}
} catch (error) {
console.error('Get current user failed:', error);
throw error;
} finally {
setLoading(false);
}
};
// 初始化认证状态
const initAuth = async () => {
try {
console.log('🔐 [Auth] 开始初始化认证状态');
setLoading(true);
// 检查URL中的code参数
const code = searchParams.get('code');
const state = searchParams.get('state');
console.log('🔍 [Auth] 检查URL参数:', { code: !!code, state: !!state });
if (code && state) {
console.log('📞 [Auth] 检测到SSO回调参数开始处理回调');
// 如果有code参数进行单点登录
try {
await ssoLogin(code, state);
setSearchParams({});
console.log('✅ [Auth] SSO回调处理完成');
} catch (error) {
console.error('❌ [Auth] SSO回调处理失败:', error);
}
return;
}
// 检查本地存储的token
const token = localStorage.getItem('token');
const refreshToken = localStorage.getItem('refreshToken');
const userStr = localStorage.getItem('user');
console.log('💾 [Auth] 检查本地存储:', {
hasToken: !!token,
hasRefreshToken: !!refreshToken,
hasUser: !!userStr
});
if (token && userStr) {
console.log('🔑 [Auth] 发现本地token开始验证有效性');
try {
const user = JSON.parse(userStr);
setAuthState(prev => ({
...prev,
user,
token,
refreshToken,
isAuthenticated: true, // 先标记为已认证避免触发SSO登录
}));
// 在后台验证token是否有效不阻塞用户使用
getCurrentUser().then(() => {
console.log('✅ [Auth] 后台Token验证成功');
}).catch(error => {
console.warn('⚠️ [Auth] 后台Token验证失败但不影响用户使用:', error);
// 可以选择是否要处理验证失败的情况
});
console.log('✅ [Auth] 本地token验证成功用户已认证');
return;
} catch (error) {
console.error('❌ [Auth] 用户数据解析失败:', error);
}
}
// 如果没有code也没有有效token请求登录URL
console.log('🚀 [Auth] 无有效认证信息开始获取登录URL');
await ssoLogin();
} catch (error) {
console.error('❌ [Auth] 初始化认证失败:', error);
} finally {
setLoading(false);
}
};
// 登出
const logout = async () => {
try {
console.log('🚪 [Logout] 开始登出流程');
// 调用后端登出接口获取登出URL
let logoutUrl = null;
if (authState.token) {
try {
console.log('📞 [Logout] 调用后端登出接口 /api/sso/logout');
const res = await SSOApiService.logout();
console.log('📞 [Logout] 后端登出响应:', {
success: res?.success,
message: res?.message
});
// 检查返回的message是否为有效URL
if (res?.message && res.message.startsWith('http')) {
logoutUrl = res.message;
console.log('✅ [Logout] 使用后端返回的登出URL:', logoutUrl);
} else {
console.warn('⚠️ [Logout] 后端返回的message不是有效URL:', res.message);
}
} catch (error) {
console.warn('⚠️ [Logout] 后端登出失败,但继续清理本地状态:', error);
}
}
// 清理本地状态(必须)
console.log('🧹 [Logout] 清理本地状态');
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
// 清理定时器
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
// 使用后端返回的登出URL如果没有则使用默认的
if (logoutUrl) {
console.log('✅ [Logout] 登出完成跳转到SSO登出页面');
console.log('🔄 [Logout] 登出URL:', logoutUrl);
window.location.href = logoutUrl;
} else {
console.log('✅ [Logout] 登出完成,跳转到当前域名');
// 如果没有有效的登出URL跳转到当前域名让应用重新走认证流程
window.location.href = window.location.origin;
}
} catch (error) {
console.error('❌ [Logout] 登出失败:', error);
// 即使登出失败,也要清理本地状态
console.log('🧹 [Logout] 清理本地状态(异常情况)');
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
setAuthState({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
loginUrl: null,
});
// 跳转到登录页面
console.log('🔄 [Logout] 异常情况,跳转到登录页面');
await redirectToLogin();
}
};
// 处理业务接口被动触发的token刷新和重新登录问题
useEffect(() => {
const handleTokenRefreshed = (event: CustomEvent) => {
const { access_token, refresh_token, expires_in } = event.detail;
updateTokenAndSetTimer({
access_token,
refresh_token,
expires_in,
});
};
const handleRelogin = () => {
logout();
};
window.addEventListener('auth:tokenRefreshed', handleTokenRefreshed as EventListener);
window.addEventListener('auth:relogin', handleRelogin);
return () => {
window.removeEventListener('auth:tokenRefreshed', handleTokenRefreshed as EventListener);
window.removeEventListener('auth:relogin', handleRelogin);
};
}, []);
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
};
}, []);
return {
...authState,
loading,
initAuth,
redirectToLogin,
refreshToken,
getCurrentUser,
logout,
};
};

View File

@@ -0,0 +1,74 @@
import { useAtom } from 'jotai';
import { authStateAtom } from '../atoms/auth';
export const usePagePermissions = () => {
const [authState] = useAtom(authStateAtom);
const hasPagePermission = (pagePath: string, action: string = 'read'): boolean => {
console.log(`🔐 [usePagePermissions] 检查页面权限 "${pagePath}:${action}"`);
console.log(`🔐 [usePagePermissions] 用户数据:`, authState.user);
// 检查用户是否有页面数据
if (!authState.user?.pages) {
console.log(`🔐 [usePagePermissions] 用户页面数据为空:`, authState.user);
return false;
}
// 根据页面路径和激活状态检查权限
const hasAccess = authState.user.pages.some(page =>
page.path === pagePath && page.is_active !== false
);
console.log(`🔐 [usePagePermissions] 页面权限检查结果: ${hasAccess}, 用户可访问页面:`,
authState.user.pages.map(p => ({ path: p.path, isActive: p.is_active })));
return hasAccess;
};
const hasPageAction = (pagePath: string, action: string): boolean => {
return hasPagePermission(pagePath, action);
};
const getPageActions = (pagePath: string): string[] => {
console.log(`🔐 [usePagePermissions] 获取页面操作权限 "${pagePath}"`);
if (!authState.user?.pages) {
console.log(`🔐 [usePagePermissions] 用户页面数据为空`);
return [];
}
// 检查用户是否有该页面的访问权限
const hasAccess = authState.user.pages.some(page => page.path === pagePath);
if (!hasAccess) {
console.log(`🔐 [usePagePermissions] 用户无该页面访问权限`);
return [];
}
// 简化版本:如果有页面访问权限,返回基本操作
return ['read', 'create', 'update', 'delete'];
};
const getAccessiblePages = (): string[] => {
console.log(`🔐 [usePagePermissions] 获取用户可访问页面`);
console.log(`🔐 [usePagePermissions] 用户数据:`, authState.user);
if (!authState.user?.pages) {
console.log(`🔐 [usePagePermissions] 用户页面数据为空`);
return [];
}
// 只返回激活状态的页面路径
const pages = authState.user.pages
.filter(page => page.is_active !== false)
.map(page => page.path);
console.log(`🔐 [usePagePermissions] 用户可访问页面:`, pages);
return pages;
};
return {
hasPagePermission,
hasPageAction,
getPageActions,
getAccessiblePages
};
};

View File

@@ -0,0 +1,46 @@
import { useAtom } from 'jotai';
import { authStateAtom } from '../atoms/auth';
export const usePermissions = () => {
const [authState] = useAtom(authStateAtom);
const hasPermission = (permission: string): boolean => {
if (!authState.user?.permissions) {
console.log(`🔐 [usePermissions] 用户权限数据为空:`, authState.user);
return false;
}
const hasAccess = authState.user.permissions.includes(permission);
console.log(`🔐 [usePermissions] 检查权限 "${permission}": ${hasAccess}, 用户权限:`, authState.user.permissions);
return hasAccess;
};
const hasAnyPermission = (permissions: string[]): boolean => {
return permissions.some(permission => hasPermission(permission));
};
const hasAllPermissions = (permissions: string[]): boolean => {
return permissions.every(permission => hasPermission(permission));
};
const hasRole = (roleName: string): boolean => {
if (!authState.user?.roles) return false;
return authState.user.roles.includes(roleName);
};
const hasAnyRole = (roleNames: string[]): boolean => {
return roleNames.some(roleName => hasRole(roleName));
};
const hasAllRoles = (roleNames: string[]): boolean => {
return roleNames.every(roleName => hasRole(roleName));
};
return {
hasPermission,
hasAnyPermission,
hasAllPermissions,
hasRole,
hasAnyRole,
hasAllRoles
};
};

1
src/index.css Normal file
View File

@@ -0,0 +1 @@
/* 基础样式重置 - 不干扰 App.css 的设计 */

28
src/main.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'jotai'
import { BrowserRouter } from 'react-router-dom'
import './App.css'
import 'antd/dist/reset.css'
import App from './App.tsx'
import AuthGuard from './components/AuthGuard'
import { migrateStorage } from './utils/storageMigration'
// 执行存储迁移
migrateStorage();
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
const root = createRoot(rootElement)
root.render(
<StrictMode>
<Provider>
<BrowserRouter>
<AuthGuard>
<App />
</AuthGuard>
</BrowserRouter>
</Provider>
</StrictMode>
)

315
src/pages/AuditLogs.tsx Normal file
View File

@@ -0,0 +1,315 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Card, Table, Tag, Form, Select, Input, DatePicker, Button, Space, Typography, Tooltip, message, Row, Col } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs, { Dayjs } from 'dayjs';
import { AuditLogApiService } from '../services/auditLogApi';
import type { AuditLog, AuditLogListRequest } from '../types/auditLog';
const { RangePicker } = DatePicker;
const { Text } = Typography;
const OPERATION_TYPE_LABELS: Record<string, string> = {
balance_adjustment: '余额调整',
user_level_change: '用户等级修改',
user_status_change: '用户状态变更',
model_price_change: '模型价格修改',
permission_change: '权限变更',
config_change: '配置修改',
};
const STATUS_TAG_PROPS: Record<string, { color: string; text: string }> = {
success: { color: 'green', text: '成功' },
failed: { color: 'red', text: '失败' },
};
interface FilterValues {
operation_type?: string;
operator_email?: string;
target_email?: string;
time_range?: [Dayjs, Dayjs];
}
const formatKey = (key: string) => key.replace(/_/g, ' ');
const renderDetailValue = (value: any): React.ReactNode => {
if (value === null || value === undefined) {
return '-';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
if (typeof value === 'number') {
return value.toLocaleString();
}
return String(value);
};
const renderOperationDetails = (details?: Record<string, any> | null) => {
if (!details || Object.keys(details).length === 0) {
return <Text type="secondary">-</Text>;
}
return (
<div style={{ lineHeight: 1.6 }}>
{Object.entries(details).map(([key, value]) => (
<div key={key}>
<Text strong>{formatKey(key)}:</Text> <Text>{renderDetailValue(value)}</Text>
</div>
))}
</div>
);
};
const AuditLogs: React.FC = () => {
const [form] = Form.useForm<FilterValues>();
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<AuditLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [filters, setFilters] = useState<AuditLogListRequest>({});
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const params: AuditLogListRequest = {
...filters,
page,
size: pageSize,
};
const data = await AuditLogApiService.getAuditLogs(params);
setLogs(data.list ?? []);
setTotal(data.total ?? 0);
if (data.page) setPage(data.page);
if (data.size) setPageSize(data.size);
} catch (error: any) {
console.error('获取审计日志失败', error);
message.error(error?.response?.data?.message || '获取审计日志失败');
} finally {
setLoading(false);
}
}, [filters, page, pageSize]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const handleSearch = useCallback(() => {
form
.validateFields()
.then(values => {
const nextFilters: AuditLogListRequest = {};
if (values.operation_type) {
nextFilters.operation_type = values.operation_type;
}
if (values.operator_email) {
nextFilters.operator_email = values.operator_email.trim();
}
if (values.target_email) {
nextFilters.target_email = values.target_email.trim();
}
if (values.time_range && values.time_range.length === 2) {
nextFilters.start_time = values.time_range[0].format('YYYY-MM-DD HH:mm:ss');
nextFilters.end_time = values.time_range[1].format('YYYY-MM-DD HH:mm:ss');
}
setPage(1);
setFilters(nextFilters);
})
.catch(() => {
/* ignore validation errors */
});
}, [form]);
const handleReset = useCallback(() => {
form.resetFields();
setPage(1);
setPageSize(20);
setFilters({});
}, [form]);
const columns: ColumnsType<AuditLog> = useMemo(() => [
{
title: '操作时间',
dataIndex: 'operation_time',
key: 'operation_time',
width: 180,
render: (value: string) => {
const formatted = value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-';
return <Text>{formatted}</Text>;
},
sorter: false,
},
{
title: '操作类型',
dataIndex: 'operation_type',
key: 'operation_type',
width: 140,
render: (value: string) => {
const label = OPERATION_TYPE_LABELS[value] || value || '-';
return <Tag color="blue">{label}</Tag>;
},
},
{
title: '操作对象',
dataIndex: 'target_email',
key: 'target_email',
width: 200,
render: (value: string | null, record) => (
<div>
<div>{value || '-'}</div>
{record.target_type && (
<Text type="secondary" style={{ fontSize: 12 }}>
{record.target_type}{record.target_id ? ` #${record.target_id}` : ''}
</Text>
)}
</div>
),
},
{
title: '操作详情',
dataIndex: 'operation_details',
key: 'operation_details',
width: 260,
render: (details: Record<string, any> | null) => renderOperationDetails(details),
},
{
title: '操作人',
dataIndex: 'operator_email',
key: 'operator_email',
width: 200,
render: (value: string, record) => (
<div>
<div>{value || '-'}</div>
<Text type="secondary" style={{ fontSize: 12 }}>ID: {record.operator_id ?? '-'}</Text>
</div>
),
},
{
title: 'IP / UA',
dataIndex: 'ip_address',
key: 'ip_address',
width: 220,
render: (value: string | null, record) => (
<div>
<div>{value || '-'}</div>
{record.user_agent && (
<Tooltip title={record.user_agent} placement="topLeft">
<Text type="secondary" ellipsis style={{ maxWidth: 200, display: 'inline-block' }}>
{record.user_agent}
</Text>
</Tooltip>
)}
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (value: string | null, record) => {
const statusKey = value ? value.toLowerCase() : 'success';
const tagProps = STATUS_TAG_PROPS[statusKey] || { color: 'default', text: value || '未知' };
return (
<Space direction="vertical" size={4}>
<Tag color={tagProps.color}>{tagProps.text}</Tag>
{record.error_message && (
<Tooltip title={record.error_message}>
<Text type="danger" ellipsis style={{ maxWidth: 180 }}>
{record.error_message}
</Text>
</Tooltip>
)}
</Space>
);
},
},
], []);
return (
<div style={{ padding: 24 }}>
<Card bordered={false} style={{ marginBottom: 16 }}>
<Form
form={form}
layout="vertical"
initialValues={{
operation_type: undefined,
operator_email: '',
target_email: '',
time_range: undefined,
}}
>
<Row gutter={16}>
<Col span={6}>
<Form.Item label="操作类型" name="operation_type">
<Select
placeholder="请选择操作类型"
allowClear
options={Object.entries(OPERATION_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="操作人邮箱" name="operator_email">
<Input placeholder="请输入操作人邮箱" allowClear />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="操作对象邮箱" name="target_email">
<Input placeholder="请输入操作对象邮箱" allowClear />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="时间范围" name="time_range">
<RangePicker
style={{ width: '100%' }}
showTime
allowEmpty={[true, true]}
format="YYYY-MM-DD HH:mm:ss"
ranges={{
: [dayjs().startOf('day'), dayjs().endOf('day')],
7: [dayjs().subtract(6, 'day').startOf('day'), dayjs().endOf('day')],
30: [dayjs().subtract(29, 'day').startOf('day'), dayjs().endOf('day')],
}}
/>
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Space>
<Button type="primary" onClick={handleSearch}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
</Form>
</Card>
<Card bordered={false}>
<Table<AuditLog>
rowKey="id"
loading={loading}
columns={columns}
dataSource={logs}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: totalValue => `${totalValue} 条记录`,
onChange: (nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize || 20);
},
}}
scroll={{ x: 1200 }}
/>
</Card>
</div>
);
};
export default AuditLogs;

42
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,42 @@
import React from 'react';
const Dashboard: React.FC = () => {
return (
<section className="panel">
<h2></h2>
<div className="desc"></div>
<div className="kpis">
<div className="kpi">
<h3></h3>
<div className="num">1,234</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">567</div>
</div>
<div className="kpi">
<h3>Token消耗</h3>
<div className="num">12.5M</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num" style={{color: 'var(--success)'}}></div>
</div>
</div>
<div className="flex-2">
<div className="panel">
<h2>7</h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2>使</h2>
<div className="chart">使</div>
</div>
</div>
</section>
);
};
export default Dashboard;

47
src/pages/Finance.tsx Normal file
View File

@@ -0,0 +1,47 @@
import React from 'react';
const Finance: React.FC = () => {
return (
<section className="panel">
<h2></h2>
<div className="desc"></div>
<div className="kpis">
<div className="kpi">
<h3></h3>
<div className="num">¥123,456</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">¥78,901</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">¥44,555</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">36.1%</div>
</div>
</div>
<div className="flex-2">
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
</div>
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
</section>
);
};
export default Finance;

View File

@@ -0,0 +1,544 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space, Select, Modal, InputNumber, message, Popconfirm, Tabs, Drawer } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
import dayjs from 'dayjs';
const { TabPane } = Tabs;
const FinanceMcpAccountRechargeRecords: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const [modalVisible, setModalVisible] = useState(false);
const [modalForm] = Form.useForm();
const [providerAccounts, setProviderAccounts] = useState<any[]>([]);
const [loadingAccounts, setLoadingAccounts] = useState(false);
const [activeTab, setActiveTab] = useState('recharge-records');
const [balances, setBalances] = useState<any[]>([]);
const [loadingBalances, setLoadingBalances] = useState(false);
const [balanceHistoryVisible, setBalanceHistoryVisible] = useState(false);
const [balanceHistory, setBalanceHistory] = useState<any[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [balanceModalVisible, setBalanceModalVisible] = useState(false);
const [balanceModalMode, setBalanceModalMode] = useState<'create' | 'edit'>('create');
const [editingBalanceRecord, setEditingBalanceRecord] = useState<any>(null);
const [balanceForm] = Form.useForm();
const [updatingBalance, setUpdatingBalance] = useState(false);
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return {
page: p,
size: s,
provider: vals.provider,
account: vals.account,
start,
end,
} as any;
};
const fetchList = useCallback(async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listMcpAccountRechargeRecords(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取数据失败');
} finally {
setLoading(false);
}
}, [form, message, page, size]);
const fetchProviderAccounts = useCallback(async () => {
setLoadingAccounts(true);
try {
const res = await FinanceApiService.getMcpProviderAccounts({ status: 'active', is_used: true });
const accounts = res?.data || res || [];
setProviderAccounts(accounts);
} catch (e: any) {
message.error('获取账号列表失败');
} finally {
setLoadingAccounts(false);
}
}, []);
const fetchBalances = useCallback(async () => {
setLoadingBalances(true);
try {
const res = await FinanceApiService.getMcpAccountBalances();
const balanceList = res?.data || res || [];
setBalances(balanceList);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取余额数据失败');
} finally {
setLoadingBalances(false);
}
}, []);
const fetchBalanceHistory = useCallback(async (providerId: string) => {
setLoadingHistory(true);
try {
const res = await FinanceApiService.getMcpAccountBalanceHistory(providerId);
const history = res?.data || res || [];
setBalanceHistory(history);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取余额历史失败');
} finally {
setLoadingHistory(false);
}
}, []);
useEffect(() => {
fetchProviderAccounts();
}, [fetchProviderAccounts]);
useEffect(() => {
if (activeTab === 'recharge-records') {
fetchList();
} else if (activeTab === 'balances') {
fetchBalances();
}
}, [activeTab, fetchBalances, fetchList]);
useEffect(() => {
if (activeTab === 'recharge-records') {
fetchList(page, size);
}
}, [activeTab, page, size, fetchList]);
const handleAdd = () => {
modalForm.resetFields();
modalForm.setFieldsValue({
recharge_date: dayjs(),
});
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await FinanceApiService.deleteMcpAccountRechargeRecord(id);
message.success('删除成功');
fetchList(page, size);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '删除失败');
}
};
const handleModalOk = async () => {
try {
const values = await modalForm.validateFields();
const payload = {
provider_id: values.provider_id,
amount: values.amount,
recharge_date: values.recharge_date.format('YYYY-MM-DD'),
remark: values.remark || '',
};
await FinanceApiService.createMcpAccountRechargeRecord(payload);
message.success('创建成功');
setModalVisible(false);
fetchList(page, size);
if (activeTab === 'balances') {
fetchBalances();
}
} catch (e: any) {
if (e?.errorFields) {
// 表单验证错误,不显示错误消息
return;
}
message.error(e?.response?.data?.message || e?.message || '操作失败');
}
};
const handleModalCancel = () => {
setModalVisible(false);
modalForm.resetFields();
};
const columns: ColumnsType<any> = useMemo(() => [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 100, ellipsis: true },
{ title: '服务商', dataIndex: 'provider', key: 'provider', width: 150 },
{ title: '账号', dataIndex: 'account', key: 'account', width: 200 },
{
title: '充值金额',
dataIndex: 'amount',
key: 'amount',
width: 120,
render: (amount: number, record: any) => {
if (amount === null || amount === undefined) return '-';
const currency = record.currency || 'USD';
return `$${amount.toFixed(2)} ${currency}`;
},
},
{
title: '充值日期',
dataIndex: 'recharge_date',
key: 'recharge_date',
width: 120,
},
{ title: '操作人', dataIndex: 'operator_name', key: 'operator_name', width: 120 },
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 200, ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 120,
render: (_: any, record: any) => (
<Popconfirm
title="确认删除这条记录?"
onConfirm={() => handleDelete(record.id)}
okText="确认"
cancelText="取消"
>
<Button type="link" danger size="small">
</Button>
</Popconfirm>
),
},
], []);
const accountOptions = useMemo(() => {
return providerAccounts.map((acc: any) => {
const label = acc.account ? `${acc.provider} - ${acc.account}` : acc.provider;
return {
label,
value: acc.id,
provider: acc.provider,
account: acc.account,
};
});
}, [providerAccounts]);
const handleViewBalanceHistory = useCallback((providerId: string) => {
setBalanceHistoryVisible(true);
fetchBalanceHistory(providerId);
}, [fetchBalanceHistory]);
const balanceColumns: ColumnsType<any> = useMemo(() => [
{ title: '服务商', dataIndex: 'provider', key: 'provider', width: 150 },
{ title: '账号', dataIndex: 'account', key: 'account', width: 200 },
{
title: '当前余额',
dataIndex: 'balance',
key: 'balance',
width: 150,
render: (balance: number, record: any) => {
if (balance === null || balance === undefined) return '-';
const currency = record.currency || 'USD';
return `$${balance.toFixed(2)} ${currency}`;
},
},
{
title: '更新时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
},
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 220, ellipsis: true },
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 200,
render: (_: any, record: any) => (
<Space>
<Button type="link" size="small" onClick={() => handleViewBalanceHistory(record.provider_id)}>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setBalanceModalMode('edit');
setEditingBalanceRecord(record);
balanceForm.setFieldsValue({
provider_id: record.provider_id,
balance: record.balance ?? 0,
remark: '',
});
setBalanceModalVisible(true);
}}
>
</Button>
</Space>
),
},
], [balanceForm, handleViewBalanceHistory]);
const balanceHistoryColumns: ColumnsType<any> = useMemo(() => [
{
title: '余额',
dataIndex: 'balance',
key: 'balance',
width: 150,
render: (balance: number, record: any) => {
if (balance === null || balance === undefined) return '-';
const currency = record.currency || 'USD';
return `$${balance.toFixed(2)} ${currency}`;
},
},
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 220, ellipsis: true },
{ title: '更新时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
], []);
return (
<>
<Card
bordered={false}
title={<Typography.Title level={4} style={{ margin: 0 }}>MCP账号管理</Typography.Title>}
>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab="充值记录" key="recharge-records">
<Space style={{ marginBottom: 16 }}>
<Form form={form} onFinish={() => fetchList(1, size)} layout="inline">
<Form.Item name="provider" label="服务商">
<Input allowClear placeholder="服务商" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="account" label="账号">
<Input allowClear placeholder="账号" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="充值日期范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}>
</Button>
<Button
onClick={() => {
form.resetFields();
fetchList(1, size);
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Button type="primary" onClick={handleAdd}>
</Button>
</Space>
<Table
rowKey={(r) => r.id}
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
pageSize: size,
total,
onChange: (p, s) => {
setPage(p);
setSize(s);
fetchList(p, s);
},
}}
scroll={{ x: 1400 }}
/>
</TabPane>
<TabPane tab="账户余额" key="balances">
<Space style={{ marginBottom: 16 }}>
<Button onClick={fetchBalances}></Button>
<Button
type="primary"
onClick={() => {
balanceForm.resetFields();
setBalanceModalMode('create');
setEditingBalanceRecord(null);
setBalanceModalVisible(true);
}}
>
</Button>
</Space>
<Table
rowKey={(r) => r.provider_id || r.id}
columns={balanceColumns}
dataSource={balances}
loading={loadingBalances}
scroll={{ x: 1000 }}
/>
</TabPane>
</Tabs>
</Card>
<Drawer
title="余额历史"
placement="right"
width={800}
open={balanceHistoryVisible}
onClose={() => {
setBalanceHistoryVisible(false);
setBalanceHistory([]);
}}
>
<Table
rowKey={(r) => r.id}
columns={balanceHistoryColumns}
dataSource={balanceHistory}
loading={loadingHistory}
pagination={false}
scroll={{ x: 800 }}
/>
</Drawer>
<Modal
title="添加充值记录"
open={modalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
width={600}
>
<Form form={modalForm} layout="vertical">
<Form.Item
name="provider_id"
label="账号"
rules={[{ required: true, message: '请选择账号' }]}
>
<Select
placeholder="请选择账号"
loading={loadingAccounts}
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={accountOptions}
/>
</Form.Item>
<Form.Item
name="amount"
label="充值金额(美元)"
rules={[
{ required: true, message: '请输入充值金额' },
{ type: 'number', min: 0.01, message: '金额必须大于0' },
]}
>
<InputNumber
style={{ width: '100%' }}
placeholder="请输入充值金额"
precision={2}
min={0.01}
step={0.01}
/>
</Form.Item>
<Form.Item
name="recharge_date"
label="充值日期"
rules={[{ required: true, message: '请选择充值日期' }]}
>
<DatePicker
style={{ width: '100%' }}
format="YYYY-MM-DD"
disabledDate={(current) => current && current > dayjs().endOf('day')}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="请输入备注(可选)" />
</Form.Item>
</Form>
</Modal>
<Modal
title={balanceModalMode === 'create' ? '新增余额记录' : '调整余额'}
open={balanceModalVisible}
confirmLoading={updatingBalance}
onOk={async () => {
try {
const values = await balanceForm.validateFields();
setUpdatingBalance(true);
if (balanceModalMode === 'create') {
await FinanceApiService.createMcpAccountBalance({
provider_id: values.provider_id,
balance: values.balance,
remark: values.remark || '',
});
message.success('新增成功');
} else if (editingBalanceRecord) {
const providerId = editingBalanceRecord.provider_id || editingBalanceRecord.providerId;
await FinanceApiService.adjustMcpAccountBalance(providerId, {
balance: values.balance,
remark: values.remark || '',
});
message.success('调整成功');
}
setBalanceModalVisible(false);
balanceForm.resetFields();
setEditingBalanceRecord(null);
setBalanceModalMode('create');
fetchBalances();
} catch (e: any) {
if (e?.errorFields) {
return;
}
message.error(e?.response?.data?.message || e?.message || '操作失败');
} finally {
setUpdatingBalance(false);
}
}}
onCancel={() => {
setBalanceModalVisible(false);
balanceForm.resetFields();
setEditingBalanceRecord(null);
setBalanceModalMode('create');
}}
width={520}
destroyOnClose
>
<Form form={balanceForm} layout="vertical">
<Form.Item
name="provider_id"
label="账号"
rules={[{ required: true, message: '请选择账号' }]}
>
<Select
placeholder="请选择账号"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={accountOptions}
disabled={balanceModalMode === 'edit'}
/>
</Form.Item>
<Form.Item
name="balance"
label="余额(美元)"
rules={[
{ required: true, message: '请输入余额' },
{ type: 'number', min: 0, message: '余额不能小于0' },
]}
>
<InputNumber
style={{ width: '100%' }}
precision={8}
min={0}
step={0.01}
placeholder="请输入余额"
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="请输入备注(可选)" />
</Form.Item>
</Form>
</Modal>
</>
);
};
export default FinanceMcpAccountRechargeRecords;

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
const FinanceMcpUsage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return { page: p, size: s, user_id: vals.user_id, project: vals.project, start, end } as any;
};
const fetchList = async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listMcpUsages(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchList(); }, []);
const columns: ColumnsType<any> = useMemo(() => ([
{ title: '用户id', dataIndex: 'user_id', key: 'user_id' },
{ title: 'API分组', dataIndex: 'api_group', key: 'api_group' },
{ title: '对话id', dataIndex: 'project_id', key: 'project_id' },
{ title: '日期', dataIndex: 'day', key: 'day' },
{ title: '小时', dataIndex: 'hour', key: 'hour' },
{ title: '服务商', dataIndex: 'provider', key: 'provider' },
{ title: '账号', dataIndex: 'account', key: 'account' },
{ title: '模型', dataIndex: 'model', key: 'model' },
{ title: '代理类型', dataIndex: 'agent_type', key: 'agent_type' },
{ title: '任务id', dataIndex: 'chat_id', key: 'chat_id' },
{ title: '花费金额', dataIndex: 'cost', key: 'cost' },
{ title: '调用次数', dataIndex: 'call_count', key: 'call_count' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
{ title: '修改时间', dataIndex: 'updated_at', key: 'updated_at' },
]), []);
return (
<Card bordered={false} title={<Typography.Title level={4} style={{ margin: 0 }}>MCP </Typography.Title>}>
<Form form={form} layout="inline" onFinish={() => fetchList(1, size)} style={{ marginBottom: 12 }}>
<Form.Item name="user_id" label="用户id">
<Input allowClear placeholder="用户id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="project" label="对话id">
<Input allowClear placeholder="对话id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="时间范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}></Button>
<Button onClick={() => { form.resetFields(); fetchList(1, size); setPage(1); }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey={(r) => r.id || `${r.user_id}-${r.day}-${r.hour}-${r.model}-${r.account}`}
columns={columns}
dataSource={data}
loading={loading}
pagination={{ current: page, pageSize: size, total, onChange: (p, s) => { setPage(p); setSize(s); fetchList(p, s); } }}
scroll={{ x: 1000 }}
/>
</Card>
);
};
export default FinanceMcpUsage;

View File

@@ -0,0 +1,567 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space, Select, Modal, InputNumber, message, Popconfirm, Tabs, Drawer, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
import dayjs from 'dayjs';
const { TabPane } = Tabs;
const FinanceModelAccountRechargeRecords: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const [modalVisible, setModalVisible] = useState(false);
const [modalForm] = Form.useForm();
const [modelAccounts, setModelAccounts] = useState<any[]>([]);
const [loadingAccounts, setLoadingAccounts] = useState(false);
const [activeTab, setActiveTab] = useState('recharge-records');
const [balances, setBalances] = useState<any[]>([]);
const [loadingBalances, setLoadingBalances] = useState(false);
const [balanceHistoryVisible, setBalanceHistoryVisible] = useState(false);
const [balanceHistory, setBalanceHistory] = useState<any[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [balanceModalVisible, setBalanceModalVisible] = useState(false);
const [balanceModalMode, setBalanceModalMode] = useState<'create' | 'edit'>('create');
const [editingBalanceRecord, setEditingBalanceRecord] = useState<any>(null);
const [balanceForm] = Form.useForm();
const [updatingBalance, setUpdatingBalance] = useState(false);
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return {
page: p,
size: s,
provider: vals.provider,
account: vals.account,
start,
end,
} as any;
};
const fetchList = useCallback(async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listModelAccountRechargeRecords(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取数据失败');
} finally {
setLoading(false);
}
}, [form, page, size]);
const fetchModelAccounts = useCallback(async () => {
setLoadingAccounts(true);
try {
const res = await FinanceApiService.getModelConfigAccounts({ enabled: true });
const accounts = res?.data || res || [];
setModelAccounts(accounts);
} catch (e: any) {
message.error('获取模型账号列表失败');
} finally {
setLoadingAccounts(false);
}
}, []);
const fetchBalances = useCallback(async () => {
setLoadingBalances(true);
try {
const res = await FinanceApiService.getModelAccountBalances();
const balanceList = res?.data || res || [];
setBalances(balanceList);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取余额数据失败');
} finally {
setLoadingBalances(false);
}
}, []);
const fetchBalanceHistory = useCallback(async (account: string) => {
setLoadingHistory(true);
try {
const res = await FinanceApiService.getModelAccountBalanceHistory(account);
const history = res?.data || res || [];
setBalanceHistory(history);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取余额历史失败');
} finally {
setLoadingHistory(false);
}
}, []);
useEffect(() => {
fetchModelAccounts();
}, [fetchModelAccounts]);
useEffect(() => {
if (activeTab === 'recharge-records') {
fetchList();
} else if (activeTab === 'balances') {
fetchBalances();
}
}, [activeTab, fetchBalances, fetchList]);
useEffect(() => {
if (activeTab === 'recharge-records') {
fetchList(page, size);
}
}, [activeTab, page, size, fetchList]);
const handleAdd = () => {
modalForm.resetFields();
modalForm.setFieldsValue({
recharge_date: dayjs(),
});
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await FinanceApiService.deleteModelAccountRechargeRecord(id);
message.success('删除成功');
fetchList(page, size);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '删除失败');
}
};
const handleModalOk = async () => {
try {
const values = await modalForm.validateFields();
const payload = {
account: values.account,
amount: values.amount,
recharge_date: values.recharge_date.format('YYYY-MM-DD'),
remark: values.remark || '',
};
await FinanceApiService.createModelAccountRechargeRecord(payload);
message.success('创建成功');
setModalVisible(false);
fetchList(page, size);
if (activeTab === 'balances') {
fetchBalances();
}
} catch (e: any) {
if (e?.errorFields) {
return;
}
message.error(e?.response?.data?.message || e?.message || '操作失败');
}
};
const handleModalCancel = () => {
setModalVisible(false);
modalForm.resetFields();
};
const columns: ColumnsType<any> = useMemo(() => [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 100, ellipsis: true },
{ title: '账号名称', dataIndex: 'provider', key: 'provider', width: 180 },
{ title: '账号', dataIndex: 'account', key: 'account', width: 200 },
{
title: '充值金额',
dataIndex: 'amount',
key: 'amount',
width: 120,
render: (amount: number, record: any) => {
if (amount === null || amount === undefined) return '-';
const currency = record.currency || 'USD';
return `$${amount.toFixed(2)} ${currency}`;
},
},
{
title: '充值日期',
dataIndex: 'recharge_date',
key: 'recharge_date',
width: 120,
},
{ title: '操作人', dataIndex: 'operator_name', key: 'operator_name', width: 120 },
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 200, ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 120,
render: (_: any, record: any) => (
<Popconfirm
title="确认删除这条记录?"
onConfirm={() => handleDelete(record.id)}
okText="确认"
cancelText="取消"
>
<Button type="link" danger size="small">
</Button>
</Popconfirm>
),
},
], []);
const accountOptions = useMemo(() => {
return modelAccounts.map((acc: any) => {
const name = acc.name || acc.provider || acc.account || '未知账号';
const apiType = acc.api_type ? ` (${acc.api_type})` : '';
const label = `${name}${apiType} - ${acc.account || ''}`;
return {
label,
value: acc.account,
name: acc.name,
api_type: acc.api_type,
account: acc.account,
};
});
}, [modelAccounts]);
const handleViewBalanceHistory = useCallback((account: string) => {
setBalanceHistoryVisible(true);
fetchBalanceHistory(account);
}, [fetchBalanceHistory]);
const balanceColumns: ColumnsType<any> = useMemo(() => [
{ title: '账号名称', dataIndex: 'provider', key: 'provider', width: 180 },
{ title: '账号', dataIndex: 'account', key: 'account', width: 180 },
{
title: '当前余额',
dataIndex: 'balance',
key: 'balance',
width: 150,
render: (balance: number, record: any) => {
if (balance === null || balance === undefined) return '-';
const currency = record.currency || 'USD';
return `$${balance.toFixed(2)} ${currency}`;
},
},
{
title: '更新时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
},
{
title: '备注',
dataIndex: 'remark',
key: 'remark',
width: 220,
ellipsis: true,
render: (text: string) => (
<Tooltip title={text}>
<span>{text || '-'}</span>
</Tooltip>
),
},
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 160,
render: (_: any, record: any) => (
<Space>
<Button type="link" size="small" onClick={() => handleViewBalanceHistory(record.account)}>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setBalanceModalMode('edit');
setEditingBalanceRecord(record);
balanceForm.setFieldsValue({
account: record.account,
balance: record.balance ?? 0,
remark: '',
});
setBalanceModalVisible(true);
}}
>
</Button>
</Space>
),
},
], [balanceForm, handleViewBalanceHistory]);
const balanceHistoryColumns: ColumnsType<any> = useMemo(() => [
{
title: '余额',
dataIndex: 'balance',
key: 'balance',
width: 150,
render: (balance: number, record: any) => {
if (balance === null || balance === undefined) return '-';
const currency = record.currency || 'USD';
return `$${balance.toFixed(2)} ${currency}`;
},
},
{
title: '备注',
dataIndex: 'remark',
key: 'remark',
width: 220,
ellipsis: true,
render: (text: string) => (
<Tooltip title={text}>
<span>{text || '-'}</span>
</Tooltip>
),
},
{ title: '更新时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
], []);
return (
<>
<Card
bordered={false}
title={<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>}
>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab="充值记录" key="recharge-records">
<Space style={{ marginBottom: 16 }}>
<Form form={form} onFinish={() => fetchList(1, size)} layout="inline">
<Form.Item name="provider" label="账号名称">
<Input allowClear placeholder="服务商" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="account" label="账号">
<Input allowClear placeholder="账号" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="充值日期范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}>
</Button>
<Button
onClick={() => {
form.resetFields();
fetchList(1, size);
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Button type="primary" onClick={handleAdd}>
</Button>
</Space>
<Table
rowKey={(r) => r.id}
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
pageSize: size,
total,
onChange: (p, s) => {
setPage(p);
setSize(s);
fetchList(p, s);
},
}}
scroll={{ x: 1400 }}
/>
</TabPane>
<TabPane tab="账户余额" key="balances">
<Space style={{ marginBottom: 16 }}>
<Button onClick={fetchBalances}></Button>
<Button
type="primary"
onClick={() => {
balanceForm.resetFields();
setBalanceModalMode('create');
setEditingBalanceRecord(null);
setBalanceModalVisible(true);
}}
>
</Button>
</Space>
<Table
rowKey={(r) => r.account || r.id}
columns={balanceColumns}
dataSource={balances}
loading={loadingBalances}
scroll={{ x: 1000 }}
/>
</TabPane>
</Tabs>
</Card>
<Drawer
title="余额历史"
placement="right"
width={800}
open={balanceHistoryVisible}
onClose={() => {
setBalanceHistoryVisible(false);
setBalanceHistory([]);
}}
>
<Table
rowKey={(r) => r.id}
columns={balanceHistoryColumns}
dataSource={balanceHistory}
loading={loadingHistory}
pagination={false}
scroll={{ x: 800 }}
/>
</Drawer>
<Modal
title="添加充值记录"
open={modalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
width={600}
>
<Form form={modalForm} layout="vertical">
<Form.Item
name="account"
label="模型账号"
rules={[{ required: true, message: '请选择模型账号' }]}
>
<Select
placeholder="请选择模型账号"
loading={loadingAccounts}
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={accountOptions}
/>
</Form.Item>
<Form.Item
name="amount"
label="充值金额(美元)"
rules={[
{ required: true, message: '请输入充值金额' },
{ type: 'number', min: 0.01, message: '金额必须大于0' },
]}
>
<InputNumber
style={{ width: '100%' }}
placeholder="请输入充值金额"
precision={2}
min={0.01}
step={0.01}
/>
</Form.Item>
<Form.Item
name="recharge_date"
label="充值日期"
rules={[{ required: true, message: '请选择充值日期' }]}
>
<DatePicker
style={{ width: '100%' }}
format="YYYY-MM-DD"
disabledDate={(current) => current && current > dayjs().endOf('day')}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="请输入备注(可选)" />
</Form.Item>
</Form>
</Modal>
<Modal
title={balanceModalMode === 'create' ? '新增余额记录' : '调整余额'}
open={balanceModalVisible}
confirmLoading={updatingBalance}
onOk={async () => {
try {
const values = await balanceForm.validateFields();
setUpdatingBalance(true);
if (balanceModalMode === 'create') {
await FinanceApiService.createModelAccountBalance({
account: values.account,
balance: values.balance,
remark: values.remark || '',
});
message.success('新增成功');
} else if (editingBalanceRecord) {
await FinanceApiService.adjustModelAccountBalance(editingBalanceRecord.account, {
balance: values.balance,
remark: values.remark || '',
});
message.success('调整成功');
}
setBalanceModalVisible(false);
balanceForm.resetFields();
setEditingBalanceRecord(null);
setBalanceModalMode('create');
fetchBalances();
} catch (e: any) {
if (e?.errorFields) {
return;
}
message.error(e?.response?.data?.message || e?.message || '操作失败');
} finally {
setUpdatingBalance(false);
}
}}
onCancel={() => {
setBalanceModalVisible(false);
balanceForm.resetFields();
setEditingBalanceRecord(null);
setBalanceModalMode('create');
}}
width={520}
destroyOnClose
>
<Form form={balanceForm} layout="vertical">
<Form.Item
name="account"
label="模型账号"
rules={[{ required: true, message: '请选择模型账号' }]}
>
<Select
placeholder="请选择模型账号"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={accountOptions}
disabled={balanceModalMode === 'edit'}
/>
</Form.Item>
<Form.Item
name="balance"
label="余额(美元)"
rules={[
{ required: true, message: '请输入余额' },
{ type: 'number', min: 0, message: '余额不能小于0' },
]}
>
<InputNumber
style={{ width: '100%' }}
precision={8}
min={0}
step={0.01}
placeholder="请输入余额"
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="请输入备注(可选)" />
</Form.Item>
</Form>
</Modal>
</>
);
};
export default FinanceModelAccountRechargeRecords;

View File

@@ -0,0 +1,165 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space, Select, Popconfirm, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
const FinancePaymentRecords: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return {
page: p,
size: s,
user_id: vals.user_id,
order_id: vals.order_id,
paypal_order_id: vals.paypal_order_id,
status: vals.status,
refund_status: vals.refund_status,
payer_email: vals.payer_email,
start,
end,
} as any;
};
const fetchList = async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listPaymentRecords(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchList(); }, []);
const columns: ColumnsType<any> = useMemo(() => ([
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '用户ID', dataIndex: 'user_id', key: 'user_id', width: 120 },
{ title: '订单ID', dataIndex: 'order_id', key: 'order_id', width: 150 },
{ title: 'PayPal订单ID', dataIndex: 'paypal_order_id', key: 'paypal_order_id', width: 180 },
{ title: 'PayPal捕获ID', dataIndex: 'paypal_capture_id', key: 'paypal_capture_id', width: 180 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{
title: '支付金额',
dataIndex: 'amount',
key: 'amount',
width: 120,
render: (amount: number, record: any) => {
if (amount === null || amount === undefined) return '-';
const currency = record.currency || 'USD';
// amount 是最小货币单位,如美分,需要转换为实际金额
const actualAmount = amount / 100;
return `${actualAmount.toFixed(2)} ${currency}`;
}
},
{ title: '货币', dataIndex: 'currency', key: 'currency', width: 80 },
{ title: '付款人邮箱', dataIndex: 'payer_email', key: 'payer_email', width: 200 },
{ title: '付款人姓名', dataIndex: 'payer_name', key: 'payer_name', width: 150 },
{ title: '付款人ID', dataIndex: 'payer_id', key: 'payer_id', width: 150 },
{ title: '退款状态', dataIndex: 'refund_status', key: 'refund_status', width: 100 },
{
title: '已退款金额',
dataIndex: 'refunded_amount',
key: 'refunded_amount',
width: 120,
render: (amount: number, record: any) => {
if (amount === null || amount === undefined || amount === 0) return '-';
const currency = record.currency || 'USD';
const actualAmount = amount / 100;
return `${actualAmount.toFixed(2)} ${currency}`;
}
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180 },
{
title: '操作', key: 'actions', fixed: 'right', width: 140,
render: (_: any, record: any) => {
const canRefund = record?.status === 'COMPLETED' && record?.refund_status !== 'full';
if (!canRefund) return null;
const onRefund = async () => {
try {
message.loading({ content: '正在发起退款...', key: 'refund' });
await FinanceApiService.refundPaymentRecord({ order_id: record.order_id, paypal_capture_id: record.paypal_capture_id });
message.success({ content: '退款已提交', key: 'refund', duration: 2 });
fetchList(page, size);
} catch (e: any) {
const errorMsg = e?.response?.data?.message || e?.message || '退款失败';
message.error({ content: errorMsg, key: 'refund' });
}
};
return (
<Popconfirm title="确认对该订单发起退款?" onConfirm={onRefund} okText="确认" cancelText="取消">
<Button danger size="small">退</Button>
</Popconfirm>
);
}
}
]), []);
return (
<Card bordered={false} title={<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>}>
<Form form={form} layout="inline" onFinish={() => fetchList(1, size)} style={{ marginBottom: 12 }}>
<Form.Item name="user_id" label="用户ID">
<Input allowClear placeholder="用户ID" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="order_id" label="订单ID">
<Input allowClear placeholder="订单ID" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="paypal_order_id" label="PayPal订单ID">
<Input allowClear placeholder="PayPal订单ID" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="status" label="订单状态">
<Select allowClear placeholder="订单状态" style={{ width: 150 }}
options={[
{ label: 'CREATED', value: 'CREATED' },
{ label: 'COMPLETED', value: 'COMPLETED' },
]}
/>
</Form.Item>
<Form.Item name="refund_status" label="退款状态">
<Select allowClear placeholder="退款状态" style={{ width: 150 }}
options={[
{ label: 'none', value: 'none' },
{ label: 'partial', value: 'partial' },
{ label: 'full', value: 'full' },
]}
/>
</Form.Item>
<Form.Item name="payer_email" label="付款人邮箱">
<Input allowClear placeholder="付款人邮箱" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="时间范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}></Button>
<Button onClick={() => { form.resetFields(); fetchList(1, size); setPage(1); }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey={(r) => r.id}
columns={columns}
dataSource={data}
loading={loading}
pagination={{ current: page, pageSize: size, total, onChange: (p, s) => { setPage(p); setSize(s); fetchList(p, s); } }}
scroll={{ x: 1800 }}
/>
</Card>
);
};
export default FinancePaymentRecords;

View File

@@ -0,0 +1,79 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
const FinanceSandboxCosts: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return { page: p, size: s, user_id: vals.user_id, project: vals.project, start, end } as any;
};
const fetchList = async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listSandboxRecords(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchList(); }, []);
const columns: ColumnsType<any> = useMemo(() => ([
{ title: '用户id', dataIndex: 'user_id', key: 'user_id' },
{ title: '对话id', dataIndex: 'project_id', key: 'project_id' },
{ title: '任务id', dataIndex: 'chat_id', key: 'chat_id' },
{ title: '花费额度', dataIndex: 'total_cost_balance', key: 'total_cost_balance' },
{ title: '花费金额', dataIndex: 'total_cost_usd', key: 'total_cost_usd' },
{ title: '支付状态', dataIndex: 'billing_status', key: 'billing_status' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
{ title: '修改时间', dataIndex: 'updated_at', key: 'updated_at' },
]), []);
return (
<Card bordered={false} title={<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>}>
<Form form={form} layout="inline" onFinish={() => fetchList(1, size)} style={{ marginBottom: 12 }}>
<Form.Item name="user_id" label="用户id">
<Input allowClear placeholder="用户id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="project" label="对话id">
<Input allowClear placeholder="对话id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="时间范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}></Button>
<Button onClick={() => { form.resetFields(); fetchList(1, size); setPage(1); }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey={(r) => r.sandbox_id || `${r.sandbox_id}-${r.created_at}`}
columns={columns}
dataSource={data}
loading={loading}
pagination={{ current: page, pageSize: size, total, onChange: (p, s) => { setPage(p); setSize(s); fetchList(p, s); } }}
scroll={{ x: 1200 }}
/>
</Card>
);
};
export default FinanceSandboxCosts;

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
const FinanceTokenUsage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return { page: p, size: s, user_id: vals.user_id, project: vals.project, start, end } as any;
};
const fetchList = async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listTokenUsages(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchList(); }, []);
const columns: ColumnsType<any> = useMemo(() => ([
{ title: '用户id', dataIndex: 'user_id', key: 'user_id' },
{ title: 'API分组', dataIndex: 'api_group', key: 'api_group' },
{ title: '对话id', dataIndex: 'project_id', key: 'project_id' },
{ title: '日期', dataIndex: 'day', key: 'day' },
{ title: '小时', dataIndex: 'hour', key: 'hour' },
{ title: '服务商', dataIndex: 'provider', key: 'provider' },
{ title: '账号', dataIndex: 'account', key: 'account' },
{ title: '模型', dataIndex: 'model', key: 'model' },
{ title: '代理类型', dataIndex: 'agent_type', key: 'agent_type' },
{ title: '任务id', dataIndex: 'chat_id', key: 'chat_id' },
{ title: '花费金额(总)', dataIndex: 'cost', key: 'cost' },
{ title: '提示Tokens', dataIndex: 'prompt_token', key: 'prompt_token' },
{ title: '输出Tokens', dataIndex: 'completion_token', key: 'completion_token' },
{ title: '缓存创建Tokens', dataIndex: 'cache_create_token', key: 'cache_create_token' },
{ title: '缓存命中Tokens', dataIndex: 'cache_read_token', key: 'cache_read_token' },
{ title: '提示花费', dataIndex: 'prompt_cost', key: 'prompt_cost' },
{ title: '输出花费', dataIndex: 'completion_cost', key: 'completion_cost' },
{ title: '缓存创建花费', dataIndex: 'cache_create_cost', key: 'cache_create_cost' },
{ title: '缓存命花费', dataIndex: 'cache_read_cost', key: 'cache_read_cost' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
{ title: '修改时间', dataIndex: 'updated_at', key: 'updated_at' },
]), []);
return (
<Card bordered={false} title={<Typography.Title level={4} style={{ margin: 0 }}>Token </Typography.Title>}>
<Form form={form} layout="inline" onFinish={() => fetchList(1, size)} style={{ marginBottom: 12 }}>
<Form.Item name="user_id" label="用户id">
<Input allowClear placeholder="用户id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="project" label="对话id">
<Input allowClear placeholder="对话id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="时间范围(按天)">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}></Button>
<Button onClick={() => { form.resetFields(); fetchList(1, size); setPage(1); }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey={(r) => r.id || `${r.user_id}-${r.day}-${r.hour}-${r.model}-${r.account}`}
columns={columns}
dataSource={data}
loading={loading}
pagination={{ current: page, pageSize: size, total, onChange: (p, s) => { setPage(p); setSize(s); fetchList(p, s); } }}
scroll={{ x: 1200 }}
/>
</Card>
);
};
export default FinanceTokenUsage;

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space, Select } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
const FinanceTransactionLogs: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return {
page: p,
size: s,
user_id: vals.user_id,
order_id: vals.order_id,
type: vals.type,
status: vals.status,
start,
end,
} as any;
};
const fetchList = async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listTransactionLogs(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchList(); }, []);
const columns: ColumnsType<any> = useMemo(() => ([
{ title: '流水ID', dataIndex: 'transaction_id', key: 'transaction_id' },
{ title: '用户id', dataIndex: 'user_id', key: 'user_id' },
{ title: '订单id', dataIndex: 'order_id', key: 'order_id' },
{ title: '流水类型', dataIndex: 'type', key: 'type' },
{ title: '金额(最小单位)', dataIndex: 'amount', key: 'amount' },
{ title: '货币', dataIndex: 'currency', key: 'currency' },
{ title: '操作前余额', dataIndex: 'balance_before', key: 'balance_before' },
{ title: '操作后余额', dataIndex: 'balance_after', key: 'balance_after' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '描述', dataIndex: 'description', key: 'description' },
{ title: 'PayPal交易ID', dataIndex: 'paypal_transaction_id', key: 'paypal_transaction_id' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
]), []);
return (
<Card bordered={false} title={<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>}>
<Form form={form} layout="inline" onFinish={() => fetchList(1, size)} style={{ marginBottom: 12 }}>
<Form.Item name="user_id" label="用户id">
<Input allowClear placeholder="用户id" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="order_id" label="订单id">
<Input allowClear placeholder="订单id" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="type" label="流水类型">
<Select allowClear placeholder="recharge/refund/consume" style={{ width: 200 }}
options={[
{ label: 'recharge', value: 'recharge' },
{ label: 'refund', value: 'refund' },
{ label: 'consume', value: 'consume' },
]}
/>
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear placeholder="success/failed/pending" style={{ width: 200 }}
options={[
{ label: 'success', value: 'success' },
{ label: 'failed', value: 'failed' },
{ label: 'pending', value: 'pending' },
]}
/>
</Form.Item>
<Form.Item name="range" label="时间范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}></Button>
<Button onClick={() => { form.resetFields(); fetchList(1, size); setPage(1); }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey={(r) => r.id || r.transaction_id}
columns={columns}
dataSource={data}
loading={loading}
pagination={{ current: page, pageSize: size, total, onChange: (p, s) => { setPage(p); setSize(s); fetchList(p, s); } }}
scroll={{ x: 1200 }}
/>
</Card>
);
};
export default FinanceTransactionLogs;

View File

@@ -0,0 +1,532 @@
import React, { useEffect, useMemo, useState } from 'react'
import { GoalfyMaxUsersApi } from '../services/api'
import { getAllUserLevelConfigs } from '../services/userLevelConfigApi'
import type { UserLevelConfig } from '../types/userLevelConfig'
import { Table, Button, Modal, Form, Input, Tag, Space, Popconfirm, Select, message, Row, Col, Card, Statistic } from 'antd'
import { EditOutlined, DeleteOutlined, StopOutlined, CheckCircleOutlined, UserOutlined, PlusOutlined, DollarOutlined, MinusCircleOutlined } from '@ant-design/icons'
type User = {
id: number
user_id: number
username: string
email: string
nickname?: string
avatar?: string
is_banned: boolean
ban_reason?: string
last_login_at?: string
created_at?: string
user_level_code?: string
user_level_id?: number
user_level?: {
id: number
level_name: string
level_code: string
}
version?: number // 版本1-用户版 2-观察版 3-用户版和观察版
goalfy_hub_permission?: number // 是否具备GoalfyHub权限 (0: 不具备, 1: 具备)
balance?: number // 用户余额(美元)
}
const { Option } = Select
export default function GoalfyMaxUsers() {
const [loading, setLoading] = useState(false)
const [list, setList] = useState<User[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState<number | undefined>(undefined)
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<User | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [form] = Form.useForm()
const [createForm] = Form.useForm()
const [userLevels, setUserLevels] = useState<UserLevelConfig[]>([])
const [addBalanceOpen, setAddBalanceOpen] = useState(false)
const [addingBalanceUser, setAddingBalanceUser] = useState<User | null>(null)
const [addBalanceForm] = Form.useForm()
const [deductBalanceOpen, setDeductBalanceOpen] = useState(false)
const [deductingBalanceUser, setDeductingBalanceUser] = useState<User | null>(null)
const [deductBalanceForm] = Form.useForm()
// 加载用户等级配置
const fetchUserLevels = async () => {
try {
const levels = await getAllUserLevelConfigs()
// 显示所有等级(包括已禁用的),以便在编辑时能正确回显
setUserLevels(levels)
} catch (e) {
console.error('获取用户等级配置失败', e)
}
}
useEffect(() => {
fetchUserLevels()
}, [])
const params = useMemo(() => ({ username: keyword, email: '', status, page, size }), [keyword, status, page, size])
const fetchList = async () => {
setLoading(true)
try {
const res = await GoalfyMaxUsersApi.list(params)
setList(res?.data?.users ?? [])
setTotal(res?.data?.total ?? 0)
} catch (e) {
message.error('获取用户列表失败')
} finally {
setLoading(false)
}
}
useEffect(() => { fetchList() }, [params.page, params.size])
const openEdit = (u: User) => {
setEditing(u)
const mappedLevelId = (() => {
if (u.user_level_id) return u.user_level_id
if (u.user_level_code) {
const level = userLevels.find(l => l.level_code === u.user_level_code)
return level?.id
}
if (u.user_level?.level_code) {
const level = userLevels.find(l => l.level_code === u.user_level?.level_code)
return level?.id
}
return undefined
})()
// 将version值转换为下拉框多选数组
// version: 1-用户版, 2-观察版, 3-用户版和观察版
const versionValue = u.version ?? 1
const versionOptions: string[] = []
if (versionValue === 1 || versionValue === 3) {
versionOptions.push('1') // 用户版
}
if (versionValue === 2 || versionValue === 3) {
versionOptions.push('2') // 观察版
}
form.setFieldsValue({
nickname: u.nickname,
email: u.email,
user_level_id: mappedLevelId,
version: versionOptions,
goalfy_hub_permission: u.goalfy_hub_permission
})
setEditOpen(true)
}
const submitEdit = async () => {
try {
const values = await form.validateFields()
if (!editing) return
const payload: any = {
nickname: values.nickname,
email: values.email,
avatar: values.avatar,
goalfy_hub_permission: values.goalfy_hub_permission,
}
if ('avatar' in payload) delete payload.avatar
if (values.user_level_id) {
const level = userLevels.find(l => l.id === values.user_level_id)
if (level) {
payload.user_level_code = level.level_code
}
delete payload.user_level_id
}
// 处理version字段将下拉框多选数组转换为INT值
// [] -> 1 (默认用户版), ['1'] -> 1, ['2'] -> 2, ['1', '2'] -> 3
if (values.version && Array.isArray(values.version)) {
if (values.version.length === 0) {
payload.version = 1 // 默认用户版
} else if (values.version.length === 1) {
payload.version = parseInt(values.version[0])
} else if (values.version.length === 2) {
payload.version = 3 // 用户版和观察版
}
} else {
// 如果没有选择,保持原值或默认值
payload.version = editing.version ?? 1
}
await GoalfyMaxUsersApi.update(editing.id, payload)
message.success('更新成功')
setEditOpen(false)
fetchList()
} catch {}
}
const openCreate = () => {
createForm.resetFields()
setCreateOpen(true)
}
const submitCreate = async () => {
try {
const values = await createForm.validateFields()
const payload: any = { ...values }
if ('avatar' in payload) delete payload.avatar
if (values.user_level_id) {
const level = userLevels.find(l => l.id === values.user_level_id)
if (level) {
payload.user_level_code = level.level_code
}
delete payload.user_level_id
}
// 处理version字段将下拉框多选数组转换为INT值
// [] -> 1 (默认用户版), ['1'] -> 1, ['2'] -> 2, ['1', '2'] -> 3
if (values.version && Array.isArray(values.version)) {
if (values.version.length === 0) {
payload.version = 1 // 默认用户版
} else if (values.version.length === 1) {
payload.version = parseInt(values.version[0])
} else if (values.version.length === 2) {
payload.version = 3 // 用户版和观察版
}
} else {
payload.version = 1 // 默认用户版
}
await GoalfyMaxUsersApi.create(payload)
message.success('创建成功')
setCreateOpen(false)
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || '创建失败')
}
}
const handleDelete = async (u: User) => {
await GoalfyMaxUsersApi.delete(u.id)
message.success('删除成功')
fetchList()
}
const handleBan = async (u: User) => {
Modal.confirm({
title: `封禁用户 ${u.username}`,
content: (
<Form form={form} layout="vertical" initialValues={{ reason: '违规行为' }}>
<Form.Item name="reason" label="封禁原因" rules={[{ required: true, message: '请输入封禁原因' }]}>
<Input placeholder="请输入封禁原因"/>
</Form.Item>
</Form>
),
onOk: async () => {
const { reason } = form.getFieldsValue()
await GoalfyMaxUsersApi.ban(u.id, reason)
message.success('已封禁')
fetchList()
}
})
}
const handleUnban = async (u: User) => {
await GoalfyMaxUsersApi.unban(u.id)
message.success('已解封')
fetchList()
}
const openAddBalance = (u: User) => {
setAddingBalanceUser(u)
addBalanceForm.resetFields()
setAddBalanceOpen(true)
}
const submitAddBalance = async () => {
try {
const values = await addBalanceForm.validateFields()
if (!addingBalanceUser) return
await GoalfyMaxUsersApi.addBalance(addingBalanceUser.id, values.amount)
message.success('增加余额成功')
setAddBalanceOpen(false)
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || '增加余额失败')
}
}
const openDeductBalance = (u: User) => {
setDeductingBalanceUser(u)
deductBalanceForm.resetFields()
setDeductBalanceOpen(true)
}
const submitDeductBalance = async () => {
try {
const values = await deductBalanceForm.validateFields()
if (!deductingBalanceUser) return
await GoalfyMaxUsersApi.deductBalance(deductingBalanceUser.id, values.amount)
message.success('减少余额成功')
setDeductBalanceOpen(false)
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || '减少余额失败')
}
}
const columns = [
{ title: '用户名', dataIndex: 'username', key: 'username' },
{ title: '邮箱', dataIndex: 'email', key: 'email' },
{ title: '昵称', dataIndex: 'nickname', key: 'nickname' },
{
title: '用户等级',
dataIndex: 'user_level',
key: 'user_level',
render: (_: any, record: User) => {
const displayName = (() => {
if (record.user_level?.level_name) return record.user_level.level_name
if (record.user_level_code) {
const level = userLevels.find(l => l.level_code === record.user_level_code)
if (level?.level_name) return level.level_name
}
return null
})()
return displayName ? (
<Tag color="blue">{displayName}</Tag>
) : (
<Tag color="default"></Tag>
)
}
},
{
title: '版本',
dataIndex: 'version',
key: 'version',
render: (version: number) => {
let tagText = '未知'
let color = 'default'
if (version === 1) {
tagText = '用户版'
color = 'blue'
} else if (version === 2) {
tagText = '观察版'
color = 'green'
} else if (version === 3) {
tagText = '用户版和观察版'
color = 'purple'
}
return <Tag color={color}>{tagText}</Tag>
},
},
{
title: 'GoalfyHub 权限',
dataIndex: 'goalfy_hub_permission',
key: 'goalfy_hub_permission',
render: (permission: number) => {
return permission === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>
},
},
{
title: '余额(美元)',
dataIndex: 'balance',
key: 'balance',
render: (balance: number | undefined) => {
const amount = balance ?? 0
return <span>${amount.toFixed(2)}</span>
}
},
{ title: '状态', dataIndex: 'is_banned', key: 'is_banned', render: (v: boolean) => v ? <Tag color="red"></Tag> : <Tag color="green"></Tag> },
{ title: '最后登录', dataIndex: 'last_login_at', key: 'last_login_at' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
{
title: '操作', key: 'action', render: (_: any, u: User) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(u)}></Button>
<Button type="link" icon={<DollarOutlined />} onClick={() => openAddBalance(u)}></Button>
<Button type="link" icon={<MinusCircleOutlined />} onClick={() => openDeductBalance(u)}></Button>
{!u.is_banned && <Button type="link" icon={<StopOutlined />} onClick={() => handleBan(u)}></Button>}
{u.is_banned && <Button type="link" icon={<CheckCircleOutlined />} onClick={() => handleUnban(u)}></Button>}
<Popconfirm title="确定删除该用户?" onConfirm={() => handleDelete(u)}>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
)
}
]
const totalUsers = total
const bannedUsers = (list || []).filter(i => i.is_banned).length
const normalUsers = totalUsers - bannedUsers
return (
<div>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic title="GoalfyMax用户总数" value={totalUsers} prefix={<UserOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="正常" value={normalUsers} valueStyle={{ color: '#3f8600' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="封禁" value={bannedUsers} valueStyle={{ color: '#cf1322' }} />
</Card>
</Col>
</Row>
{/* 搜索与筛选 */}
<Space style={{ marginBottom: 16 }}>
<Input placeholder="搜索用户名" value={keyword} onChange={e=>setKeyword(e.target.value)} allowClear style={{ width: 240 }} />
<Select placeholder="状态" allowClear style={{ width: 160 }} value={status as any} onChange={(v)=>{ setStatus(v as number | undefined); setPage(1) }}>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
<Button onClick={()=>{ setPage(1); fetchList() }}></Button>
<Button onClick={()=>{ setKeyword(''); setStatus(undefined); setPage(1); fetchList() }}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</Space>
{/* 列表 */}
<Table
columns={columns as any}
dataSource={list}
loading={loading}
rowKey="id"
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
showQuickJumper: true,
onChange: (p, s) => { setPage(p); setSize(s || 10) }
}}
/>
{/* 编辑弹窗 */}
<Modal open={editOpen} title={`编辑用户 ${editing?.username || ''}`} onCancel={()=>setEditOpen(false)} onOk={submitEdit} okText="保存">
<Form layout="vertical" form={form}>
<Form.Item name="nickname" label="昵称">
<Input />
</Form.Item>
<Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}>
<Input />
</Form.Item>
<Form.Item name="user_level_id" label="用户等级">
<Select placeholder="请选择用户等级" allowClear>
{userLevels.map(level => (
<Option key={level.id} value={level.id} disabled={level.status !== 1}>
{level.level_name} ({level.level_code}){level.status !== 1 ? ' [已禁用]' : ''}
</Option>
))}
</Select>
</Form.Item>
<Form.Item name="version" label="版本">
<Select mode="multiple" placeholder="请选择版本" allowClear>
<Option value="1"></Option>
<Option value="2"></Option>
<Option value="3"></Option>
</Select>
</Form.Item>
<Form.Item label="GoalfyHub 权限" name="goalfy_hub_permission">
<Select placeholder="请选择GoalfyHub权限" allowClear>
<Option value={0}></Option>
<Option value={1}></Option>
</Select>
</Form.Item>
</Form>
</Modal>
{/* 创建用户弹窗 */}
<Modal open={createOpen} title="新增用户" onCancel={()=>setCreateOpen(false)} onOk={submitCreate} okText="创建">
<Form layout="vertical" form={createForm}>
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item name="email" label="邮箱" rules={[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '邮箱格式不正确' }]}>
<Input placeholder="请输入邮箱" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }, { min: 8, message: '密码至少8位' }]}>
<Input.Password placeholder="请输入密码" />
</Form.Item>
<Form.Item name="nickname" label="昵称">
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item name="user_level_id" label="用户等级">
<Select placeholder="请选择用户等级" allowClear>
{userLevels.filter(level => level.status === 1).map(level => (
<Option key={level.id} value={level.id}>
{level.level_name} ({level.level_code})
</Option>
))}
</Select>
</Form.Item>
<Form.Item name="version" label="版本" initialValue={['1']}>
<Select mode="multiple" placeholder="请选择版本" allowClear>
<Option value="1"></Option>
<Option value="2"></Option>
</Select>
</Form.Item>
</Form>
</Modal>
{/* 增加余额弹窗 */}
<Modal open={addBalanceOpen} title={`增加余额 - ${addingBalanceUser?.username || ''}`} onCancel={()=>setAddBalanceOpen(false)} onOk={submitAddBalance} okText="确认">
<Form layout="vertical" form={addBalanceForm}>
<Form.Item
name="amount"
label="增加金额(美元)"
rules={[
{ required: true, message: '请输入增加金额' },
{ type: 'number', min: 0.01, message: '金额必须大于0' }
]}
getValueFromEvent={(e) => parseFloat(e.target.value) || 0}
getValueProps={(value) => ({ value: value || '' })}
>
<Input
type="number"
step="0.01"
placeholder="请输入要增加的美元金额"
prefix="$"
style={{ width: '100%' }}
/>
</Form.Item>
{addingBalanceUser && (
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
<div>: <strong>${addingBalanceUser.balance?.toFixed(2) || '0.00'}</strong></div>
</div>
)}
</Form>
</Modal>
{/* 减少余额弹窗 */}
<Modal open={deductBalanceOpen} title={`减少余额 - ${deductingBalanceUser?.username || ''}`} onCancel={()=>setDeductBalanceOpen(false)} onOk={submitDeductBalance} okText="确认">
<Form layout="vertical" form={deductBalanceForm}>
<Form.Item
name="amount"
label="减少金额(美元)"
rules={[
{ required: true, message: '请输入减少金额' },
{ type: 'number', min: 0.01, message: '金额必须大于0' }
]}
getValueFromEvent={(e) => parseFloat(e.target.value) || 0}
getValueProps={(value) => ({ value: value || '' })}
>
<Input
type="number"
step="0.01"
placeholder="请输入要减少的美元金额"
prefix="$"
style={{ width: '100%' }}
/>
</Form.Item>
{deductingBalanceUser && (
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
<div>: <strong>${deductingBalanceUser.balance?.toFixed(2) || '0.00'}</strong></div>
</div>
)}
</Form>
</Modal>
</div>
)
}

954
src/pages/InviteCodes.tsx Normal file
View File

@@ -0,0 +1,954 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Table,
Button,
Space,
Tag,
Popconfirm,
message,
Input,
Select,
DatePicker,
Row,
Col,
Statistic,
Modal,
Form,
Badge,
Tabs,
} from 'antd';
import { PlusOutlined, DeleteOutlined, CopyOutlined, EditOutlined, BellOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { InviteCodeApiService } from '../services/inviteCodeApi';
import { InviteCodeApplicationApiService } from '../services/inviteCodeApplicationApi';
import { getAllUserLevelConfigs } from '../services/userLevelConfigApi';
import type { InviteCode, InviteCodeStatistics, InviteCodeCreateRequest, InviteCodeUpdateRequest, ClientOption } from '../types/inviteCode';
import type { InviteCodeApplication, InviteCodeApplicationStatistics } from '../types/inviteCodeApplication';
import type { UserLevelConfig } from '../types/userLevelConfig';
import dayjs from 'dayjs';
const { RangePicker } = DatePicker;
const { Option } = Select;
const InviteCodes: React.FC = () => {
const [inviteCodes, setInviteCodes] = useState<InviteCode[]>([]);
const [statistics, setStatistics] = useState<InviteCodeStatistics | null>(null);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [createModalVisible, setCreateModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingInviteCode, setEditingInviteCode] = useState<InviteCode | null>(null);
const [clientOptions, setClientOptions] = useState<ClientOption[]>([]);
const [userLevels, setUserLevels] = useState<UserLevelConfig[]>([]);
const [createForm] = Form.useForm();
const [pagination, setPagination] = useState({
current: 1,
pageSize: 20,
total: 0,
});
// 申请管理相关状态
const [applications, setApplications] = useState<InviteCodeApplication[]>([]);
const [applicationStatistics, setApplicationStatistics] = useState<InviteCodeApplicationStatistics | null>(null);
const [pendingCount, setPendingCount] = useState(0);
const [applicationModalVisible, setApplicationModalVisible] = useState(false);
const [selectedApplications, setSelectedApplications] = useState<number[]>([]);
const [rejectModalVisible, setRejectModalVisible] = useState(false);
const [rejectingApplication, setRejectingApplication] = useState<InviteCodeApplication | null>(null);
const [applicationPagination, setApplicationPagination] = useState({
current: 1,
pageSize: 20,
total: 0,
});
// 搜索条件
const [filters, setFilters] = useState({
code: '',
is_used: undefined as boolean | undefined,
dateRange: null as [dayjs.Dayjs, dayjs.Dayjs] | null,
});
// 获取邀请码列表
const fetchInviteCodes = async () => {
setLoading(true);
try {
const params: any = {
page: pagination.current,
size: pagination.pageSize,
};
if (filters.code) params.code = filters.code;
if (filters.is_used !== undefined) params.is_used = filters.is_used;
if (filters.dateRange) {
params.start_time = filters.dateRange[0].format('YYYY-MM-DD');
params.end_time = filters.dateRange[1].format('YYYY-MM-DD');
}
const response = await InviteCodeApiService.getInviteCodeList(params);
setInviteCodes(response.list);
setPagination(prev => ({ ...prev, total: response.total }));
} catch (error) {
message.error('获取邀请码列表失败');
} finally {
setLoading(false);
}
};
// 获取统计信息
const fetchStatistics = async () => {
try {
const stats = await InviteCodeApiService.getStatistics();
setStatistics(stats);
} catch (error) {
console.error('获取统计信息失败:', error);
}
};
// 获取客户端选项
const fetchClientOptions = async () => {
try {
const options = await InviteCodeApiService.getClientOptions();
setClientOptions(options);
} catch (error) {
console.error('获取客户端选项失败:', error);
}
};
// 获取用户等级列表
const fetchUserLevels = async () => {
try {
const levels = await getAllUserLevelConfigs();
// 只显示启用状态的等级
setUserLevels(levels.filter(level => level.status === 1));
} catch (error) {
console.error('获取用户等级列表失败:', error);
}
};
// 获取申请列表
const fetchApplications = async () => {
setLoading(true);
try {
const params = {
status: 'pending' as const,
page: applicationPagination.current,
size: applicationPagination.pageSize,
};
const response = await InviteCodeApplicationApiService.getApplicationList(params);
setApplications(response.list);
setApplicationPagination(prev => ({ ...prev, total: response.total }));
} catch (error) {
message.error('获取申请列表失败');
} finally {
setLoading(false);
}
};
// 获取待处理申请数量
const fetchPendingCount = async () => {
try {
const count = await InviteCodeApplicationApiService.getPendingCount();
setPendingCount(count);
} catch (error) {
console.error('获取待处理数量失败:', error);
}
};
// 获取申请统计
const fetchApplicationStatistics = async () => {
try {
const stats = await InviteCodeApplicationApiService.getStatistics();
setApplicationStatistics(stats);
} catch (error) {
console.error('获取申请统计失败:', error);
}
};
// 创建邀请码
const handleCreate = async (values: any) => {
setCreating(true);
try {
// 解析邮箱列表(支持换行和逗号分隔)
let emails: string[] = [];
if (values.emails) {
const emailText = values.emails.trim();
if (emailText) {
// 先按换行分割,再按逗号分割
const rawEmails = emailText
.split(/[\n,]/)
.map((email: string) => email.trim())
.filter((email: string) => email.length > 0);
// 去重处理(转换为小写后去重)
const emailSet = new Set<string>();
emails = rawEmails.filter(email => {
const lowerEmail = email.toLowerCase();
if (emailSet.has(lowerEmail)) {
return false; // 已存在,跳过
}
emailSet.add(lowerEmail);
return true; // 新邮箱,保留
});
}
}
const requestData: InviteCodeCreateRequest = {
emails: emails.length > 0 ? emails : undefined,
user_level_id: values.user_level_id,
expires_at: values.expires_at ? dayjs(values.expires_at).toISOString() : undefined,
};
const response = await InviteCodeApiService.createInviteCode(requestData);
// 处理响应(可能是单个对象或数组)
const createdCodes = Array.isArray(response) ? response : [response];
const count = createdCodes.length;
if (count === 1) {
message.success('邀请码创建成功');
} else {
message.success(`成功创建 ${count} 个邀请码`);
}
setCreateModalVisible(false);
createForm.resetFields(); // 清空表单
fetchInviteCodes();
fetchStatistics();
} catch (error: any) {
message.error(error?.response?.data?.message || '创建邀请码失败');
} finally {
setCreating(false);
}
};
// 编辑邀请码
const handleEdit = async (values: InviteCodeUpdateRequest) => {
if (!editingInviteCode) return;
setCreating(true);
try {
await InviteCodeApiService.updateInviteCode(editingInviteCode.id, values);
message.success('邀请码更新成功');
setEditModalVisible(false);
setEditingInviteCode(null);
fetchInviteCodes();
fetchStatistics();
} catch (error) {
message.error('更新邀请码失败');
} finally {
setCreating(false);
}
};
// 打开编辑模态框
const openEditModal = (inviteCode: InviteCode) => {
setEditingInviteCode(inviteCode);
setEditModalVisible(true);
};
// 删除邀请码
const handleDelete = async (id: number) => {
try {
await InviteCodeApiService.deleteInviteCode(id);
message.success('删除成功');
fetchInviteCodes();
fetchStatistics();
} catch (error) {
message.error('删除失败');
}
};
// 复制邀请码
const handleCopyCode = (code: string) => {
navigator.clipboard.writeText(code);
message.success('邀请码已复制到剪贴板');
};
// 复制邀请链接
const handleCopyLink = (inviteUrl?: string, code?: string) => {
const link = inviteUrl || `${window.location.origin}/register?invite=${code ?? ''}`;
navigator.clipboard.writeText(link);
message.success('邀请链接已复制到剪贴板');
};
// 搜索
const handleSearch = () => {
setPagination(prev => ({ ...prev, current: 1 }));
fetchInviteCodes();
};
// 重置搜索
const handleReset = () => {
setFilters({
code: '',
is_used: undefined,
dateRange: null,
});
setPagination(prev => ({ ...prev, current: 1 }));
};
// 审批通过申请
const handleApproveApplication = async (applicationId: number, validDays: number = 3) => {
try {
await InviteCodeApplicationApiService.approveApplication({
application_id: applicationId,
valid_days: validDays,
});
message.success('申请已通过,邀请码已发送至申请人邮箱');
fetchApplications();
fetchPendingCount();
fetchInviteCodes();
fetchStatistics();
} catch (error) {
message.error('审批失败');
}
};
// 审批拒绝申请
const handleRejectApplication = async (applicationId: number, rejectReason?: string) => {
try {
await InviteCodeApplicationApiService.rejectApplication({
application_id: applicationId,
reject_reason: rejectReason,
});
message.success('申请已拒绝');
fetchApplications();
fetchPendingCount();
setRejectModalVisible(false);
setRejectingApplication(null);
} catch (error) {
message.error('拒绝失败');
}
};
// 批量审批通过
const handleBatchApprove = async () => {
if (selectedApplications.length === 0) {
message.warning('请选择要审批的申请');
return;
}
try {
await InviteCodeApplicationApiService.batchApproveApplications({
application_ids: selectedApplications,
valid_days: 3,
});
message.success(`成功审批通过 ${selectedApplications.length} 个申请`);
setSelectedApplications([]);
fetchApplications();
fetchPendingCount();
fetchInviteCodes();
fetchStatistics();
} catch (error) {
message.error('批量审批失败');
}
};
// 批量审批拒绝
const handleBatchReject = async (rejectReason?: string) => {
if (selectedApplications.length === 0) {
message.warning('请选择要拒绝的申请');
return;
}
try {
await InviteCodeApplicationApiService.batchRejectApplications({
application_ids: selectedApplications,
reject_reason: rejectReason,
});
message.success(`成功拒绝 ${selectedApplications.length} 个申请`);
setSelectedApplications([]);
fetchApplications();
fetchPendingCount();
} catch (error) {
message.error('批量拒绝失败');
}
};
useEffect(() => {
fetchInviteCodes();
fetchStatistics();
fetchClientOptions();
fetchUserLevels();
fetchPendingCount();
fetchApplicationStatistics();
}, [pagination.current, pagination.pageSize]);
// 申请列表表格列
const applicationColumns = [
{
title: '申请邮箱',
dataIndex: 'email',
key: 'email',
},
{
title: '申请理由',
dataIndex: 'reason',
key: 'reason',
render: (reason: string | undefined) => reason || '-',
ellipsis: true,
},
{
title: '申请时间',
dataIndex: 'created_at',
key: 'created_at',
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
render: (_: any, record: InviteCodeApplication) => (
<Space>
<Button
type="primary"
size="small"
icon={<CheckOutlined />}
onClick={() => {
Modal.confirm({
title: '确认通过',
content: `确定要通过邮箱 ${record.email} 的申请吗默认有效期3天。`,
onOk: () => handleApproveApplication(record.id),
});
}}
>
</Button>
<Button
danger
size="small"
icon={<CloseOutlined />}
onClick={() => {
setRejectingApplication(record);
setRejectModalVisible(true);
}}
>
</Button>
</Space>
),
},
];
const columns = [
{
title: '邀请码',
dataIndex: 'code',
key: 'code',
render: (code: string) => (
<Space>
<span style={{ fontFamily: 'monospace', fontWeight: 'bold' }}>{code}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyCode(code)}
>
</Button>
</Space>
),
},
{
title: '邀请链接',
dataIndex: 'invite_url',
key: 'invite_url',
render: (_: any, record: InviteCode) => (
<Space>
<span style={{ color: '#555' }}>{record.invite_url ?? '-'}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyLink(record.invite_url, record.code)}
>
</Button>
</Space>
),
},
{
title: '是否使用',
dataIndex: 'is_used',
key: 'is_used',
render: (isUsed: boolean) => (
<Tag color={isUsed ? 'red' : 'green'}>
{isUsed ? '已使用' : '未使用'}
</Tag>
),
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
render: (email: string | undefined) => (
email ? <span>{email}</span> : <span>-</span>
),
},
{
title: '用户等级',
dataIndex: 'user_level_name',
key: 'user_level_name',
render: (userLevelName: string | undefined) => (
userLevelName ? <Tag color="purple">{userLevelName}</Tag> : <span>-</span>
),
},
{
title: '客户端',
dataIndex: 'client_label',
key: 'client_label',
render: (clientLabel: string | undefined) => (
clientLabel ? <Tag color="blue">{clientLabel}</Tag> : <span>-</span>
),
},
{
title: '过期时间',
dataIndex: 'expires_at',
key: 'expires_at',
render: (expiresAt: string | null) => {
if (!expiresAt) {
return <Tag color="blue"></Tag>;
}
const isExpired = dayjs(expiresAt).isBefore(dayjs());
return (
<Tag color={isExpired ? 'red' : 'green'}>
{dayjs(expiresAt).format('YYYY-MM-DD HH:mm:ss')}
{isExpired && ' (已过期)'}
</Tag>
);
},
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
render: (record: InviteCode) => (
<Space>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
disabled={record.is_used}
>
</Button>
<Popconfirm
title="确定要删除这个邀请码吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: '24px' }}>
<Card title="邀请码管理" style={{ marginBottom: 16 }}>
{/* 统计信息 */}
{statistics && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Statistic title="总邀请码" value={statistics.total} />
</Col>
<Col span={6}>
<Statistic title="已使用" value={statistics.used} valueStyle={{ color: '#cf1322' }} />
</Col>
<Col span={6}>
<Statistic title="未使用" value={statistics.unused} valueStyle={{ color: '#3f8600' }} />
</Col>
<Col span={6}>
<Statistic title="今日新增" value={statistics.today_created} />
</Col>
</Row>
)}
{/* 操作栏 */}
<Space style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields(); // 打开时重置表单
setCreateModalVisible(true);
}}
>
</Button>
<Badge count={pendingCount} offset={[-5, 0]}>
<Button
icon={<BellOutlined />}
onClick={() => {
setApplicationModalVisible(true);
fetchApplications();
}}
>
({pendingCount})
</Button>
</Badge>
</Space>
{/* 搜索栏 */}
<Space style={{ marginBottom: 16 }}>
<Input
placeholder="搜索邀请码"
value={filters.code}
onChange={(e) => setFilters(prev => ({ ...prev, code: e.target.value }))}
style={{ width: 200 }}
/>
<Select
placeholder="使用状态"
value={filters.is_used}
onChange={(value) => setFilters(prev => ({ ...prev, is_used: value }))}
style={{ width: 120 }}
allowClear
>
<Option value={false}>使</Option>
<Option value={true}>使</Option>
</Select>
<RangePicker
value={filters.dateRange}
onChange={(dates) => setFilters(prev => ({ ...prev, dateRange: dates }))}
/>
<Button type="primary" onClick={handleSearch}>
</Button>
<Button onClick={handleReset}>
</Button>
</Space>
{/* 表格 */}
<Table
columns={columns}
dataSource={inviteCodes}
rowKey="id"
loading={loading}
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条/共 ${total}`,
onChange: (page, pageSize) => {
setPagination(prev => ({ ...prev, current: page, pageSize: pageSize || 20 }));
},
}}
/>
</Card>
{/* 创建邀请码模态框 */}
<Modal
title="创建邀请码"
open={createModalVisible}
onCancel={() => {
setCreateModalVisible(false);
createForm.resetFields(); // 关闭时清空表单
}}
footer={null}
width={500}
>
<Form
form={createForm}
layout="vertical"
onFinish={handleCreate}
initialValues={{
expires_at: dayjs().add(3, 'day'),
user_level_id: 1, // 默认用户等级ID为1
}}
>
<Form.Item
label="邮箱"
name="emails"
rules={[
{ required: false },
{
validator: (_, value) => {
if (!value || value.trim() === '') {
return Promise.resolve();
}
const emailText = value.trim();
const emails = emailText.split(/[\n,]/).map((e: string) => e.trim()).filter((e: string) => e.length > 0);
const emailRegex = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;
for (const email of emails) {
if (!emailRegex.test(email)) {
return Promise.reject(new Error(`邮箱格式不正确: ${email}`));
}
}
return Promise.resolve();
},
},
]}
extra="每行一个邮箱,或使用逗号分隔。支持多个邮箱,将为每个邮箱创建一个邀请码。"
>
<Input.TextArea
rows={4}
placeholder="请输入邮箱地址,例如:&#10;user1@example.com&#10;user2@example.com&#10;或user1@example.com, user2@example.com"
/>
</Form.Item>
<Form.Item
label="用户等级"
name="user_level_id"
rules={[{ required: false }]}
extra="选择用户等级将关联到创建的邀请码默认等级1"
>
<Select placeholder="请选择用户等级默认等级1" allowClear>
{userLevels.map(level => (
<Option key={level.id} value={level.id}>
{level.level_name} ({level.level_code})
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label="过期时间"
name="expires_at"
rules={[{ required: false, message: '请选择过期时间' }]}
>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
style={{ width: '100%' }}
placeholder="选择过期时间可选默认3天后过期"
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={creating}>
</Button>
<Button onClick={() => setCreateModalVisible(false)}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 编辑邀请码模态框 */}
<Modal
title="编辑邀请码"
open={editModalVisible}
onCancel={() => {
setEditModalVisible(false);
setEditingInviteCode(null);
}}
footer={null}
width={500}
>
<Form
key={editingInviteCode?.id} // 添加 key 确保表单重新渲染
layout="vertical"
onFinish={handleEdit}
initialValues={{
code: editingInviteCode?.code,
client_id: editingInviteCode?.client_id,
email: editingInviteCode?.email,
user_level_id: editingInviteCode?.user_level_id,
expires_at: editingInviteCode?.expires_at ? dayjs(editingInviteCode.expires_at) : null,
}}
>
<Form.Item
label="邀请码"
name="code"
>
<Input disabled />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: false },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input placeholder="请输入邮箱地址" />
</Form.Item>
<Form.Item
label="用户等级"
name="user_level_id"
rules={[{ required: false }]}
>
<Select placeholder="请选择用户等级(可选)" allowClear>
{userLevels.map(level => (
<Option key={level.id} value={level.id}>
{level.level_name} ({level.level_code})
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label="客户端"
name="client_id"
rules={[{ required: false, message: '请选择客户端' }]}
>
<Select placeholder="请选择客户端" allowClear>
{clientOptions.map(option => (
<Option key={option.key} value={option.key}>
{option.value}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label="过期时间"
name="expires_at"
rules={[{ required: false, message: '请选择过期时间' }]}
>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
style={{ width: '100%' }}
placeholder="选择过期时间(可选,设置为空表示永不过期)"
allowClear
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={creating}>
</Button>
<Button onClick={() => {
setEditModalVisible(false);
setEditingInviteCode(null);
}}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 待处理申请弹窗 */}
<Modal
title="待处理申请"
open={applicationModalVisible}
onCancel={() => {
setApplicationModalVisible(false);
setSelectedApplications([]);
}}
width={900}
footer={[
<Button key="cancel" onClick={() => {
setApplicationModalVisible(false);
setSelectedApplications([]);
}}>
</Button>,
<Button
key="batch-reject"
danger
onClick={() => {
if (selectedApplications.length === 0) {
message.warning('请选择要拒绝的申请');
return;
}
Modal.confirm({
title: '批量拒绝',
content: `确定要拒绝选中的 ${selectedApplications.length} 个申请吗?`,
onOk: () => handleBatchReject(),
});
}}
disabled={selectedApplications.length === 0}
>
({selectedApplications.length})
</Button>,
<Button
key="batch-approve"
type="primary"
onClick={() => {
if (selectedApplications.length === 0) {
message.warning('请选择要通过的申请');
return;
}
Modal.confirm({
title: '批量通过',
content: `确定要通过选中的 ${selectedApplications.length} 个申请吗默认有效期3天。`,
onOk: handleBatchApprove,
});
}}
disabled={selectedApplications.length === 0}
>
({selectedApplications.length})
</Button>,
]}
>
<Table
columns={applicationColumns}
dataSource={applications}
rowKey="id"
loading={loading}
rowSelection={{
selectedRowKeys: selectedApplications,
onChange: (selectedRowKeys) => {
setSelectedApplications(selectedRowKeys as number[]);
},
}}
pagination={{
...applicationPagination,
onChange: (page, pageSize) => {
setApplicationPagination(prev => ({ ...prev, current: page, pageSize: pageSize || 20 }));
fetchApplications();
},
}}
/>
</Modal>
{/* 拒绝申请弹窗 */}
<Modal
title="拒绝申请"
open={rejectModalVisible}
onCancel={() => {
setRejectModalVisible(false);
setRejectingApplication(null);
}}
footer={null}
width={500}
>
<Form
layout="vertical"
onFinish={(values) => {
if (rejectingApplication) {
handleRejectApplication(rejectingApplication.id, values.reject_reason);
}
}}
>
<Form.Item label="申请邮箱">
<Input value={rejectingApplication?.email} disabled />
</Form.Item>
<Form.Item
label="拒绝原因"
name="reject_reason"
rules={[{ required: false, message: '请输入拒绝原因' }]}
>
<Input.TextArea
rows={4}
placeholder="请输入拒绝原因(选填)"
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={() => {
setRejectModalVisible(false);
setRejectingApplication(null);
}}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default InviteCodes;

View File

@@ -0,0 +1,517 @@
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
InputNumber,
Tag,
Space,
Popconfirm,
message,
Card,
Select,
Switch,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
CheckCircleOutlined,
StopOutlined,
} from '@ant-design/icons';
import type { MCPProvider } from '../types/mcpProvider';
import {
getMCPProviderList,
createMCPProvider,
updateMCPProvider,
deleteMCPProvider,
updateMCPProviderStatus,
updateMCPProviderIsUsed,
} from '../services/mcpProviderApi';
const { Option } = Select;
const { TextArea } = Input;
export default function MCPProviderPricing() {
const [loading, setLoading] = useState(false);
const [list, setList] = useState<MCPProvider[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<MCPProvider | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [searchForm] = Form.useForm();
const fetchList = async (searchParams: any = {}) => {
setLoading(true);
try {
const params = {
page,
page_size: size,
...searchParams,
};
const res = await getMCPProviderList(params);
setList(res?.data ?? []);
setTotal(res?.total ?? 0);
} catch (e: any) {
message.error(e?.response?.data?.message || '获取列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchList();
}, [page, size]);
const openEdit = (provider: MCPProvider) => {
setEditing(provider);
form.setFieldsValue({
provider: provider.provider,
base_url: provider.base_url,
auth: provider.auth,
account: provider.account,
price_type: provider.price_type,
price: provider.price,
floating_ratio: provider.floating_ratio,
is_used: provider.is_used,
status: provider.status,
description: provider.description,
});
setEditOpen(true);
};
const submitEdit = async () => {
try {
const values = await form.validateFields();
if (!editing) return;
await updateMCPProvider(editing.id, values);
message.success('更新成功');
setEditOpen(false);
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '更新失败');
}
};
const openCreate = () => {
createForm.resetFields();
createForm.setFieldsValue({
is_used: true,
status: 'active',
floating_ratio: 0.3,
});
setCreateOpen(true);
};
const submitCreate = async () => {
try {
const values = await createForm.validateFields();
await createMCPProvider(values);
message.success('创建成功');
setCreateOpen(false);
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '创建失败');
}
};
const handleDelete = async (provider: MCPProvider) => {
try {
await deleteMCPProvider(provider.id);
message.success('删除成功');
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '删除失败');
}
};
const handleToggleStatus = async (provider: MCPProvider) => {
try {
const newStatus = provider.status === 'active' ? 'inactive' : 'active';
await updateMCPProviderStatus(provider.id, { status: newStatus });
message.success(newStatus === 'active' ? '已启用' : '已禁用');
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '状态更新失败');
}
};
const handleToggleIsUsed = async (provider: MCPProvider, isUsed: boolean) => {
try {
await updateMCPProviderIsUsed(provider.id, { is_used: isUsed });
message.success(isUsed ? '已启用' : '已停用');
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '状态更新失败');
}
};
const handleSearch = (values: any) => {
setPage(1);
fetchList(values);
};
const handleReset = () => {
searchForm.resetFields();
setPage(1);
fetchList();
};
const columns = [
{
title: '供应商',
dataIndex: 'provider',
key: 'provider',
width: 150,
},
{
title: 'Base URL',
dataIndex: 'base_url',
key: 'base_url',
ellipsis: true,
width: 200,
},
{
title: '账户',
dataIndex: 'account',
key: 'account',
width: 120,
render: (text: string) => text || '-',
},
{
title: '价格类型',
dataIndex: 'price_type',
key: 'price_type',
width: 120,
},
{
title: '价格',
dataIndex: 'price',
key: 'price',
width: 100,
render: (value: number) => value.toFixed(6),
},
{
title: '浮动比例',
dataIndex: 'floating_ratio',
key: 'floating_ratio',
width: 100,
render: (value: number) => `${(value * 100).toFixed(2)}%`,
},
{
title: '使用中',
dataIndex: 'is_used',
key: 'is_used',
width: 100,
render: (value: boolean, record: MCPProvider) => (
<Switch
checked={value}
onChange={(checked) => handleToggleIsUsed(record, checked)}
/>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (value: string) =>
value === 'active' ? (
<Tag color="green"></Tag>
) : (
<Tag color="red"></Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 180,
render: (text: string) => new Date(text).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
width: 250,
fixed: 'right' as const,
render: (_: any, provider: MCPProvider) => (
<Space>
<Button
type="link"
icon={<EditOutlined />}
onClick={() => openEdit(provider)}
>
</Button>
<Button
type="link"
icon={
provider.status === 'active' ? (
<StopOutlined />
) : (
<CheckCircleOutlined />
)
}
onClick={() => handleToggleStatus(provider)}
>
{provider.status === 'active' ? '禁用' : '启用'}
</Button>
<Popconfirm
title="确定删除该配置?"
onConfirm={() => handleDelete(provider)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card>
<div style={{ marginBottom: 16 }}>
<Form
form={searchForm}
layout="inline"
onFinish={handleSearch}
style={{ marginBottom: 16 }}
>
<Form.Item name="provider">
<Input placeholder="供应商名称" style={{ width: 150 }} />
</Form.Item>
<Form.Item name="status">
<Select placeholder="状态" allowClear style={{ width: 120 }}>
<Option value="active"></Option>
<Option value="inactive"></Option>
</Select>
</Form.Item>
<Form.Item name="is_used">
<Select placeholder="使用中" allowClear style={{ width: 120 }}>
<Option value="true"></Option>
<Option value="false"></Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={handleReset}></Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openCreate}
>
</Button>
</Space>
</Form.Item>
</Form>
</div>
<Table
dataSource={list}
columns={columns}
rowKey="id"
loading={loading}
scroll={{ x: 1400 }}
pagination={{
current: page,
pageSize: size,
total: total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (p, s) => {
setPage(p);
setSize(s);
},
}}
/>
</Card>
{/* 编辑弹窗 */}
<Modal
title="编辑MCP供应商配置"
open={editOpen}
onOk={submitEdit}
onCancel={() => setEditOpen(false)}
width={600}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item
name="provider"
label="供应商"
rules={[{ required: true, message: '请输入供应商名称' }]}
>
<Input placeholder="请输入供应商名称" />
</Form.Item>
<Form.Item
name="base_url"
label="Base URL"
rules={[
{ required: true, message: '请输入Base URL' },
{ type: 'url', message: '请输入有效的URL' },
]}
>
<Input placeholder="请输入Base URL" />
</Form.Item>
<Form.Item name="auth" label="认证信息">
<Input.Password placeholder="请输入认证信息" />
</Form.Item>
<Form.Item
name="account"
label="账户"
rules={[{ max: 200, message: '账户名称不能超过200个字符' }]}
>
<Input placeholder="请输入账户名称" />
</Form.Item>
<Form.Item
name="price_type"
label="价格类型"
rules={[{ required: true, message: '请输入价格类型' }]}
>
<Input placeholder="请输入价格类型per_token, per_request等" />
</Form.Item>
<Form.Item
name="price"
label="价格"
rules={[{ required: true, message: '请输入价格' }]}
>
<InputNumber
placeholder="请输入价格"
min={0}
precision={6}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="floating_ratio"
label="浮动比例"
rules={[
{ type: 'number', min: 0, max: 5, message: '浮动比例应在0-5之间' },
]}
>
<InputNumber
placeholder="请输入浮动比例"
min={0}
max={5}
precision={2}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="is_used" label="使用中" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="status" label="状态">
<Select>
<Option value="active"></Option>
<Option value="inactive"></Option>
</Select>
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={3} placeholder="请输入描述信息" />
</Form.Item>
</Form>
</Modal>
{/* 创建弹窗 */}
<Modal
title="新建MCP供应商配置"
open={createOpen}
onOk={submitCreate}
onCancel={() => setCreateOpen(false)}
width={600}
destroyOnClose
>
<Form form={createForm} layout="vertical">
<Form.Item
name="provider"
label="供应商"
rules={[{ required: true, message: '请输入供应商名称' }]}
>
<Input placeholder="请输入供应商名称" />
</Form.Item>
<Form.Item
name="base_url"
label="Base URL"
rules={[
{ required: true, message: '请输入Base URL' },
{ type: 'url', message: '请输入有效的URL' },
]}
>
<Input placeholder="请输入Base URL" />
</Form.Item>
<Form.Item name="auth" label="认证信息">
<Input.Password placeholder="请输入认证信息" />
</Form.Item>
<Form.Item
name="account"
label="账户"
rules={[{ max: 200, message: '账户名称不能超过200个字符' }]}
>
<Input placeholder="请输入账户名称" />
</Form.Item>
<Form.Item
name="price_type"
label="价格类型"
rules={[{ required: true, message: '请输入价格类型' }]}
>
<Input placeholder="请输入价格类型per_token, per_request等" />
</Form.Item>
<Form.Item
name="price"
label="价格"
rules={[{ required: true, message: '请输入价格' }]}
>
<InputNumber
placeholder="请输入价格"
min={0}
precision={6}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="floating_ratio"
label="浮动比例"
rules={[
{ type: 'number', min: 0, max: 5, message: '浮动比例应在0-5之间' },
]}
>
<InputNumber
placeholder="请输入浮动比例"
min={0}
max={5}
precision={2}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="is_used" label="使用中" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="status" label="状态">
<Select>
<Option value="active"></Option>
<Option value="inactive"></Option>
</Select>
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={3} placeholder="请输入描述信息" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

566
src/pages/MessagePush.tsx Normal file
View File

@@ -0,0 +1,566 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Form,
Input,
Button,
Space,
message,
Table,
Tag,
Modal,
Select,
DatePicker,
Row,
Col,
Typography,
Divider,
Drawer,
Statistic,
Tooltip,
} from 'antd';
import {
SendOutlined,
SearchOutlined,
UserOutlined,
EyeOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { MessagePushApiService } from '../services/api';
import dayjs from 'dayjs';
const { TextArea } = Input;
const { Option } = Select;
const { RangePicker } = DatePicker;
const { Title, Text } = Typography;
interface PushLogItem {
id: number;
title: string;
content: string;
target_users: string; // JSON字符串
sender_id: number;
sender_name: string;
status: number;
success_count: number;
fail_count: number;
error_message?: string;
sent_at?: string;
created_at: string;
updated_at: string;
}
interface UserSearchItem {
id: number;
username: string;
email: string;
}
const MessagePush: React.FC = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<PushLogItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// 所有用户列表
const [allUsers, setAllUsers] = useState<UserSearchItem[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
// 详情查看
const [detailVisible, setDetailVisible] = useState(false);
const [selectedLog, setSelectedLog] = useState<PushLogItem | null>(null);
// 筛选条件
const [filters, setFilters] = useState({
status: undefined as number | undefined,
sender_id: undefined as number | undefined,
dateRange: undefined as [dayjs.Dayjs, dayjs.Dayjs] | undefined,
});
// 获取所有用户
const fetchAllUsers = async () => {
setUsersLoading(true);
try {
const response = await MessagePushApiService.searchUsers('', 1000); // 获取所有用户
if (response.code === 200) {
setAllUsers(response.data.users || []);
}
} catch (error) {
console.error('获取用户列表失败:', error);
} finally {
setUsersLoading(false);
}
};
// 获取推送记录列表
const fetchLogs = async () => {
setLoading(true);
try {
const params: any = {
page,
page_size: pageSize,
};
if (filters.status !== undefined) params.status = filters.status;
if (filters.sender_id) params.sender_id = filters.sender_id;
if (filters.dateRange) {
params.start_time = filters.dateRange[0].format('YYYY-MM-DD HH:mm:ss');
params.end_time = filters.dateRange[1].format('YYYY-MM-DD HH:mm:ss');
}
const response = await MessagePushApiService.getPushLogs(params);
if (response.code === 200) {
setLogs(response.data.list);
setTotal(response.data.total);
}
} catch (error) {
message.error('获取推送记录失败');
} finally {
setLoading(false);
}
};
// 发送消息
const handleSend = async () => {
try {
const values = await form.validateFields();
if (!values.title?.trim()) {
message.error('请输入消息标题');
return;
}
if (values.title.trim().length > 100) {
message.error('标题不能超过100个字符');
return;
}
if (!values.content?.trim()) {
message.error('请输入消息内容');
return;
}
if (!values.user_ids || values.user_ids.length === 0) {
message.error('请选择目标用户');
return;
}
setLoading(true);
const response = await MessagePushApiService.sendMessage({
title: values.title.trim(),
content: values.content.trim(),
user_ids: values.user_ids,
});
if (response.code === 200) {
message.success('消息发送成功');
form.resetFields();
fetchLogs();
}
} catch (error) {
message.error('发送失败,请重试');
} finally {
setLoading(false);
}
};
// 查看详情
const handleViewDetail = async (id: number) => {
try {
const response = await MessagePushApiService.getPushLogDetail(id);
if (response.code === 200) {
setSelectedLog(response.data);
setDetailVisible(true);
}
} catch (error) {
message.error('获取详情失败');
}
};
// 搜索记录
const handleSearch = () => {
setPage(1);
fetchLogs();
};
// 重置筛选
const handleReset = () => {
setFilters({
status: undefined,
sender_id: undefined,
dateRange: undefined,
});
setPage(1);
fetchLogs();
};
useEffect(() => {
fetchLogs();
fetchAllUsers(); // 加载所有用户
}, [page, pageSize]);
// 表格列定义
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
width: 200,
render: (text: string) => (
<div style={{ maxWidth: 240 }}>
{text?.length > 20 ? `${text.substring(0, 20)}...` : text}
</div>
),
},
{
title: '消息内容',
dataIndex: 'content',
key: 'content',
width: 200,
render: (text: string) => (
<div style={{ maxWidth: 200 }}>
{text.length > 50 ? `${text.substring(0, 50)}...` : text}
</div>
),
},
{
title: '目标用户',
dataIndex: 'target_users',
key: 'target_users',
width: 120,
render: (targetUsers: string) => {
try {
const userIds = JSON.parse(targetUsers);
return (
<Tooltip title={`用户ID: ${userIds.join(', ')}`}>
<span>{userIds.length}</span>
</Tooltip>
);
} catch {
return '-';
}
},
},
{
title: '发送人',
dataIndex: 'sender_name',
key: 'sender_name',
width: 100,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: number) => {
const statusMap = {
0: { text: '待发送', color: 'default' },
1: { text: '发送中', color: 'processing' },
2: { text: '发送成功', color: 'success' },
3: { text: '发送失败', color: 'error' },
};
const config = statusMap[status as keyof typeof statusMap] || { text: '未知', color: 'default' };
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: '成功/失败',
key: 'counts',
width: 120,
render: (record: PushLogItem) => (
<span>
<Text type="success">{record.success_count}</Text> /
<Text type="danger">{record.fail_count}</Text>
</span>
),
},
{
title: '发送时间',
dataIndex: 'sent_at',
key: 'sent_at',
width: 150,
render: (sentAt: string) => sentAt ? dayjs(sentAt).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 150,
render: (createdAt: string) => dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
width: 100,
render: (record: PushLogItem) => (
<Space>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record.id)}
>
</Button>
</Space>
),
},
];
return (
<div className="message-push-page">
{/* 消息发送 */}
<Card title="消息推送" style={{ marginBottom: 16 }}>
<Form form={form} layout="vertical">
<Form.Item
label="标题"
name="title"
rules={[
{ required: true, message: '请输入消息标题' },
{ max: 100, message: '标题不能超过100字符' }
]}
>
<Input placeholder="请输入标题..." maxLength={100} />
</Form.Item>
<Form.Item
label="消息内容"
name="content"
rules={[
{ required: true, message: '请输入消息内容' },
{ max: 2000, message: '消息内容不能超过2000字符' }
]}
>
<TextArea
rows={4}
placeholder="请输入消息内容..."
maxLength={2000}
showCount
/>
</Form.Item>
<Form.Item
label="目标用户"
name="user_ids"
rules={[{ required: true, message: '请选择目标用户' }]}
>
<Select
mode="multiple"
placeholder="请选择目标用户"
style={{ width: '100%' }}
loading={usersLoading}
options={allUsers.map(user => ({
label: `${user.username} (${user.email})`,
value: user.id
}))}
filterOption={(input, option) => {
const label = option?.label as string || '';
return label.toLowerCase().includes(input.toLowerCase());
}}
showSearch
allowClear
/>
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={loading}
>
</Button>
<Button onClick={() => form.resetFields()}>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
{/* 推送记录 */}
<Card title="推送记录">
{/* 筛选条件 */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={6}>
<Select
placeholder="状态筛选"
value={filters.status}
onChange={(value) => setFilters({ ...filters, status: value })}
allowClear
style={{ width: '100%' }}
>
<Option value={0}></Option>
<Option value={1}></Option>
<Option value={2}></Option>
<Option value={3}></Option>
</Select>
</Col>
<Col span={6}>
<RangePicker
value={filters.dateRange}
onChange={(dates) => setFilters({ ...filters, dateRange: dates })}
showTime
style={{ width: '100%' }}
/>
</Col>
<Col span={4}>
<Space>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
<Table
columns={columns}
dataSource={logs}
loading={loading}
rowKey="id"
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPage(page);
setPageSize(pageSize);
},
}}
/>
</Card>
{/* 详情抽屉 */}
<Drawer
title="推送记录详情"
width={600}
open={detailVisible}
onClose={() => setDetailVisible(false)}
>
{selectedLog && (
<div>
<div>
<Text strong></Text>
<div style={{ marginTop: 8 }}>
{selectedLog.title || '-'}
</div>
</div>
<Divider />
<div>
<Text strong></Text>
<div style={{ marginTop: 8, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
{selectedLog.content}
</div>
</div>
<Divider />
<div>
<Text strong></Text>
<div style={{ marginTop: 8 }}>
{(() => {
try {
const userIds = JSON.parse(selectedLog.target_users);
return (
<div>
<Text> {userIds.length} </Text>
<div style={{ marginTop: 8 }}>
<Text code>{userIds.join(', ')}</Text>
</div>
</div>
);
} catch {
return <Text>-</Text>;
}
})()}
</div>
</div>
<Divider />
<Row gutter={[16, 16]}>
<Col span={12}>
<Text strong></Text>
<Text>{selectedLog.sender_name}</Text>
</Col>
<Col span={12}>
<Text strong></Text>
{(() => {
const statusMap = {
0: { text: '待发送', color: 'default' },
1: { text: '发送中', color: 'processing' },
2: { text: '发送成功', color: 'success' },
3: { text: '发送失败', color: 'error' },
};
const config = statusMap[selectedLog.status as keyof typeof statusMap] || { text: '未知', color: 'default' };
return <Tag color={config.color}>{config.text}</Tag>;
})()}
</Col>
</Row>
<Divider />
<Row gutter={[16, 16]}>
<Col span={8}>
<Statistic title="成功数量" value={selectedLog.success_count} valueStyle={{ color: '#3f8600' }} />
</Col>
<Col span={8}>
<Statistic title="失败数量" value={selectedLog.fail_count} valueStyle={{ color: '#cf1322' }} />
</Col>
<Col span={8}>
<Statistic title="总计" value={selectedLog.success_count + selectedLog.fail_count} />
</Col>
</Row>
{selectedLog.error_message && (
<>
<Divider />
<div>
<Text strong></Text>
<div style={{ marginTop: 8, padding: 12, background: '#fff2f0', borderRadius: 4, color: '#cf1322' }}>
{selectedLog.error_message}
</div>
</div>
</>
)}
<Divider />
<Row gutter={[16, 16]}>
<Col span={12}>
<Text strong></Text>
<Text>{dayjs(selectedLog.created_at).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Col>
{selectedLog.sent_at && (
<Col span={12}>
<Text strong></Text>
<Text>{dayjs(selectedLog.sent_at).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Col>
)}
</Row>
</div>
)}
</Drawer>
</div>
);
};
export default MessagePush;

83
src/pages/Monitoring.tsx Normal file
View File

@@ -0,0 +1,83 @@
import React, { useState, useEffect } from 'react';
import { QuotaApiService } from '../services/api';
interface QuotaHistoryItem {
id: number;
user_id: string;
api_group: string;
project_id: string;
day: string;
account: string;
model: string;
quota_used: number;
quota_used_text?: string;
created_at: string;
}
const Monitoring: React.FC = () => {
const [quotaData, setQuotaData] = useState<QuotaHistoryItem[]>([]);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// 初始化日期范围最近30天
useEffect(() => {
const today = new Date();
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(today.getDate() - 30);
setEndDate(today.toISOString().split('T')[0]);
setStartDate(thirtyDaysAgo.toISOString().split('T')[0]);
}, []);
// 计算统计数据
const stats = React.useMemo(() => {
if (quotaData.length === 0) {
return {
totalQuota: 0,
totalUsers: 0,
totalProjects: 0,
totalModels: 0,
};
}
const totalQuota = quotaData.reduce((sum, item) => sum + item.quota_used, 0);
const uniqueUsers = new Set(quotaData.map(item => item.user_id)).size;
const uniqueProjects = new Set(quotaData.map(item => item.project_id)).size;
const uniqueModels = new Set(quotaData.map(item => item.model)).size;
return {
totalQuota,
totalUsers: uniqueUsers,
totalProjects: uniqueProjects,
totalModels: uniqueModels,
};
}, [quotaData]);
return (
<section className="panel">
<h2>Token 使</h2>
<div className="desc">使</div>
<div className="kpis">
<div className="kpi">
<h3> Token 使</h3>
<div className="num">{stats.totalQuota.toLocaleString()}</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">{stats.totalUsers}</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">{stats.totalProjects}</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">{stats.totalModels}</div>
</div>
</div>
</section>
);
};
export default Monitoring;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { useAuth } from '../hooks/useAuth';
const NoPermission: React.FC = () => {
const { logout } = useAuth();
return (
<div style={{
minHeight: '60vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
textAlign: 'center',
gap: 16,
padding: 24,
}}>
<div style={{ fontSize: 72, lineHeight: 1, color: '#ff4d4f' }}>403</div>
<div style={{ fontSize: 18, fontWeight: 600 }}>访</div>
<div style={{ color: '#666' }}> sys_admin </div>
<button
onClick={async () => {
console.log('🔁 [NoPermission] 点击返回登录开始完整登出并清理IdP会话');
try { await logout(); } catch (e) { console.warn('⚠️ [NoPermission] 登出异常', e); }
}}
style={{
padding: '8px 16px',
borderRadius: 6,
border: '1px solid #1677ff',
background: '#1677ff',
color: '#fff',
cursor: 'pointer',
marginTop: 8,
}}
>
</button>
</div>
);
};
export default NoPermission;

51
src/pages/Operations.tsx Normal file
View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Card, Button, Space } from 'antd';
import { useNavigate } from 'react-router-dom';
const Operations: React.FC = () => {
const navigate = useNavigate();
return (
<section className="panel">
<h2></h2>
<div className="desc">广</div>
<div style={{ margin: '16px 0' }}>
<Card>
<Space wrap>
<Button type="primary" onClick={() => navigate('/mcp-provider-pricing')}>
MCP
</Button>
</Space>
</Card>
</div>
<div className="flex-3">
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
</div>
<div className="flex-2">
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
</div>
</section>
);
};
export default Operations;

42
src/pages/Overview.tsx Normal file
View File

@@ -0,0 +1,42 @@
import React from 'react';
const Overview: React.FC = () => {
return (
<section className="panel">
<h2></h2>
<div className="desc"></div>
<div className="kpis">
<div className="kpi">
<h3></h3>
<div className="num">1,234</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">567</div>
</div>
<div className="kpi">
<h3>Token消耗</h3>
<div className="num">12.5M</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num" style={{color: 'var(--success)'}}></div>
</div>
</div>
<div className="flex-2">
<div className="panel">
<h2>7</h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2>使</h2>
<div className="chart">使</div>
</div>
</div>
</section>
);
};
export default Overview;

View File

@@ -0,0 +1,634 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
message,
Space,
Popconfirm,
Tag,
Card,
Row,
Col,
Statistic,
Tree,
Checkbox
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
TeamOutlined,
KeyOutlined,
SettingOutlined
} from '@ant-design/icons';
import PermissionGuard from '../components/PermissionGuard';
import { usePermissions } from '../hooks/usePermissions';
import { RoleApiService } from '../services/roleApi';
import type { Role, RoleCreateRequest, RoleUpdateRequest, RoleListRequest } from '../services/roleApi';
interface Role {
id: number;
name: string;
description: string;
status: number;
permissions: string[];
createdAt: string;
}
interface Permission {
id: number;
name: string;
code: string;
module: string;
children?: Permission[];
}
const RoleManagement: React.FC = () => {
const [roles, setRoles] = useState<Role[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [permissionModalVisible, setPermissionModalVisible] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
const [form] = Form.useForm();
const { hasPermission } = usePermissions();
// 模拟数据
const mockRoles: Role[] = [
{
id: 1,
name: '超级管理员',
description: '拥有所有权限的超级管理员',
status: 1,
permissions: ['user:read', 'user:create', 'user:update', 'user:delete', 'role:read', 'role:create'],
createdAt: '2024-01-01 10:00:00'
},
{
id: 2,
name: '管理员',
description: '普通管理员,拥有大部分权限',
status: 1,
permissions: ['user:read', 'user:create', 'user:update', 'role:read'],
createdAt: '2024-01-02 09:00:00'
},
{
id: 3,
name: '操作员',
description: '基础操作权限',
status: 1,
permissions: ['user:read', 'role:read'],
createdAt: '2024-01-03 11:00:00'
}
];
const mockPermissions: Permission[] = [
{
id: 1,
name: '用户管理',
code: 'user',
module: 'user',
children: [
{ id: 11, name: '查看用户列表', code: 'user:read', module: 'user' },
{ id: 12, name: '创建用户', code: 'user:create', module: 'user' },
{ id: 13, name: '编辑用户', code: 'user:update', module: 'user' },
{ id: 14, name: '删除用户', code: 'user:delete', module: 'user' },
{ id: 15, name: '重置用户密码', code: 'user:reset_password', module: 'user' },
{ id: 16, name: '管理用户状态', code: 'user:status', module: 'user' }
]
},
{
id: 2,
name: '角色管理',
code: 'role',
module: 'role',
children: [
{ id: 21, name: '查看角色列表', code: 'role:read', module: 'role' },
{ id: 22, name: '创建角色', code: 'role:create', module: 'role' },
{ id: 23, name: '编辑角色', code: 'role:update', module: 'role' },
{ id: 24, name: '删除角色', code: 'role:delete', module: 'role' },
{ id: 25, name: '分配角色权限', code: 'role:assign_permission', module: 'role' }
]
},
{
id: 3,
name: '配额管理',
code: 'quota',
module: 'quota',
children: [
{ id: 31, name: '查看配额', code: 'quota:read', module: 'quota' },
{ id: 32, name: '设置配额', code: 'quota:write', module: 'quota' },
{ id: 33, name: '配额历史', code: 'quota:history', module: 'quota' }
]
}
];
const fetchRoles = async () => {
setLoading(true);
try {
console.log('🔍 [RoleManagement] 获取角色列表');
const response = await RoleApiService.getRoles({
page: 1,
size: 100
});
console.log('🔍 [RoleManagement] 角色列表响应:', response);
// 处理不同的响应格式
let rolesData = [];
if (response.data && response.data.roles && Array.isArray(response.data.roles)) {
// 新格式response.data.roles
rolesData = response.data.roles;
console.log('🔍 [RoleManagement] 使用新格式数据,角色数量:', rolesData.length);
} else if (response.data && Array.isArray(response.data)) {
// 直接数组格式
rolesData = response.data;
console.log('🔍 [RoleManagement] 使用直接数组格式,角色数量:', rolesData.length);
} else if (response.roles && Array.isArray(response.roles)) {
// 旧格式response.roles
rolesData = response.roles;
console.log('🔍 [RoleManagement] 使用旧格式数据,角色数量:', rolesData.length);
} else if (Array.isArray(response)) {
// 直接响应就是数组
rolesData = response;
console.log('🔍 [RoleManagement] 使用直接响应数组,角色数量:', rolesData.length);
}
console.log('🔍 [RoleManagement] 处理后的角色数据:', rolesData);
console.log('🔍 [RoleManagement] 第一个角色的页面权限:', rolesData[0]?.pages);
setRoles(rolesData);
} catch (error) {
console.error('❌ [RoleManagement] 获取角色列表失败:', error);
message.error('获取角色列表失败');
setRoles([]); // 确保设置为空数组
} finally {
setLoading(false);
}
};
const fetchPermissions = async () => {
try {
console.log('🔍 [RoleManagement] 获取页面列表');
// 使用带认证的API调用
const response = await fetch('/api/admin/pages', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
const result = await response.json();
console.log('🔍 [RoleManagement] 页面列表响应:', result);
if (result.success && result.data) {
// 将页面数据转换为权限树结构
const pagePermissions = result.data.map((page: any) => ({
id: page.id,
name: page.name,
code: page.path,
module: page.path.replace('/', ''),
children: [
{ id: page.id * 10 + 1, name: '查看', code: `${page.path}:read`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 2, name: '创建', code: `${page.path}:create`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 3, name: '编辑', code: `${page.path}:update`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 4, name: '删除', code: `${page.path}:delete`, module: page.path.replace('/', '') }
]
}));
setPermissions(pagePermissions);
} else {
console.warn('🔍 [RoleManagement] 页面列表获取失败,使用默认数据');
// 使用默认的页面数据
const defaultPages = [
{ id: 1, name: '总览', path: '/overview' },
{ id: 2, name: '运营', path: '/operations' },
{ id: 3, name: '监控', path: '/monitoring' },
{ id: 4, name: '财务', path: '/finance' },
{ id: 5, name: '系统管理', path: '/system' }
];
const pagePermissions = defaultPages.map((page: any) => ({
id: page.id,
name: page.name,
code: page.path,
module: page.path.replace('/', ''),
children: [
{ id: page.id * 10 + 1, name: '查看', code: `${page.path}:read`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 2, name: '创建', code: `${page.path}:create`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 3, name: '编辑', code: `${page.path}:update`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 4, name: '删除', code: `${page.path}:delete`, module: page.path.replace('/', '') }
]
}));
setPermissions(pagePermissions);
}
} catch (error) {
console.error('❌ [RoleManagement] 获取页面列表失败:', error);
message.error('获取页面列表失败');
// 使用默认数据
const defaultPages = [
{ id: 1, name: '总览', path: '/overview' },
{ id: 2, name: '运营', path: '/operations' },
{ id: 3, name: '监控', path: '/monitoring' },
{ id: 4, name: '财务', path: '/finance' },
{ id: 5, name: '系统管理', path: '/system' }
];
const pagePermissions = defaultPages.map((page: any) => ({
id: page.id,
name: page.name,
code: page.path,
module: page.path.replace('/', ''),
children: [
{ id: page.id * 10 + 1, name: '查看', code: `${page.path}:read`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 2, name: '创建', code: `${page.path}:create`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 3, name: '编辑', code: `${page.path}:update`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 4, name: '删除', code: `${page.path}:delete`, module: page.path.replace('/', '') }
]
}));
setPermissions(pagePermissions);
}
};
const handleCreate = () => {
setEditingRole(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (role: Role) => {
setEditingRole(role);
form.setFieldsValue({
name: role.name,
description: role.description,
status: role.status
});
setModalVisible(true);
};
const handleSubmit = async (values: any) => {
try {
if (editingRole) {
// 更新角色
console.log('🔍 [RoleManagement] 更新角色:', editingRole.id, values);
await RoleApiService.updateRole(editingRole.id, values);
message.success('角色更新成功');
} else {
// 创建角色
console.log('🔍 [RoleManagement] 创建角色:', values);
await RoleApiService.createRole(values);
message.success('角色创建成功');
}
setModalVisible(false);
fetchRoles(); // 重新获取角色列表
} catch (error) {
console.error('❌ [RoleManagement] 角色操作失败:', error);
message.error('操作失败');
}
};
const handleDelete = async (id: number) => {
try {
console.log('🔍 [RoleManagement] 删除角色:', id);
await RoleApiService.deleteRole(id);
message.success('角色删除成功');
fetchRoles(); // 重新获取角色列表
} catch (error) {
console.error('❌ [RoleManagement] 删除角色失败:', error);
message.error('删除失败');
}
};
const handleStatusChange = async (id: number, status: number) => {
try {
const updatedRoles = roles.map(role =>
role.id === id ? { ...role, status } : role
);
setRoles(updatedRoles);
message.success('角色状态更新成功');
} catch (error) {
message.error('状态更新失败');
}
};
const handlePermissionAssign = async (role: Role) => {
try {
console.log('🔍 [RoleManagement] 获取角色页面权限:', role.id);
setEditingRole(role);
// 优先使用角色数据中的页面权限如果没有则从API获取
if (role.pages && Array.isArray(role.pages)) {
console.log('🔍 [RoleManagement] 使用角色数据中的页面权限:', role.pages);
const pagePermissions = role.pages.map((page: any) => page.path);
setSelectedPermissions(pagePermissions);
setPermissionModalVisible(true);
return;
}
// 从API获取角色当前的页面权限
const response = await fetch(`/api/admin/rbac/roles/${role.id}/page-permissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
const result = await response.json();
console.log('🔍 [RoleManagement] 角色页面权限:', result);
if (result.success && result.data) {
// 将页面权限转换为权限代码
const pagePermissions = result.data.map((page: any) => page.path);
setSelectedPermissions(pagePermissions);
} else {
setSelectedPermissions([]);
}
setPermissionModalVisible(true);
} catch (error) {
console.error('❌ [RoleManagement] 获取角色权限失败:', error);
message.error('获取角色权限失败');
setSelectedPermissions([]);
setPermissionModalVisible(true);
}
};
const handlePermissionSubmit = async () => {
try {
if (!editingRole) return;
console.log('🔍 [RoleManagement] 分配角色页面权限:', editingRole.id, selectedPermissions);
// 将页面路径转换为页面ID
const pageIds = selectedPermissions.map(path => {
const page = permissions.find(p => p.code === path);
return page?.id;
}).filter(id => id !== undefined);
const response = await fetch(`/api/admin/rbac/role-page-permissions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
roleId: editingRole.id,
pageIds: pageIds
})
});
const result = await response.json();
console.log('🔍 [RoleManagement] 权限分配响应:', result);
if (result.success || response.ok) {
setPermissionModalVisible(false);
message.success('页面权限分配成功');
fetchRoles(); // 重新获取角色列表
} else {
message.error(result.message || '权限分配失败');
}
} catch (error) {
console.error('❌ [RoleManagement] 权限分配失败:', error);
message.error('权限分配失败');
}
};
const columns = [
{
title: '角色名称',
dataIndex: 'name',
key: 'name',
render: (text: string) => (
<Space>
<TeamOutlined />
{text}
</Space>
),
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
},
{
title: '页面权限',
dataIndex: 'pages',
key: 'pages',
render: (pages: any[]) => (
<div>
{pages && pages.length > 0 ? (
<div>
<Tag color="blue">
<KeyOutlined /> {pages.length}
</Tag>
<div style={{ marginTop: 4, fontSize: '12px', color: '#666' }}>
{pages.map(page => page.name).join(', ')}
</div>
</div>
) : (
<Tag color="red"></Tag>
)}
</div>
),
},
{
title: '角色等级',
dataIndex: 'level',
key: 'level',
render: (level: number) => (
<Tag color={level === 1 ? 'red' : level === 2 ? 'orange' : level === 3 ? 'blue' : level === 4 ? 'green' : 'default'}>
L{level}
</Tag>
),
},
{
title: '默认角色',
dataIndex: 'isDefault',
key: 'isDefault',
render: (isDefault: boolean) => (
<Tag color={isDefault ? 'green' : 'default'}>
{isDefault ? '是' : '否'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
},
{
title: '操作',
key: 'action',
render: (_, record: Role) => (
<Space>
<PermissionGuard permission="role:update">
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
</PermissionGuard>
<PermissionGuard permission="role:assign_permission">
<Button
type="link"
icon={<SettingOutlined />}
onClick={() => handlePermissionAssign(record)}
>
</Button>
</PermissionGuard>
<PermissionGuard permission="role:delete">
<Popconfirm
title="确定要删除这个角色吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</PermissionGuard>
</Space>
),
},
];
useEffect(() => {
fetchRoles();
fetchPermissions();
}, []);
const totalRoles = roles.length;
const activeRoles = roles.filter(role => role.status === 1).length;
const inactiveRoles = roles.filter(role => role.status === 0).length;
return (
<div>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card>
<Statistic
title="总角色数"
value={totalRoles}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="活跃角色"
value={activeRoles}
valueStyle={{ color: '#3f8600' }}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="禁用角色"
value={inactiveRoles}
valueStyle={{ color: '#cf1322' }}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
</Row>
{/* 操作栏 */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2>
<PermissionGuard permission="role:create">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</PermissionGuard>
</div>
{/* 角色表格 */}
<Table
columns={columns}
dataSource={roles}
loading={loading}
rowKey="id"
pagination={{
total: roles.length,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
}}
/>
{/* 角色表单弹窗 */}
<Modal
title={editingRole ? '编辑角色' : '创建角色'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={() => form.submit()}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form.Item
name="name"
label="角色名称"
rules={[{ required: true, message: '请输入角色名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea rows={3} />
</Form.Item>
{editingRole && (
<Form.Item
name="status"
label="状态"
>
<Select>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
)}
</Form>
</Modal>
{/* 页面权限分配弹窗 */}
<Modal
title={`分配页面权限 - ${editingRole?.name}`}
open={permissionModalVisible}
onCancel={() => setPermissionModalVisible(false)}
onOk={handlePermissionSubmit}
width={800}
>
<div>
<p style={{ marginBottom: 16, color: '#666' }}>
访访
</p>
<Tree
checkable
checkedKeys={selectedPermissions}
onCheck={(checkedKeys) => setSelectedPermissions(checkedKeys as string[])}
treeData={permissions.map(permission => ({
title: `${permission.name} (${permission.code})`,
key: permission.code,
children: permission.children?.map(child => ({
title: child.name,
key: child.code,
}))
}))}
/>
</div>
</Modal>
</div>
);
};
export default RoleManagement;

283
src/pages/SystemConfigs.tsx Normal file
View File

@@ -0,0 +1,283 @@
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
Tag,
Space,
Popconfirm,
message,
Row,
Col,
Card,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
CheckCircleOutlined,
StopOutlined,
} from '@ant-design/icons';
import type { SystemConfig } from '../types/systemConfig';
import {
getSystemConfigList,
createSystemConfig,
updateSystemConfig,
deleteSystemConfig,
updateSystemConfigStatus,
} from '../services/systemConfigApi';
export default function SystemConfigs() {
const [loading, setLoading] = useState(false);
const [list, setList] = useState<SystemConfig[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<SystemConfig | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const fetchList = async () => {
setLoading(true);
try {
const res = await getSystemConfigList({ page, size });
setList(res?.data ?? []);
setTotal(res?.total ?? 0);
} catch (e) {
message.error('获取列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchList();
}, [page, size]);
const openEdit = (config: SystemConfig) => {
setEditing(config);
form.setFieldsValue({
name: config.name,
value: config.value,
type: config.type,
desc: config.desc,
});
setEditOpen(true);
};
const submitEdit = async () => {
try {
const values = await form.validateFields();
if (!editing) return;
await updateSystemConfig(editing.id, values);
message.success('更新成功');
setEditOpen(false);
fetchList();
} catch (error) {
message.error('更新失败');
}
};
const openCreate = () => {
createForm.resetFields();
setCreateOpen(true);
};
const submitCreate = async () => {
try {
const values = await createForm.validateFields();
await createSystemConfig(values);
message.success('创建成功');
setCreateOpen(false);
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '创建失败');
}
};
const handleDelete = async (config: SystemConfig) => {
try {
await deleteSystemConfig(config.id);
message.success('删除成功');
fetchList();
} catch (error) {
message.error('删除失败');
}
};
const handleToggleStatus = async (config: SystemConfig) => {
try {
const newStatus = config.status === 1 ? 0 : 1;
await updateSystemConfigStatus(config.id, { status: newStatus });
message.success(newStatus === 1 ? '已启用' : '已禁用');
fetchList();
} catch (error) {
message.error('状态更新失败');
}
};
const columns = [
{
title: '配置标识',
dataIndex: 'key',
key: 'key',
width: 200,
},
{
title: '配置名称',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '配置值',
dataIndex: 'value',
key: 'value',
ellipsis: true,
},
{
title: '配置描述',
dataIndex: 'desc',
key: 'desc',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (value: number) =>
value === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>,
},
{
title: '操作',
key: 'action',
width: 250,
render: (_: any, config: SystemConfig) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(config)}>
</Button>
<Button
type="link"
icon={config.status === 1 ? <StopOutlined /> : <CheckCircleOutlined />}
onClick={() => handleToggleStatus(config)}
>
{config.status === 1 ? '禁用' : '启用'}
</Button>
<Popconfirm title="确定删除该配置?" onConfirm={() => handleDelete(config)}>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={24}>
<Card>
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</div>
<Table
dataSource={list}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: size,
total: total,
onChange: (p, s) => {
setPage(p);
setSize(s);
},
}}
/>
</Card>
</Col>
</Row>
{/* 编辑弹窗 */}
<Modal
title="编辑配置"
open={editOpen}
onOk={submitEdit}
onCancel={() => setEditOpen(false)}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="配置名称"
rules={[{ required: true, message: '请输入配置名称' }]}
>
<Input placeholder="请输入配置名称" />
</Form.Item>
<Form.Item
name="value"
label="配置值"
rules={[{ required: true, message: '请输入配置值' }]}
>
<Input.TextArea rows={4} placeholder="请输入配置值" />
</Form.Item>
<Form.Item name="type" label="配置类型">
<Input placeholder="请输入配置类型string, int, bool, json" />
</Form.Item>
<Form.Item name="desc" label="配置描述">
<Input.TextArea rows={3} placeholder="请输入配置描述" />
</Form.Item>
</Form>
</Modal>
{/* 创建弹窗 */}
<Modal
title="新建配置"
open={createOpen}
onOk={submitCreate}
onCancel={() => setCreateOpen(false)}
width={600}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="key"
label="配置标识"
rules={[{ required: true, message: '请输入配置标识' }]}
>
<Input placeholder="请输入配置标识唯一app_name" />
</Form.Item>
<Form.Item
name="name"
label="配置名称"
rules={[{ required: true, message: '请输入配置名称' }]}
>
<Input placeholder="请输入配置名称" />
</Form.Item>
<Form.Item
name="value"
label="配置值"
rules={[{ required: true, message: '请输入配置值' }]}
>
<Input.TextArea rows={4} placeholder="请输入配置值" />
</Form.Item>
<Form.Item name="type" label="配置类型">
<Input placeholder="请输入配置类型string, int, bool, json" />
</Form.Item>
<Form.Item name="desc" label="配置描述">
<Input.TextArea rows={3} placeholder="请输入配置描述" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import React from 'react';
const SystemHealth: React.FC = () => {
return (
<section className="panel">
<h2></h2>
<div className="desc"></div>
<div className="flex-3">
<div className="panel">
<h2></h2>
<div className="chart">(%)</div>
</div>
<div className="panel">
<h2></h2>
<div className="chart">P50 / P95 </div>
</div>
<div className="panel">
<h2></h2>
<div className="chart">5xx//</div>
</div>
</div>
</section>
);
};
export default SystemHealth;

View File

@@ -0,0 +1,27 @@
import React from 'react';
const TokenAnalytics: React.FC = () => {
return (
<section className="panel">
<h2>Token 使</h2>
<div className="desc">使</div>
<div className="flex-3">
<div className="panel">
<h2>使</h2>
<div className="chart">Token使用量趋势图占位</div>
</div>
<div className="panel">
<h2></h2>
<div className="chart">Token使用量排行占位</div>
</div>
<div className="panel">
<h2></h2>
<div className="chart">Token使用分布占位</div>
</div>
</div>
</section>
);
};
export default TokenAnalytics;

190
src/pages/TokenHistory.tsx Normal file
View File

@@ -0,0 +1,190 @@
import React from 'react'
import { QuotaApiService } from '../services/api'
import { Form, DatePicker, Input, Select, Button, Space, Dropdown, Checkbox, type MenuProps } from 'antd'
import dayjs, { Dayjs } from 'dayjs'
interface QuotaHistoryItem {
id: number;
user_id: string;
api_group: string;
project_id: string;
day: string;
account: string;
model: string;
quota_used: number;
quota_used_text?: string;
created_at: string;
}
export default function TokenHistoryPage() {
const [loading, setLoading] = React.useState(false)
const [data, setData] = React.useState<QuotaHistoryItem[]>([])
const [startDate, setStartDate] = React.useState('')
const [endDate, setEndDate] = React.useState('')
const [qhUserId, setQhUserId] = React.useState('')
const [qhApiGroup, setQhApiGroup] = React.useState('')
const [qhProjectId, setQhProjectId] = React.useState('')
const [qhPeriod, setQhPeriod] = React.useState<'daily'|'monthly'>('daily')
const [qhGroupBy, setQhGroupBy] = React.useState<string[]>(['user_id'])
const [groupDropOpen, setGroupDropOpen] = React.useState(false)
const groupOptions = [
{ label: '用户', value: 'user_id' },
{ label: 'API组', value: 'api_group' },
{ label: '项目', value: 'project_id' },
{ label: '服务商', value: 'provider' },
{ label: '模型', value: 'model' },
]
React.useEffect(() => {
const today = new Date()
const from = new Date()
from.setDate(today.getDate() - 30)
setEndDate(today.toISOString().split('T')[0])
setStartDate(from.toISOString().split('T')[0])
}, [])
const handleSearch = async () => {
setLoading(true)
try {
const params: any = {
start_date: startDate,
end_date: endDate,
period: qhPeriod,
group_by: qhGroupBy
}
if (qhUserId) params.user_id = qhUserId
if (qhApiGroup) params.api_group = qhApiGroup
if (qhProjectId) params.project_id = qhProjectId
const response: any = await QuotaApiService.getQuotaHistory(params)
let rows: any = []
if (Array.isArray(response)) rows = response
else if (Array.isArray(response?.data)) rows = response.data
else if (typeof response?.data === 'string') { try { rows = JSON.parse(response.data) } catch { rows = [] } }
if (Array.isArray(rows)) {
const normalized: QuotaHistoryItem[] = rows.map((row: any) => ({
id: row.ID ?? 0,
user_id: row.UserID ?? '',
api_group: row.ApiGroup ?? '',
project_id: row.ProjectID ?? '',
day: row.Day ?? '',
account: row.Account ?? '',
model: row.Model ?? '',
quota_used: typeof row.QuotaUsed === 'number' ? row.QuotaUsed : Number(row.QuotaUsed ?? 0),
quota_used_text: row.QuotaUsed !== undefined && row.QuotaUsed !== null ? String(row.QuotaUsed) : undefined,
created_at: row.CreatedAt ?? '',
}))
setData(normalized)
}
} finally {
setLoading(false)
}
}
const handleReset = () => {
setData([])
setQhUserId('')
setQhApiGroup('')
setQhProjectId('')
setQhPeriod('daily')
setQhGroupBy(['user_id'])
const today = new Date()
const from = new Date()
from.setDate(today.getDate() - 30)
setEndDate(today.toISOString().split('T')[0])
setStartDate(from.toISOString().split('T')[0])
}
return (
<section className="panel">
<h2>Token 使</h2>
<div className="desc">ID</div>
<Form layout="inline" className="form-row" onFinish={handleSearch}>
<Form.Item label="开始日期">
<DatePicker
value={startDate ? dayjs(startDate) : undefined}
onChange={(d: Dayjs|null)=> setStartDate(d ? d.format('YYYY-MM-DD') : '')}
/>
</Form.Item>
<Form.Item>
<span className="form-split"></span>
</Form.Item>
<Form.Item label="结束日期">
<DatePicker
value={endDate ? dayjs(endDate) : undefined}
onChange={(d: Dayjs|null)=> setEndDate(d ? d.format('YYYY-MM-DD') : '')}
/>
</Form.Item>
<Form.Item label="用户ID">
<Input placeholder="可选" value={qhUserId} onChange={(e)=>setQhUserId(e.target.value)} style={{width:160}} />
</Form.Item>
<Form.Item label="API组">
<Input placeholder="可选" value={qhApiGroup} onChange={(e)=>setQhApiGroup(e.target.value)} style={{width:140}} />
</Form.Item>
<Form.Item label="项目ID">
<Input placeholder="可选" value={qhProjectId} onChange={(e)=>setQhProjectId(e.target.value)} style={{width:140}} />
</Form.Item>
<Form.Item label="统计周期">
<Select
value={qhPeriod}
onChange={(v)=>setQhPeriod(v)}
style={{width:120}}
options={[{label:'按日', value:'daily'}, {label:'按月', value:'monthly'}]}
/>
</Form.Item>
<Form.Item label="分组维度">
<Select
mode="multiple"
value={qhGroupBy}
onChange={(vals)=>setQhGroupBy(vals)}
style={{minWidth:280}}
options={groupOptions}
placeholder="选择分组"
maxTagCount="responsive"
/>
</Form.Item>
<Form.Item>
<Space>
<Button htmlType="submit" loading={loading}> Token </Button>
<Button type="primary" onClick={handleReset}></Button>
</Space>
</Form.Item>
</Form>
<div className="panel full">
<div className="table-container">
<table className="table" data-sortable>
<thead>
<tr>
<th></th>
<th>ID</th>
<th></th>
</tr>
</thead>
<tbody>
{data.length > 0 ? (
data.slice(0, 200).map((item, idx) => (
<tr key={`${item.day}-${item.user_id}-${idx}`}>
<td>{item.day}</td>
<td>{item.user_id}</td>
<td>{item.quota_used_text ?? String(item.quota_used)}</td>
</tr>
))
) : (
<tr>
<td colSpan={3} style={{textAlign: 'center', color: 'var(--muted)'}}>
Token
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
)
}

489
src/pages/UserFeedback.tsx Normal file
View File

@@ -0,0 +1,489 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Table,
Button,
Input,
Select,
Space,
Tag,
message,
Popconfirm,
Image,
Row,
Col,
Statistic,
DatePicker,
Drawer,
Typography,
Divider,
} from 'antd';
import { SearchOutlined, EyeOutlined, CheckOutlined, DeleteOutlined } from '@ant-design/icons';
import { UserFeedbackApiService } from '../services/api';
import dayjs from 'dayjs';
const { Option } = Select;
const { RangePicker } = DatePicker;
const { Title, Text } = Typography;
interface FeedbackItem {
id: number;
user_id: number;
content: string;
file_keys: string[]; // 原始的文件key可能为JSON字符串
file_contents?: string[]; // 后端返回的Base64编码图片内容
status: number;
handled_by?: number;
handled_at?: string;
created_at: string;
updated_at: string;
}
const UserFeedback: React.FC = () => {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<FeedbackItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// 筛选条件
const [filters, setFilters] = useState({
status: undefined as number | undefined,
user_id: undefined as number | undefined,
keyword: '',
dateRange: null as [dayjs.Dayjs, dayjs.Dayjs] | null,
});
// 详情抽屉
const [detailVisible, setDetailVisible] = useState(false);
const [selectedFeedback, setSelectedFeedback] = useState<FeedbackItem | null>(null);
// 统计信息
const [statistics, setStatistics] = useState({
total: 0,
unhandled: 0,
handled: 0,
today_count: 0,
});
// 获取反馈列表
const fetchList = async () => {
setLoading(true);
try {
const params: any = {
page,
page_size: pageSize,
};
if (filters.status !== undefined) params.status = filters.status;
if (filters.user_id) params.user_id = filters.user_id;
if (filters.keyword) params.keyword = filters.keyword;
if (filters.dateRange) {
params.start_time = filters.dateRange[0].format('YYYY-MM-DD HH:mm:ss');
params.end_time = filters.dateRange[1].format('YYYY-MM-DD HH:mm:ss');
}
const response = await UserFeedbackApiService.getFeedbackList(params);
if (response.code === 200) {
// 处理file_keys字段将JSON字符串转换为数组
const processedList = response.data.list.map((item: any) => {
let fileKeys = [];
if (typeof item.file_keys === 'string') {
try {
fileKeys = JSON.parse(item.file_keys);
} catch (e) {
console.error('解析file_keys JSON失败:', e, item.file_keys);
fileKeys = [];
}
} else if (Array.isArray(item.file_keys)) {
fileKeys = item.file_keys;
}
console.log('处理后的文件数据:', { id: item.id, file_keys: fileKeys });
return {
...item,
file_keys: fileKeys
};
});
setData(processedList);
setTotal(response.data.total);
}
} catch (error) {
message.error('获取反馈列表失败');
} finally {
setLoading(false);
}
};
// 获取统计信息
const fetchStatistics = async () => {
try {
const response = await UserFeedbackApiService.getFeedbackStatistics();
if (response.code === 200) {
setStatistics(response.data);
}
} catch (error) {
console.error('获取统计信息失败:', error);
}
};
useEffect(() => {
fetchList();
fetchStatistics();
}, [page, pageSize]);
// 查看详情
const handleViewDetail = async (id: number) => {
try {
const response = await UserFeedbackApiService.getFeedbackDetail(id);
if (response.code === 200) {
// 处理file_keys字段
let fileKeys = [];
if (typeof response.data.file_keys === 'string') {
try {
fileKeys = JSON.parse(response.data.file_keys);
} catch (e) {
console.error('解析详情file_keys JSON失败:', e, response.data.file_keys);
fileKeys = [];
}
} else if (Array.isArray(response.data.file_keys)) {
fileKeys = response.data.file_keys;
}
console.log('详情页文件数据:', { id: response.data.id, file_keys: fileKeys });
const processedData = {
...response.data,
file_keys: fileKeys,
file_contents: response.data.file_contents || [], // 获取Base64图片内容
};
setSelectedFeedback(processedData);
setDetailVisible(true);
}
} catch (error) {
message.error('获取反馈详情失败');
}
};
// 切换处理状态
const handleMarkHandled = async (id: number, currentStatus: number) => {
try {
const response = await UserFeedbackApiService.markFeedbackHandled(id, '');
if (response.code === 200) {
const newStatus = currentStatus === 0 ? 1 : 0;
message.success(newStatus === 1 ? '标记为已处理成功' : '标记为未处理成功');
fetchList();
fetchStatistics();
}
} catch (error) {
message.error('状态切换失败');
}
};
// 删除反馈
const handleDelete = async (id: number) => {
try {
const response = await UserFeedbackApiService.deleteFeedback(id);
if (response.code === 200) {
message.success('删除成功');
fetchList();
fetchStatistics();
}
} catch (error) {
message.error('删除失败');
}
};
// 搜索
const handleSearch = () => {
setPage(1);
fetchList();
};
// 重置
const handleReset = () => {
setFilters({
status: undefined,
user_id: undefined,
keyword: '',
dateRange: null,
});
setPage(1);
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户ID',
dataIndex: 'user_id',
key: 'user_id',
width: 100,
},
{
title: '反馈内容',
dataIndex: 'content',
key: 'content',
ellipsis: true,
render: (text: string) => (
<div style={{ maxWidth: 200 }}>
{text.length > 50 ? `${text.substring(0, 50)}...` : text}
</div>
),
},
{
title: '文件',
dataIndex: 'file_keys',
key: 'file_keys',
width: 80,
render: (fileKeys: string[]) => (
<div>
{fileKeys && fileKeys.length > 0 ? (
<span style={{ color: '#1890ff' }}>{fileKeys.length}</span>
) : (
<span>-</span>
)}
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: number) => (
<Tag color={status === 1 ? 'green' : 'orange'}>
{status === 1 ? '已处理' : '未处理'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
width: 200,
render: (_, record: FeedbackItem) => (
<Space>
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record.id)}
>
</Button>
<Button
type="link"
icon={<CheckOutlined />}
onClick={() => handleMarkHandled(record.id, record.status)}
>
{record.status === 0 ? '标记处理' : '标记未处理'}
</Button>
<Popconfirm
title="确定要删除这个反馈吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: '24px' }}>
<Title level={2}></Title>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic title="总反馈数" value={statistics.total} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="未处理" value={statistics.unhandled} valueStyle={{ color: '#cf1322' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="已处理" value={statistics.handled} valueStyle={{ color: '#3f8600' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="今日新增" value={statistics.today_count} />
</Card>
</Col>
</Row>
{/* 筛选条件 */}
<Card style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={6}>
<Input
placeholder="搜索反馈内容"
value={filters.keyword}
onChange={(e) => setFilters({ ...filters, keyword: e.target.value })}
allowClear
/>
</Col>
<Col span={4}>
<Input
placeholder="用户ID"
value={filters.user_id || ''}
onChange={(e) => setFilters({ ...filters, user_id: e.target.value ? Number(e.target.value) : undefined })}
allowClear
/>
</Col>
<Col span={4}>
<Select
placeholder="状态"
value={filters.status}
onChange={(value) => setFilters({ ...filters, status: value })}
allowClear
style={{ width: '100%' }}
>
<Option value={0}></Option>
<Option value={1}></Option>
</Select>
</Col>
<Col span={6}>
<RangePicker
value={filters.dateRange}
onChange={(dates) => setFilters({ ...filters, dateRange: dates })}
showTime
style={{ width: '100%' }}
/>
</Col>
<Col span={4}>
<Space>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
</Card>
{/* 列表 */}
<Card>
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPage(page);
setPageSize(pageSize);
},
}}
/>
</Card>
{/* 详情抽屉 */}
<Drawer
title="反馈详情"
width={600}
open={detailVisible}
onClose={() => setDetailVisible(false)}
>
{selectedFeedback && (
<div>
<Row gutter={[16, 16]}>
<Col span={12}>
<Text strong>ID</Text>
<Text>{selectedFeedback.id}</Text>
</Col>
<Col span={12}>
<Text strong>ID</Text>
<Text>{selectedFeedback.user_id}</Text>
</Col>
<Col span={12}>
<Text strong></Text>
<Tag color={selectedFeedback.status === 1 ? 'green' : 'orange'}>
{selectedFeedback.status === 1 ? '已处理' : '未处理'}
</Tag>
</Col>
<Col span={12}>
<Text strong></Text>
<Text>{dayjs(selectedFeedback.created_at).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Col>
</Row>
<Divider />
<div>
<Text strong></Text>
<div style={{ marginTop: 8, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
{selectedFeedback.content}
</div>
</div>
{selectedFeedback.file_contents && selectedFeedback.file_contents.length > 0 && (
<>
<Divider />
<div>
<Text strong></Text>
<div style={{ marginTop: 8 }}>
{selectedFeedback.file_contents.map((fileContent, index) => (
<div key={index} style={{ marginBottom: 8 }}>
<Image
src={fileContent}
alt={`反馈图片 ${index + 1}`}
style={{ maxWidth: '100%', borderRadius: '4px', border: '1px solid #eee' }}
/>
</div>
))}
</div>
</div>
</>
)}
{selectedFeedback.status === 1 && (
<>
<Divider />
<Row gutter={[16, 16]}>
<Col span={12}>
<Text strong>ID</Text>
<Text>{selectedFeedback.handled_by}</Text>
</Col>
<Col span={12}>
<Text strong></Text>
<Text>{dayjs(selectedFeedback.handled_at).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Col>
</Row>
</>
)}
</div>
)}
</Drawer>
</div>
);
};
export default UserFeedback;

View File

@@ -0,0 +1,328 @@
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
InputNumber,
Space,
Popconfirm,
message,
Row,
Col,
Card,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import type { UserLevelConfig } from '../types/userLevelConfig';
import {
getUserLevelConfigList,
createUserLevelConfig,
updateUserLevelConfig,
deleteUserLevelConfig,
} from '../services/userLevelConfigApi';
export default function UserLevelConfigs() {
const [loading, setLoading] = useState(false);
const [list, setList] = useState<UserLevelConfig[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<UserLevelConfig | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const fetchList = async () => {
setLoading(true);
try {
const res = await getUserLevelConfigList({ page, size });
console.log('用户等级配置 API 响应:', res);
// 处理响应数据:如果是数组直接使用,否则取 data 字段
if (Array.isArray(res)) {
setList(res);
setTotal(res.length);
} else {
setList(res?.data ?? []);
setTotal(res?.total ?? 0);
}
} catch (e) {
console.error('获取列表失败:', e);
message.error('获取列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchList();
}, [page, size]);
const openEdit = (config: UserLevelConfig) => {
setEditing(config);
form.setFieldsValue({
level_name: config.level_name,
project_limit: config.project_limit,
coder_vm_limit: config.coder_vm_limit,
browser_vm_limit: config.browser_vm_limit,
process_limit: config.process_limit,
description: config.description,
sort_order: config.sort_order,
});
setEditOpen(true);
};
const submitEdit = async () => {
try {
const values = await form.validateFields();
if (!editing) return;
await updateUserLevelConfig(editing.id, values);
message.success('更新成功');
setEditOpen(false);
fetchList();
} catch (error) {
message.error('更新失败');
}
};
const openCreate = () => {
createForm.resetFields();
setCreateOpen(true);
};
const submitCreate = async () => {
try {
const values = await createForm.validateFields();
await createUserLevelConfig(values);
message.success('创建成功');
setCreateOpen(false);
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '创建失败');
}
};
const handleDelete = async (config: UserLevelConfig) => {
try {
await deleteUserLevelConfig(config.id);
message.success('删除成功');
fetchList();
} catch (error) {
message.error('删除失败');
}
};
const columns = [
{
title: '等级名称',
dataIndex: 'level_name',
key: 'level_name',
},
{
title: '等级代码',
dataIndex: 'level_code',
key: 'level_code',
},
{
title: '项目数限制',
dataIndex: 'project_limit',
key: 'project_limit',
render: (value: number) => (value === 0 ? '不限' : value),
},
{
title: 'Coder VM上限',
dataIndex: 'coder_vm_limit',
key: 'coder_vm_limit',
render: (value: number) => (value === 0 ? '不限' : value),
},
{
title: 'Browser VM上限',
dataIndex: 'browser_vm_limit',
key: 'browser_vm_limit',
render: (value: number) => (value === 0 ? '不限' : value),
},
{
title: '进程上限',
dataIndex: 'process_limit',
key: 'process_limit',
render: (value: number) => (value === 0 ? '不限' : value),
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
},
{
title: '排序',
dataIndex: 'sort_order',
key: 'sort_order',
},
{
title: '操作',
key: 'action',
render: (_: any, config: UserLevelConfig) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(config)}>
</Button>
<Popconfirm title="确定删除该等级配置?" onConfirm={() => handleDelete(config)}>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={24}>
<Card>
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</div>
<Table
dataSource={list}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: size,
total: total,
onChange: (p, s) => {
setPage(p);
setSize(s);
},
}}
/>
</Card>
</Col>
</Row>
{/* 编辑弹窗 */}
<Modal
title="编辑等级配置"
open={editOpen}
onOk={submitEdit}
onCancel={() => setEditOpen(false)}
>
<Form form={form} layout="vertical">
<Form.Item
name="level_name"
label="等级名称"
rules={[{ required: true, message: '请输入等级名称' }]}
>
<Input placeholder="请输入等级名称" />
</Form.Item>
<Form.Item
name="project_limit"
label="项目数限制0表示不限"
rules={[{ required: true, message: '请输入项目数限制' }]}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入项目数限制" />
</Form.Item>
<Form.Item
name="coder_vm_limit"
label="Coder VM上限0表示不限"
rules={[{ required: true, message: '请输入Coder VM上限' }]}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入Coder VM上限" />
</Form.Item>
<Form.Item
name="browser_vm_limit"
label="Browser VM上限0表示不限"
rules={[{ required: true, message: '请输入Browser VM上限' }]}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入Browser VM上限" />
</Form.Item>
<Form.Item
name="process_limit"
label="进程上限0表示不限"
rules={[{ required: true, message: '请输入进程上限' }]}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入进程上限" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="请输入描述" />
</Form.Item>
<Form.Item name="sort_order" label="排序顺序">
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入排序顺序" />
</Form.Item>
</Form>
</Modal>
{/* 创建弹窗 */}
<Modal
title="新建等级配置"
open={createOpen}
onOk={submitCreate}
onCancel={() => setCreateOpen(false)}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="level_name"
label="等级名称"
rules={[{ required: true, message: '请输入等级名称' }]}
>
<Input placeholder="请输入等级名称" />
</Form.Item>
<Form.Item
name="level_code"
label="等级代码"
rules={[{ required: true, message: '请输入等级代码' }]}
>
<Input placeholder="请输入等级代码vip_plus" />
</Form.Item>
<Form.Item
name="project_limit"
label="项目数限制0表示不限"
rules={[{ required: true, message: '请输入项目数限制' }]}
initialValue={0}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入项目数限制" />
</Form.Item>
<Form.Item
name="coder_vm_limit"
label="Coder VM上限0表示不限"
rules={[{ required: true, message: '请输入Coder VM上限' }]}
initialValue={0}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入Coder VM上限" />
</Form.Item>
<Form.Item
name="browser_vm_limit"
label="Browser VM上限0表示不限"
rules={[{ required: true, message: '请输入Browser VM上限' }]}
initialValue={0}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入Browser VM上限" />
</Form.Item>
<Form.Item
name="process_limit"
label="进程上限0表示不限"
rules={[{ required: true, message: '请输入进程上限' }]}
initialValue={0}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入进程上限" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="请输入描述" />
</Form.Item>
<Form.Item name="sort_order" label="排序顺序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入排序顺序" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,585 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
message,
Space,
Popconfirm,
Tag,
Card,
Row,
Col,
Statistic,
Transfer
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
UserOutlined,
TeamOutlined,
LockOutlined,
UnlockOutlined,
SettingOutlined
} from '@ant-design/icons';
// 移除权限控制相关导入
import { UserApiService } from '../services/userApi';
import { RoleApiService } from '../services/roleApi';
import { GoalfyMaxUsersApi } from '../services/api';
import type { User, UserCreateRequest, UserUpdateRequest, UserListRequest } from '../services/userApi';
import type { Role } from '../services/roleApi';
const { Option } = Select;
// GoalfyMax用户类型
interface GoalfyMaxUser {
id: number;
user_id: number;
username: string;
email: string;
nickname?: string;
}
const UserManagement: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0
});
const [form] = Form.useForm();
// 变更角色相关状态
const [changeRoleModalVisible, setChangeRoleModalVisible] = useState(false);
const [changeRoleForm] = Form.useForm();
const [goalfyMaxUsers, setGoalfyMaxUsers] = useState<GoalfyMaxUser[]>([]);
const [loadingGoalfyUsers, setLoadingGoalfyUsers] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const fetchUsers = async (page = 1, size = 10) => {
setLoading(true);
try {
const response = await UserApiService.getUsers({
page,
size
});
setUsers(response.users);
setPagination(prev => ({
...prev,
current: page,
pageSize: size,
total: response.total
}));
} catch (error) {
console.error('获取用户列表失败:', error);
message.error('获取用户列表失败');
setUsers([]); // 重置用户列表
} finally {
setLoading(false);
}
};
const fetchRoles = async () => {
try {
console.log('🔍 [UserManagement] 获取角色列表');
const response = await RoleApiService.getRoles({
page: 1,
size: 100
});
console.log('🔍 [UserManagement] 角色列表响应:', response);
// 处理不同的响应格式
let rolesData = [];
if (response.data && response.data.roles && Array.isArray(response.data.roles)) {
rolesData = response.data.roles;
} else if (response.data && Array.isArray(response.data)) {
rolesData = response.data;
} else if (response.roles && Array.isArray(response.roles)) {
rolesData = response.roles;
} else if (Array.isArray(response)) {
rolesData = response;
}
console.log('🔍 [UserManagement] 处理后的角色数据:', rolesData);
setRoles(rolesData);
} catch (error) {
console.error('❌ [UserManagement] 获取角色列表失败:', error);
message.error('获取角色列表失败');
setRoles([]);
}
};
const handleCreate = () => {
setEditingUser(null);
form.resetFields();
setModalVisible(true);
};
// 打开变更角色模态框
const handleOpenChangeRole = async () => {
setChangeRoleModalVisible(true);
changeRoleForm.resetFields();
setSelectedUserId(null);
// 加载GoalfyMax用户列表
await fetchGoalfyMaxUsers();
};
// 获取GoalfyMax用户列表
const fetchGoalfyMaxUsers = async () => {
setLoadingGoalfyUsers(true);
try {
const res = await GoalfyMaxUsersApi.list({
page: 1,
size: 1000, // 获取所有用户,可以根据需要调整
});
setGoalfyMaxUsers(res?.data?.users ?? []);
} catch (e) {
message.error('获取用户列表失败');
console.error('获取GoalfyMax用户列表失败', e);
} finally {
setLoadingGoalfyUsers(false);
}
};
// 当选择的用户改变时,检查当前角色
const handleUserSelectChange = async (userId: number) => {
setSelectedUserId(userId);
try {
const roleInfo = await UserApiService.checkUserRole(userId);
changeRoleForm.setFieldsValue({
system_role: roleInfo.current_role,
});
} catch (error: any) {
console.error('检查用户角色失败:', error);
message.error(error?.response?.data?.message || '检查用户角色失败');
// 如果检查失败默认设置为custom
changeRoleForm.setFieldsValue({
system_role: 'custom',
});
}
};
// 提交变更角色
const handleChangeRoleSubmit = async (values: any) => {
if (!values.user_id) {
message.error('请选择用户');
return;
}
try {
await UserApiService.changeUserSystemRole(values.user_id, values.system_role);
message.success('角色变更成功');
setChangeRoleModalVisible(false);
changeRoleForm.resetFields();
setSelectedUserId(null);
// 刷新用户列表
fetchUsers(pagination.current, pagination.pageSize);
} catch (error: any) {
console.error('变更角色失败:', error);
message.error(error?.response?.data?.message || '变更角色失败');
}
};
const handleEdit = async (user: User) => {
setEditingUser(user);
console.log('🔍 [UserManagement] 编辑用户:', user);
console.log('🔍 [UserManagement] 用户角色:', user.role);
// 从用户数据中获取当前角色ID
let currentRoleId = undefined;
if (user.role && user.role.id) {
currentRoleId = user.role.id;
}
console.log('🔍 [UserManagement] 设置表单角色ID:', currentRoleId);
form.setFieldsValue({
username: user.username,
email: user.email,
nickname: user.nickname,
roleId: currentRoleId, // 设置当前角色
status: user.status,
ssoProvider: user.ssoProvider
});
setModalVisible(true);
};
const handleSubmit = async (values: any) => {
try {
if (editingUser) {
// 更新用户信息(包括角色)
await UserApiService.updateUser(editingUser.id, values);
message.success('用户更新成功');
} else {
// 创建用户(包括角色)
await UserApiService.createUser(values);
message.success('用户创建成功');
}
setModalVisible(false);
fetchUsers(pagination.current, pagination.pageSize);
} catch (error) {
console.error('操作失败:', error);
message.error('操作失败');
}
};
const handleDelete = async (id: number) => {
try {
await UserApiService.deleteUser(id);
message.success('用户删除成功');
fetchUsers(pagination.current, pagination.pageSize);
} catch (error) {
console.error('删除失败:', error);
message.error('删除失败');
}
};
const handleStatusChange = async (id: number, status: number) => {
try {
await UserApiService.updateUserStatus(id, status);
message.success('用户状态更新成功');
fetchUsers(pagination.current, pagination.pageSize);
} catch (error) {
console.error('状态更新失败:', error);
message.error('状态更新失败');
}
};
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
render: (text: string) => (
<Space>
<UserOutlined />
{text}
</Space>
),
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role: any, record: User) => {
// 显示用户角色
if (role && role.name) {
return (
<Tag
color={role.level === 1 ? 'red' : role.level === 2 ? 'orange' : role.level === 3 ? 'blue' : role.level === 4 ? 'green' : 'default'}
>
<TeamOutlined /> {role.name} (L{role.level})
</Tag>
);
}
// 兼容旧数据
return (
<Tag color={record.role === '超级管理员' ? 'red' : 'blue'}>
<TeamOutlined /> {record.role}
</Tag>
);
},
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: number) => (
<Tag color={status === 1 ? 'green' : 'red'}>
{status === 1 ? '正常' : '禁用'}
</Tag>
),
},
{
title: 'SSO提供商',
dataIndex: 'ssoProvider',
key: 'ssoProvider',
render: (provider: string) => (
<Tag color="blue">{provider || 'default'}</Tag>
),
},
{
title: '登录次数',
dataIndex: 'loginCount',
key: 'loginCount',
},
{
title: '最后登录',
dataIndex: 'lastLoginAt',
key: 'lastLoginAt',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
},
{
title: '操作',
key: 'action',
render: (_, record: User) => (
<Space>
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Button
type="link"
icon={record.status === 1 ? <LockOutlined /> : <UnlockOutlined />}
onClick={() => handleStatusChange(record.id, record.status === 1 ? 0 : 1)}
>
{record.status === 1 ? '禁用' : '启用'}
</Button>
<Popconfirm
title="确定要删除这个用户吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
useEffect(() => {
fetchUsers(1, 10);
fetchRoles(); // 获取角色列表
}, []);
const totalUsers = pagination.total;
const activeUsers = (users || []).filter(user => user.status === 1).length;
const inactiveUsers = (users || []).filter(user => user.status === 0).length;
return (
<div>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic
title="后台用户总数"
value={totalUsers}
prefix={<UserOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="正常用户"
value={activeUsers}
valueStyle={{ color: '#3f8600' }}
prefix={<UserOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="禁用用户"
value={inactiveUsers}
valueStyle={{ color: '#cf1322' }}
prefix={<LockOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="系统管理员"
value={(users || []).filter(user => user.role === '系统管理员').length}
valueStyle={{ color: '#722ed1' }}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
</Row>
{/* 操作栏 */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2>
<Button type="primary" icon={<SettingOutlined />} onClick={handleOpenChangeRole}>
</Button>
</div>
{/* 用户表格 */}
<Table
columns={columns}
dataSource={users}
loading={loading}
rowKey="id"
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showQuickJumper: true,
onChange: (page, size) => {
fetchUsers(page, size);
},
onShowSizeChange: (current, size) => {
fetchUsers(current, size);
}
}}
/>
{/* 用户表单弹窗 */}
<Modal
title={editingUser ? '编辑用户' : '创建用户'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={() => form.submit()}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input disabled={!!editingUser} />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, message: '请输入邮箱' }]}
>
<Input />
</Form.Item>
{!editingUser && (
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }, { min: 8, message: '密码至少8位' }]}
>
<Input.Password placeholder="请输入密码" />
</Form.Item>
)}
<Form.Item
name="nickname"
label="昵称"
>
<Input />
</Form.Item>
<Form.Item
name="roleId"
label="角色"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select placeholder="请选择角色">
{roles.map(role => (
<Option key={role.id} value={role.id}>
{role.name} (L{role.level}) - {role.description}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="ssoProvider"
label="SSO提供商"
rules={[{ required: true, message: '请输入SSO提供商' }]}
>
<Input placeholder="如: default, google, github" />
</Form.Item>
{editingUser && (
<Form.Item
name="status"
label="状态"
>
<Select>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
)}
</Form>
</Modal>
{/* 变更用户角色弹窗 */}
<Modal
title="变更用户角色"
open={changeRoleModalVisible}
onCancel={() => {
setChangeRoleModalVisible(false);
changeRoleForm.resetFields();
setSelectedUserId(null);
}}
onOk={() => changeRoleForm.submit()}
width={600}
okText="变更"
>
<Form
form={changeRoleForm}
layout="vertical"
onFinish={handleChangeRoleSubmit}
>
<Form.Item
name="user_id"
label="选择用户"
rules={[{ required: true, message: '请选择用户' }]}
>
<Select
placeholder="请选择GoalfyMax用户"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase()) ||
(option?.value ?? '').toString().includes(input)
}
loading={loadingGoalfyUsers}
onChange={handleUserSelectChange}
options={goalfyMaxUsers.map(user => ({
value: user.user_id,
label: `${user.username} (${user.email})${user.nickname ? ` - ${user.nickname}` : ''}`,
}))}
/>
</Form.Item>
<Form.Item
name="system_role"
label="系统角色"
rules={[{ required: true, message: '请选择系统角色' }]}
>
<Select placeholder="请选择系统角色">
<Option value="custom"></Option>
<Option value="sys_admin"></Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserManagement;

View File

@@ -0,0 +1,202 @@
import React from 'react'
import { Table, Button, Modal, Form, Input, InputNumber, Switch, Space } from 'antd'
import { QuotaApiService } from '../services/api'
type QuotaRow = {
id: number
user_id: string
project_limit: number
coder_vm_limit: number
browser_vm_limit: number
process_limit: number
enabled?: boolean
description?: string
}
export default function UserProjectQuotaPage() {
const [loading, setLoading] = React.useState(false)
const [rows, setRows] = React.useState<QuotaRow[]>([])
const [open, setOpen] = React.useState(false)
const [editOpen, setEditOpen] = React.useState(false)
const [currentRow, setCurrentRow] = React.useState<QuotaRow | null>(null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const fetchList = async () => {
setLoading(true)
try {
const resp = await QuotaApiService.listUserProjectQuota({ page: 1, size: 100 })
// 兼容 {code,message,data} 或直接数组
const data = Array.isArray(resp?.data) ? resp.data : Array.isArray(resp) ? resp : (resp?.data?.list || [])
setRows(Array.isArray(data) ? data : [])
} finally {
setLoading(false)
}
}
React.useEffect(() => { fetchList() }, [])
const submitCreate = async () => {
const values = await form.validateFields()
if (!values.user_id || String(values.user_id).trim() === '') {
form.setFields([{ name:'user_id', errors:['用户ID不能为空'] }])
return
}
await QuotaApiService.createUserProjectQuota({
user_id: String(values.user_id).trim(),
project_limit: Number(values.project_limit),
coder_vm_limit: Number(values.coder_vm_limit),
browser_vm_limit: Number(values.browser_vm_limit),
process_limit: Number(values.process_limit),
enabled: values.enabled,
description: values.description?.trim() || undefined,
})
setOpen(false)
form.resetFields()
fetchList()
}
const submitEdit = async () => {
const values = await editForm.validateFields()
if (!currentRow) return
const uid = String(values.user_id ?? '').trim()
await QuotaApiService.updateUserProjectQuota(currentRow.id, {
// 后端可能需要 user_id一并传递取自编辑窗展示值
// @ts-ignore
user_id: uid,
project_limit: Number(values.project_limit),
coder_vm_limit: Number(values.coder_vm_limit),
browser_vm_limit: Number(values.browser_vm_limit),
process_limit: Number(values.process_limit),
enabled: values.enabled,
description: values.description?.trim() || undefined,
})
setEditOpen(false)
setCurrentRow(null)
editForm.resetFields()
fetchList()
}
return (
<section className="panel">
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<h2 style={{margin:0}}></h2>
<Button type="primary" onClick={()=>{ form.resetFields(); setOpen(true) }}></Button>
</div>
<div style={{height:12}} />
<Table
loading={loading}
rowKey={(r)=> String(r.id)}
dataSource={rows}
columns={[
{ title:'ID', dataIndex:'id', width:80 },
{ title:'用户ID', dataIndex:'user_id', width:160 },
{ title:'项目上限', dataIndex:'project_limit', width:120 },
{ title:'Coder VM上限', dataIndex:'coder_vm_limit', width:120 },
{ title:'Browser VM上限', dataIndex:'browser_vm_limit', width:120 },
{ title:'进程上限', dataIndex:'process_limit', width:120 },
{ title:'启用', dataIndex:'enabled', render:(v:boolean)=> v? '是':'否', width:100 },
{ title:'描述', dataIndex:'description' },
{
title:'操作', key:'actions', fixed:'right' as const, width:160,
render: (_: any, row: QuotaRow) => (
<Space>
<Button size="small" onClick={()=>{
setCurrentRow(row)
editForm.setFieldsValue({
user_id: row.user_id,
project_limit: row.project_limit,
coder_vm_limit: row.coder_vm_limit,
browser_vm_limit: row.browser_vm_limit,
process_limit: row.process_limit,
enabled: row.enabled ?? true,
description: row.description || '',
})
setEditOpen(true)
}}></Button>
<Button size="small" danger onClick={async()=>{
await QuotaApiService.deleteUserProjectQuota(row.id)
fetchList()
}}></Button>
</Space>
)
}
]}
pagination={false}
/>
<Modal
title="创建用户项目配额"
open={open}
onOk={submitCreate}
onCancel={()=>{ setOpen(false); form.resetFields() }}
okText="创建"
cancelText="取消"
>
<Form form={form} layout="vertical">
<Form.Item label="用户ID" name="user_id" rules={[{ required: true, message: '请输入用户ID' }]}>
<Input placeholder="例如123" />
</Form.Item>
<Form.Item label="项目上限" name="project_limit" rules={[{ required: true, message: '请输入项目上限' }]}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Space.Compact block>
<Form.Item label="Coder VM上限" name="coder_vm_limit" rules={[{ required: true, message: '请输入Coder VM上限' }]} style={{ flex:1 }}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Form.Item label="Browser VM上限" name="browser_vm_limit" rules={[{ required: true, message: '请输入Browser VM上限' }]} style={{ flex:1 }}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
</Space.Compact>
<Form.Item label="进程上限" name="process_limit" rules={[{ required: true, message: '请输入进程上限' }]}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Form.Item label="启用" name="enabled" valuePropName="checked" initialValue={true}>
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea rows={3} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑用户项目配额"
open={editOpen}
onOk={submitEdit}
onCancel={()=>{ setEditOpen(false); setCurrentRow(null); editForm.resetFields() }}
okText="保存"
cancelText="取消"
>
<Form form={editForm} layout="vertical">
<Form.Item label="用户ID" name="user_id">
<Input disabled />
</Form.Item>
<Form.Item label="项目上限" name="project_limit" rules={[{ required: true, message: '请输入项目上限' }]}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Space.Compact block>
<Form.Item label="Coder VM上限" name="coder_vm_limit" rules={[{ required: true, message: '请输入Coder VM上限' }]} style={{ flex:1 }}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Form.Item label="Browser VM上限" name="browser_vm_limit" rules={[{ required: true, message: '请输入Browser VM上限' }]} style={{ flex:1 }}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
</Space.Compact>
<Form.Item label="进程上限" name="process_limit" rules={[{ required: true, message: '请输入进程上限' }]}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Form.Item label="启用" name="enabled" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea rows={3} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
</section>
)
}

View File

@@ -0,0 +1,418 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Table,
Button,
Input,
Select,
Space,
Form,
message,
Switch,
Typography,
InputNumber,
} from 'antd';
import { SearchOutlined, ReloadOutlined, EditOutlined } from '@ant-design/icons';
import { VendorModelPricingApi } from '../services/api';
const { Option } = Select;
const { Title } = Typography;
interface VendorModelPricingItem {
id: number;
provider: string;
account: string;
model_name: string;
input_price: number;
output_price: number;
cache_read_price: number;
cache_create_price: number;
price_ratio?: number;
enabled: boolean;
created_at: string;
updated_at: string;
}
const VendorModelPricing: React.FC = () => {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<VendorModelPricingItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [providers, setProviders] = useState<string[]>([]);
const [editingCell, setEditingCell] = useState<{ rowId: number; field: string } | null>(null);
const [editingValue, setEditingValue] = useState<number>(0);
const [form] = Form.useForm();
// 获取数据
const fetchData = async (searchParams: any = {}) => {
setLoading(true);
try {
const response = await VendorModelPricingApi.getList({
...searchParams,
page,
size: pageSize,
});
setData(response.data || []);
setTotal(response.total || 0);
} catch (error) {
console.error('获取数据失败:', error);
message.error('获取数据失败');
} finally {
setLoading(false);
}
};
// 获取供应商列表
const fetchProviders = async () => {
try {
const response = await VendorModelPricingApi.getProviders();
setProviders(response.data || []);
} catch (error) {
console.error('获取供应商列表失败:', error);
}
};
useEffect(() => {
fetchData();
fetchProviders();
}, [page, pageSize]);
// 更新价格
const handleUpdatePricing = async (id: number, field: string, value: number) => {
try {
await VendorModelPricingApi.updatePricing(id, { [field]: value });
message.success('价格更新成功');
setEditingCell(null);
fetchData();
} catch (error) {
message.error('价格更新失败');
console.error('更新价格失败:', error);
}
};
// 更新状态
const handleUpdateStatus = async (id: number, enabled: boolean) => {
try {
await VendorModelPricingApi.updatePricing(id, { enabled });
message.success('状态更新成功');
fetchData();
} catch (error) {
message.error('状态更新失败');
console.error('更新状态失败:', error);
}
};
// 搜索
const handleSearch = (values: any) => {
setPage(1);
fetchData(values);
};
// 重置
const handleReset = () => {
form.resetFields();
setPage(1);
fetchData();
};
// 开始编辑
const handleEdit = (rowId: number, field: string, value: number) => {
setEditingCell({ rowId, field });
setEditingValue(value);
};
// 保存编辑
const handleSaveEdit = (rowId: number, field: string) => {
handleUpdatePricing(rowId, field, editingValue);
};
// 取消编辑
const handleCancelEdit = () => {
setEditingCell(null);
};
const columns = [
{
title: '供应商',
dataIndex: 'provider',
key: 'provider',
width: 120,
},
{
title: '模型名称',
dataIndex: 'model_name',
key: 'model_name',
width: 150,
},
{
title: '输入价格($/1K)',
dataIndex: 'input_price',
key: 'input_price',
width: 140,
render: (value: number, record: VendorModelPricingItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'input_price') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={4}
style={{ width: 100 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'input_price')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'input_price', value)}
>
<span>${value.toFixed(4)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '输出价格($/1K)',
dataIndex: 'output_price',
key: 'output_price',
width: 140,
render: (value: number, record: VendorModelPricingItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'output_price') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={4}
style={{ width: 100 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'output_price')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'output_price', value)}
>
<span>${value.toFixed(4)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '缓存读取价格($/1M)',
dataIndex: 'cache_read_price',
key: 'cache_read_price',
width: 160,
render: (value: number, record: VendorModelPricingItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'cache_read_price') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={4}
style={{ width: 100 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'cache_read_price')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'cache_read_price', value)}
>
<span>${value.toFixed(4)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '缓存创建价格($/1M)',
dataIndex: 'cache_create_price',
key: 'cache_create_price',
width: 160,
render: (value: number, record: VendorModelPricingItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'cache_create_price') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={4}
style={{ width: 100 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'cache_create_price')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'cache_create_price', value)}
>
<span>${value.toFixed(4)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '价格倍率',
dataIndex: 'price_ratio',
key: 'price_ratio',
width: 120,
render: (value: number | undefined, record: VendorModelPricingItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'price_ratio') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={2}
min={0}
max={100}
step={0.01}
style={{ width: 120 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'price_ratio')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
const ratio = Number(value ?? 1);
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'price_ratio', ratio)}
>
<span>{ratio.toFixed(2)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: 80,
render: (value: boolean, record: VendorModelPricingItem) => (
<Switch
checked={value}
onChange={(checked) => handleUpdateStatus(record.id, checked)}
/>
),
},
];
return (
<Card title={<Title level={4}></Title>}>
<Form form={form} onFinish={handleSearch} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item name="provider">
<Select placeholder="选择供应商" allowClear style={{ width: 150 }}>
{providers.map(provider => (
<Option key={provider} value={provider}>{provider}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="model">
<Input placeholder="模型名称" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="status">
<Select placeholder="状态" allowClear style={{ width: 120 }}>
<Option value="enabled"></Option>
<Option value="disabled"></Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
</Button>
<Button onClick={handleReset} icon={<ReloadOutlined />}>
</Button>
</Space>
</Form.Item>
</Form>
<Table
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPage(page);
setPageSize(pageSize || 10);
},
}}
/>
</Card>
);
};
export default VendorModelPricing;

732
src/pages/VmPricing.tsx Normal file
View File

@@ -0,0 +1,732 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Table,
Button,
Input,
Select,
Space,
Form,
message,
Switch,
Typography,
InputNumber,
Tabs,
Modal,
Popconfirm,
} from 'antd';
import { SearchOutlined, ReloadOutlined, EditOutlined, PlusOutlined, DeleteOutlined, CheckOutlined } from '@ant-design/icons';
import { VmPricingApi } from '../services/api';
const { Option } = Select;
const { Title } = Typography;
const { TabPane } = Tabs;
interface VmSpecItem {
id: number;
spec_type: string;
cpu_cores: number;
memory_gb: number;
description?: string;
cost_price_per_minute: number;
markup_rate: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
interface VmTemplateItem {
id: number;
spec_type: string;
template_id: string;
is_default: boolean;
created_at: string;
updated_at: string;
}
const VmPricing: React.FC = () => {
const [loading, setLoading] = useState(false);
const [specsData, setSpecsData] = useState<VmSpecItem[]>([]);
const [templatesData, setTemplatesData] = useState<VmTemplateItem[]>([]);
const [specsTotal, setSpecsTotal] = useState(0);
const [templatesTotal, setTemplatesTotal] = useState(0);
const [specsPage, setSpecsPage] = useState(1);
const [templatesPage, setTemplatesPage] = useState(1);
const [specsPageSize, setSpecsPageSize] = useState(10);
const [templatesPageSize, setTemplatesPageSize] = useState(10);
const [activeTab, setActiveTab] = useState('specs');
const [editingCell, setEditingCell] = useState<{ rowId: number; field: string } | null>(null);
const [editingValue, setEditingValue] = useState<number>(0);
const [specModalVisible, setSpecModalVisible] = useState(false);
const [templateModalVisible, setTemplateModalVisible] = useState(false);
const [specsForm] = Form.useForm();
const [templatesForm] = Form.useForm();
const [templateForm] = Form.useForm();
const [specForm] = Form.useForm();
const [specTypes, setSpecTypes] = useState<string[]>([]);
// 获取规格类型列表(用于模板表单)
const fetchSpecTypes = async () => {
try {
const response = await VmPricingApi.getSpecs({ size: 1000 });
const types = [...new Set((response.data || []).map((item: VmSpecItem) => item.spec_type))];
setSpecTypes(types);
} catch (error) {
console.error('获取规格类型失败:', error);
}
};
// 获取规格数据
const fetchSpecs = async (searchParams: any = {}) => {
setLoading(true);
try {
const response = await VmPricingApi.getSpecs({
...searchParams,
page: specsPage,
size: specsPageSize,
});
setSpecsData(response.data || []);
setSpecsTotal(response.total || 0);
} catch (error) {
console.error('获取规格数据失败:', error);
message.error('获取规格数据失败');
} finally {
setLoading(false);
}
};
// 获取模板数据
const fetchTemplates = async (searchParams: any = {}) => {
setLoading(true);
try {
const response = await VmPricingApi.getTemplates({
...searchParams,
page: templatesPage,
size: templatesPageSize,
});
setTemplatesData(response.data || []);
setTemplatesTotal(response.total || 0);
} catch (error) {
console.error('获取模板数据失败:', error);
message.error('获取模板数据失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSpecTypes(); // 获取规格类型列表
if (activeTab === 'specs') {
fetchSpecs();
} else {
fetchTemplates();
}
}, [specsPage, specsPageSize, templatesPage, templatesPageSize, activeTab]);
useEffect(() => {
// 当规格数据更新时,更新规格类型列表
if (specsData.length > 0) {
const types = [...new Set(specsData.map(item => item.spec_type))];
setSpecTypes(types);
}
}, [specsData]);
// 更新规格
const handleUpdateSpec = async (id: number, field: string, value: number | boolean) => {
try {
await VmPricingApi.updateSpec(id, { [field]: value });
message.success('更新成功');
setEditingCell(null);
fetchSpecs();
} catch (error) {
message.error('更新失败');
console.error('更新规格失败:', error);
}
};
// 更新状态
const handleUpdateStatus = async (id: number, isActive: boolean) => {
handleUpdateSpec(id, 'is_active', isActive);
};
// 搜索规格
const handleSpecsSearch = (values: any) => {
setSpecsPage(1);
fetchSpecs(values);
};
// 重置规格搜索
const handleSpecsReset = () => {
specsForm.resetFields();
setSpecsPage(1);
fetchSpecs();
};
// 搜索模板
const handleTemplatesSearch = (values: any) => {
setTemplatesPage(1);
fetchTemplates(values);
};
// 重置模板搜索
const handleTemplatesReset = () => {
templatesForm.resetFields();
setTemplatesPage(1);
fetchTemplates();
};
// 开始编辑
const handleEdit = (rowId: number, field: string, value: number) => {
setEditingCell({ rowId, field });
setEditingValue(value);
};
// 保存编辑
const handleSaveEdit = (rowId: number, field: string) => {
let valueToSave = editingValue;
// 加价率需要转换为小数(前端显示为百分比,后端存储为小数)
if (field === 'markup_rate') {
valueToSave = editingValue / 100;
}
handleUpdateSpec(rowId, field, valueToSave);
};
// 取消编辑
const handleCancelEdit = () => {
setEditingCell(null);
};
// 创建规格
const handleCreateSpec = async (values: any) => {
try {
// 转换加价率:前端输入百分比,后端存储小数
const data = {
...values,
markup_rate: values.markup_rate ? values.markup_rate / 100 : undefined,
};
await VmPricingApi.createSpec(data);
message.success('规格创建成功');
setSpecModalVisible(false);
specForm.resetFields();
fetchSpecs();
fetchSpecTypes(); // 刷新规格类型列表
} catch (error: any) {
message.error(error?.response?.data?.message || '规格创建失败');
console.error('创建规格失败:', error);
}
};
// 创建模板
const handleCreateTemplate = async (values: any) => {
try {
await VmPricingApi.createTemplate(values);
message.success('模板创建成功');
setTemplateModalVisible(false);
templateForm.resetFields();
fetchTemplates();
} catch (error: any) {
message.error(error?.response?.data?.message || '模板创建失败');
console.error('创建模板失败:', error);
}
};
// 删除规格
const handleDeleteSpec = async (id: number) => {
try {
await VmPricingApi.deleteSpec(id);
message.success('规格删除成功');
fetchSpecs();
fetchSpecTypes(); // 刷新规格类型列表
} catch (error) {
message.error('规格删除失败');
console.error('删除规格失败:', error);
}
};
// 删除模板
const handleDeleteTemplate = async (id: number) => {
try {
await VmPricingApi.deleteTemplate(id);
message.success('模板删除成功');
fetchTemplates();
} catch (error) {
message.error('模板删除失败');
console.error('删除模板失败:', error);
}
};
// 设置默认模板
const handleSetDefaultTemplate = async (id: number) => {
try {
await VmPricingApi.setDefaultTemplate(id);
message.success('默认模板设置成功');
fetchTemplates();
} catch (error) {
message.error('设置默认模板失败');
console.error('设置默认模板失败:', error);
}
};
// 规格表格列
const specsColumns = [
{
title: '配置类型',
dataIndex: 'spec_type',
key: 'spec_type',
width: 120,
},
{
title: 'CPU核心',
dataIndex: 'cpu_cores',
key: 'cpu_cores',
width: 100,
},
{
title: '内存(GB)',
dataIndex: 'memory_gb',
key: 'memory_gb',
width: 100,
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
width: 150,
render: (text: string) => text || '-',
},
{
title: '成本价($/分钟)',
dataIndex: 'cost_price_per_minute',
key: 'cost_price_per_minute',
width: 160,
render: (value: number, record: VmSpecItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'cost_price_per_minute') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={8}
min={0}
style={{ width: 120 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'cost_price_per_minute')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'cost_price_per_minute', value)}
>
<span>${value.toFixed(8)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '加价率(%)',
dataIndex: 'markup_rate',
key: 'markup_rate',
width: 140,
render: (value: number, record: VmSpecItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'markup_rate') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={4}
min={0}
max={100}
style={{ width: 100 }}
autoFocus
formatter={(value) => `${value}%`}
parser={(value) => value!.replace('%', '')}
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'markup_rate')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'markup_rate', value * 100)}
>
<span>{(value * 100).toFixed(2)}%</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 80,
render: (value: boolean, record: VmSpecItem) => (
<Switch
checked={value}
onChange={(checked) => handleUpdateStatus(record.id, checked)}
/>
),
},
{
title: '操作',
key: 'action',
width: 100,
render: (_: any, record: VmSpecItem) => (
<Popconfirm
title="确定要删除这个规格吗?"
description="删除后该规格的所有配置将无法使用"
onConfirm={() => handleDeleteSpec(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
),
},
];
// 模板表格列
const templatesColumns = [
{
title: '配置类型',
dataIndex: 'spec_type',
key: 'spec_type',
width: 120,
},
{
title: '模板ID',
dataIndex: 'template_id',
key: 'template_id',
width: 200,
},
{
title: '默认模板',
dataIndex: 'is_default',
key: 'is_default',
width: 100,
render: (value: boolean, record: VmTemplateItem) => (
value ? (
<span style={{ color: '#52c41a' }}>
<CheckOutlined />
</span>
) : (
<Button
type="link"
size="small"
onClick={() => handleSetDefaultTemplate(record.id)}
>
</Button>
)
),
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: VmTemplateItem) => (
<Popconfirm
title="确定要删除这个模板吗?"
onConfirm={() => handleDeleteTemplate(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
),
},
];
return (
<Card title={<Title level={4}></Title>}>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab="规格价格" key="specs">
<Space style={{ marginBottom: 16 }}>
<Form form={specsForm} onFinish={handleSpecsSearch} layout="inline">
<Form.Item name="spec_type">
<Input placeholder="配置类型" style={{ width: 150 }} />
</Form.Item>
<Form.Item name="status">
<Select placeholder="状态" allowClear style={{ width: 120 }}>
<Option value="active"></Option>
<Option value="inactive"></Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
</Button>
<Button onClick={handleSpecsReset} icon={<ReloadOutlined />}>
</Button>
</Space>
</Form.Item>
</Form>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setSpecModalVisible(true)}
>
</Button>
</Space>
<Table
dataSource={specsData}
columns={specsColumns}
rowKey="id"
loading={loading}
pagination={{
current: specsPage,
pageSize: specsPageSize,
total: specsTotal,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setSpecsPage(page);
setSpecsPageSize(pageSize || 10);
},
}}
/>
</TabPane>
<TabPane tab="模板配置" key="templates">
<Space style={{ marginBottom: 16 }}>
<Form form={templatesForm} onFinish={handleTemplatesSearch} layout="inline">
<Form.Item name="spec_type">
<Input placeholder="配置类型" style={{ width: 150 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
</Button>
<Button onClick={handleTemplatesReset} icon={<ReloadOutlined />}>
</Button>
</Space>
</Form.Item>
</Form>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setTemplateModalVisible(true)}
>
</Button>
</Space>
<Table
dataSource={templatesData}
columns={templatesColumns}
rowKey="id"
loading={loading}
pagination={{
current: templatesPage,
pageSize: templatesPageSize,
total: templatesTotal,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setTemplatesPage(page);
setTemplatesPageSize(pageSize || 10);
},
}}
/>
</TabPane>
</Tabs>
{/* 添加规格模态框 */}
<Modal
title="添加规格"
open={specModalVisible}
onCancel={() => {
setSpecModalVisible(false);
specForm.resetFields();
}}
footer={null}
>
<Form
form={specForm}
layout="vertical"
onFinish={handleCreateSpec}
>
<Form.Item
name="spec_type"
label="配置类型"
rules={[{ required: true, message: '请输入配置类型' }]}
>
<Input placeholder="如2c4g, 4c8g, 6c12g" />
</Form.Item>
<Form.Item
name="cpu_cores"
label="CPU核心数"
rules={[{ required: true, message: '请输入CPU核心数' }]}
>
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入CPU核心数" />
</Form.Item>
<Form.Item
name="memory_gb"
label="内存(GB)"
rules={[{ required: true, message: '请输入内存大小' }]}
>
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入内存大小(GB)" />
</Form.Item>
<Form.Item
name="description"
label="描述"
>
<Input placeholder="配置描述(可选)" />
</Form.Item>
<Form.Item
name="cost_price_per_minute"
label="成本价($/分钟)"
rules={[{ required: true, message: '请输入成本价' }]}
>
<InputNumber
min={0}
precision={8}
style={{ width: '100%' }}
placeholder="请输入成本价(美元/分钟)"
/>
</Form.Item>
<Form.Item
name="markup_rate"
label="加价率(%)"
initialValue={30}
>
<InputNumber
min={0}
max={100}
precision={2}
style={{ width: '100%' }}
placeholder="默认30%"
formatter={(value) => `${value}%`}
parser={(value) => value!.replace('%', '')}
/>
</Form.Item>
<Form.Item
name="is_active"
label="启用"
valuePropName="checked"
initialValue={true}
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={() => {
setSpecModalVisible(false);
specForm.resetFields();
}}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 添加模板模态框 */}
<Modal
title="添加模板"
open={templateModalVisible}
onCancel={() => {
setTemplateModalVisible(false);
templateForm.resetFields();
}}
footer={null}
>
<Form
form={templateForm}
layout="vertical"
onFinish={handleCreateTemplate}
>
<Form.Item
name="spec_type"
label="配置类型"
rules={[{ required: true, message: '请选择配置类型' }]}
>
<Select placeholder="请选择配置类型" showSearch>
{specTypes.map(type => (
<Option key={type} value={type}>{type}</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="template_id"
label="模板ID"
rules={[{ required: true, message: '请输入模板ID' }]}
>
<Input placeholder="请输入E2B模板ID" />
</Form.Item>
<Form.Item
name="is_default"
label="设为默认模板"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={() => {
setTemplateModalVisible(false);
templateForm.resetFields();
}}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default VmPricing;

View File

@@ -0,0 +1,109 @@
// 邀请码编辑功能测试用例
// 这个文件用于验证编辑邀请码功能是否正常工作
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { InviteCodes } from '../pages/InviteCodes';
import { InviteCodeApiService } from '../services/inviteCodeApi';
// Mock API 服务
jest.mock('../services/inviteCodeApi');
const mockInviteCodeApiService = InviteCodeApiService as jest.Mocked<typeof InviteCodeApiService>;
describe('邀请码编辑功能', () => {
const mockInviteCode = {
id: 1,
code: 'GFY-ABC12345-XYZ1',
is_used: false,
expires_at: '2025-12-31T23:59:59Z',
created_at: '2024-12-01T10:00:00Z',
invite_url: 'https://sso.example.com/register?invite=GFY-ABC12345-XYZ1'
};
beforeEach(() => {
// Mock API 调用
mockInviteCodeApiService.getInviteCodeList.mockResolvedValue({
list: [mockInviteCode],
total: 1
});
mockInviteCodeApiService.getStatistics.mockResolvedValue({
total: 1,
used: 0,
unused: 1,
today_created: 1
});
mockInviteCodeApiService.updateInviteCode.mockResolvedValue(mockInviteCode);
});
test('应该正确显示邀请码值', async () => {
render(<InviteCodes />);
// 等待数据加载
await waitFor(() => {
expect(screen.getByText('GFY-ABC12345-XYZ1')).toBeInTheDocument();
});
// 点击编辑按钮
const editButton = screen.getByText('编辑');
fireEvent.click(editButton);
// 验证邀请码值是否正确显示
await waitFor(() => {
const codeInput = screen.getByDisplayValue('GFY-ABC12345-XYZ1');
expect(codeInput).toBeInTheDocument();
expect(codeInput).toBeDisabled();
});
});
test('应该能够更新过期时间', async () => {
render(<InviteCodes />);
// 等待数据加载
await waitFor(() => {
expect(screen.getByText('GFY-ABC12345-XYZ1')).toBeInTheDocument();
});
// 点击编辑按钮
const editButton = screen.getByText('编辑');
fireEvent.click(editButton);
// 等待模态框打开
await waitFor(() => {
expect(screen.getByText('编辑邀请码')).toBeInTheDocument();
});
// 验证过期时间字段存在
const datePicker = screen.getByPlaceholderText('选择过期时间(可选,设置为空表示永不过期)');
expect(datePicker).toBeInTheDocument();
// 点击更新按钮
const updateButton = screen.getByText('更新');
fireEvent.click(updateButton);
// 验证 API 被调用
await waitFor(() => {
expect(mockInviteCodeApiService.updateInviteCode).toHaveBeenCalledWith(1, {
expires_at: undefined // 因为没有修改过期时间
});
});
});
test('已使用的邀请码编辑按钮应该被禁用', async () => {
const usedInviteCode = { ...mockInviteCode, is_used: true };
mockInviteCodeApiService.getInviteCodeList.mockResolvedValue({
list: [usedInviteCode],
total: 1
});
render(<InviteCodes />);
// 等待数据加载
await waitFor(() => {
expect(screen.getByText('GFY-ABC12345-XYZ1')).toBeInTheDocument();
});
// 验证编辑按钮被禁用
const editButton = screen.getByText('编辑');
expect(editButton.closest('button')).toBeDisabled();
});
});

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { usePagePermissions } from '../hooks/usePagePermissions';
// 页面组件导入
import Dashboard from '../pages/Dashboard';
import UserManagement from '../pages/UserManagement';
import RoleManagement from '../pages/RoleManagement';
import PermissionManagement from '../pages/PermissionManagement';
import FinanceTransactionLogs from '../pages/FinanceTransactionLogs';
import QuotaManagement from '../pages/QuotaManagement';
import GoalfyMaxUsers from '../pages/GoalfyMaxUsers';
import UserFeedback from '../pages/UserFeedback';
import MessagePush from '../pages/MessagePush';
import UserLevelConfigs from '../pages/UserLevelConfigs';
import Operations from '../pages/Operations';
import MCPProviderPricing from '../pages/MCPProviderPricing';
import AuditLogs from '../pages/AuditLogs';
// 动态路由配置
const DynamicRoutes: React.FC = () => {
const { getAccessiblePages } = usePagePermissions();
const accessiblePages = getAccessiblePages();
return (
<Routes>
{/* 默认重定向到仪表盘 */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* 仪表盘 - 所有用户都可以访问 */}
<Route path="/dashboard" element={<Dashboard />} />
{/* 总览页面 */}
{accessiblePages.includes('/overview') && (
<Route path="/overview" element={<Dashboard />} />
)}
{/* 运营页面 */}
{accessiblePages.includes('/operations') && (
<Route path="/operations" element={<Operations />} />
)}
{/* 监控页面 */}
{accessiblePages.includes('/monitoring') && (
<Route path="/monitoring" element={<RoleManagement />} />
)}
{/* 财务页面 */}
{accessiblePages.includes('/finance') && (
<Route path="/finance" element={<FinanceTransactionLogs />} />
)}
{/* 系统管理页面 */}
{accessiblePages.includes('/system') && (
<Route path="/system" element={<QuotaManagement />} />
)}
{/* GoalfyMax 用户管理 - 系统管理子菜单,随系统权限显示 */}
{accessiblePages.includes('/system') && (
<Route path="/system/goalfymax-users" element={<GoalfyMaxUsers />} />
)}
{/* 用户等级配置管理 - 系统管理子菜单,随系统权限显示 */}
{accessiblePages.includes('/system') && (
<Route path="/system/user-level-configs" element={<UserLevelConfigs />} />
)}
{/* 审计日志 - 系统管理子菜单 */}
{accessiblePages.includes('/system') && (
<Route path="/system/audit-logs" element={<AuditLogs />} />
)}
{/* 用户反馈管理 - 运营页面子菜单,随运营权限显示 */}
{accessiblePages.includes('/operations') && (
<Route path="/user-feedback" element={<UserFeedback />} />
)}
{/* 消息推送管理 - 运营页面子菜单,随运营权限显示 */}
{accessiblePages.includes('/operations') && (
<Route path="/message-push" element={<MessagePush />} />
)}
{/* MCP价格配置管理 - 运营页面子菜单,随运营权限显示 */}
{accessiblePages.includes('/operations') && (
<Route path="/mcp-provider-pricing" element={<MCPProviderPricing />} />
)}
{/* 404页面 */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
};
export default DynamicRoutes;

911
src/services/api.ts Normal file
View File

@@ -0,0 +1,911 @@
// 类型定义
interface QuotaHistoryRequest {
start_date: string;
end_date: string;
user_id?: string;
api_group?: string;
project_id?: string;
period?: string;
group_by?: string[];
}
interface QuotaHistoryItem {
id: number;
user_id: string;
api_group: string;
project_id: string;
day: string;
account: string;
model: string;
quota_used: number;
created_at: string;
}
interface QuotaHistoryResponse {
success: boolean;
data: QuotaHistoryItem[];
message?: string;
}
// API 基础配置
const API_BASE_URL = '';
// 创建 axios 实例用于自动处理 token
import axios from 'axios';
class ApiClient {
private instance: any;
private isRefreshing = false;
private failedQueue: Array<{
resolve: (value: any) => void;
reject: (reason: any) => void;
}> = [];
constructor(baseURL: string = '/api') {
this.instance = axios.create({
baseURL,
timeout: 60000,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
private processQueue(error: any, token: string | null = null) {
this.failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token);
}
});
this.failedQueue = [];
}
private async handleTokenRefresh(): Promise<{
access_token: string;
refresh_token?: string;
expires_in?: number;
}> {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
try {
// 使用独立的axios实例来避免循环依赖和拦截器影响
const refreshResponse = await axios.post<SSOCallbackResponse>(
'/api/sso/refresh',
{ refresh_token: refreshToken },
{
headers: {
'Content-Type': 'application/json',
},
}
);
const {
access_token,
refresh_token: newRefreshToken,
expires_in,
} = refreshResponse.data;
if (access_token) {
localStorage.setItem('token', access_token);
if (newRefreshToken) {
localStorage.setItem('refreshToken', newRefreshToken);
}
return {
access_token,
refresh_token: newRefreshToken,
expires_in,
};
}
throw new Error('No access token in refresh response');
} catch (error) {
// 刷新失败时不在这里清除token让上层的relogin事件处理器来处理
throw error;
}
}
private setupInterceptors() {
// 请求拦截器
this.instance.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
// 响应拦截器
this.instance.interceptors.response.use(
(response: any) => {
return response;
},
error => {
if (error.response?.status === 401) {
const originalRequest = error.config;
// 避免无限递归
if (originalRequest._retry) {
// 重试失败,触发重新登录
const event = new CustomEvent('auth:relogin');
window.dispatchEvent(event);
return Promise.reject(error);
}
if (this.isRefreshing) {
// 如果正在刷新token将请求加入队列
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers.Authorization = token;
return this.instance(originalRequest);
})
.catch(err => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
this.isRefreshing = true;
return this.handleTokenRefresh()
.then(refreshResult => {
const { access_token, refresh_token, expires_in } = refreshResult;
this.processQueue(null, `Bearer ${access_token}`);
originalRequest.headers.Authorization = `Bearer ${access_token}`;
// 刷新成功通知useAuth更新状态和重置定时器
const event = new CustomEvent('auth:tokenRefreshed', {
detail: {
access_token,
refresh_token,
expires_in,
},
});
window.dispatchEvent(event);
return this.instance(originalRequest);
})
.catch(refreshError => {
this.processQueue(refreshError, null);
// 刷新失败,重新进行单点登录
const event = new CustomEvent('auth:relogin');
window.dispatchEvent(event);
return Promise.reject(refreshError);
})
.finally(() => {
this.isRefreshing = false;
});
}
return Promise.reject(error);
}
);
}
// GET 请求
async get<T = any>(url: string, config?: any): Promise<T> {
const response = await this.instance.get<T>(url, config);
return response.data;
}
// POST 请求
async post<T = any>(
url: string,
data?: any,
config?: any
): Promise<T> {
const response = await this.instance.post<T>(url, data, config);
return response.data;
}
// PUT 请求
async put<T = any>(
url: string,
data?: any,
config?: any
): Promise<T> {
const response = await this.instance.put<T>(url, data, config);
return response.data;
}
// DELETE 请求
async delete<T = any>(
url: string,
data?: any,
config?: any
): Promise<T> {
const response = await this.instance.delete<T>(url, {
...config,
data,
});
return response.data;
}
}
export const apiClient = new ApiClient();
// GoalfyMax Users API
export const GoalfyMaxUsersApi = {
list(params: { username?: string; email?: string; status?: number; page?: number; size?: number }) {
const qs = new URLSearchParams()
if (params?.username) qs.set('username', params.username)
if (params?.email) qs.set('email', params.email)
if (typeof params?.status === 'number') qs.set('status', String(params.status))
if (params?.page) qs.set('page', String(params.page))
if (params?.size) qs.set('size', String(params.size))
return apiClient.get<any>(`/admin/goalfymax-users?${qs.toString()}`)
},
getById(id: number) {
return apiClient.get<any>(`/admin/goalfymax-users/${id}`)
},
create(payload: { username: string; email: string; password: string; nickname?: string; avatar?: string; user_level_id?: number; version?: number }) {
return apiClient.post<any>(`/admin/goalfymax-users`, payload)
},
update(id: number, payload: { nickname?: string; email?: string; avatar?: string; user_level_id?: number; version?: number; goalfy_hub_permission?: number }) {
return apiClient.put<any>(`/admin/goalfymax-users/${id}`, payload)
},
delete(id: number) {
return apiClient.delete<any>(`/admin/goalfymax-users/${id}`)
},
ban(id: number, reason: string) {
return apiClient.post<any>(`/admin/goalfymax-users/${id}/ban`, { reason })
},
unban(id: number) {
return apiClient.post<any>(`/admin/goalfymax-users/${id}/unban`, {})
},
addBalance(id: number, amount: number) {
return apiClient.post<any>(`/admin/goalfymax-users/${id}/add-balance`, { amount })
},
deductBalance(id: number, amount: number) {
return apiClient.post<any>(`/admin/goalfymax-users/${id}/deduct-balance`, { amount })
},
}
// ============ SSO 类型与工具 ============
export interface SSOLoginResponse {
success: boolean
message: string
auth_url: string
state: string
}
export interface SSOCallbackResponse {
success: boolean
message: string
access_token?: string
id_token?: string
refresh_token?: string
expires_in?: number
user_info?: {
sub: string;
name?: string;
email?: string;
preferred_username?: string;
permissions?: string[];
roles?: string[];
}
uuid?: string
}
export const AuthStorage = {
getToken(): string | null {
return localStorage.getItem('admin_auth_token')
},
setToken(token: string) {
localStorage.setItem('admin_auth_token', token)
},
clear() {
localStorage.removeItem('admin_auth_token')
}
}
export class SSOApiService {
static async initiateLogin(): Promise<SSOLoginResponse> {
const apiClient = new ApiClient()
const response = await apiClient.instance.post('/sso/login', {})
return response.data?.data as SSOLoginResponse
}
static async callback(code: string, state: string): Promise<SSOCallbackResponse> {
const apiClient = new ApiClient()
const response = await apiClient.instance.post('/sso/callback', { code, state })
return response.data?.data as SSOCallbackResponse
}
static async userInfo(): Promise<any> {
const apiClient = new ApiClient()
const response = await apiClient.instance.get('/sso/userinfo')
return response.data
}
static async logout(): Promise<any> {
const apiClient = new ApiClient()
const response = await apiClient.instance.post('/sso/logout', {})
return response.data?.data || response.data
}
static async refreshToken(refreshToken: string): Promise<SSOCallbackResponse> {
const apiClient = new ApiClient()
const response = await apiClient.instance.post('/sso/refresh', { refresh_token: refreshToken })
return response.data?.data as SSOCallbackResponse
}
}
// API 服务类
export class QuotaApiService {
/**
* 获取配额历史数据
*/
static async getQuotaHistory(request: QuotaHistoryRequest): Promise<QuotaHistoryResponse> {
try {
return await apiClient.post<QuotaHistoryResponse>('/quotas/history', request);
} catch (error) {
console.error('获取配额历史数据失败:', error);
throw error;
}
}
/**
* 健康检查
*/
static async healthCheck(): Promise<{ status: string }> {
try {
return await apiClient.get<{ status: string }>('/health');
} catch (error) {
console.error('健康检查失败:', error);
throw error;
}
}
/**
* 获取配额规则列表
*/
static async getQuotaRules(): Promise<any[]> {
try {
const body = await apiClient.get<any>('/quotas/rules');
// 后端统一返回 { code, message, data }
if (typeof body?.data === 'string') {
// 兼容 data 透传为字符串的情况
try { return JSON.parse(body.data); } catch { return []; }
}
return body?.data ?? [];
} catch (e) {
console.error('获取配额规则失败:', e);
throw e;
}
}
/**
* 创建配额规则
*/
static async createQuotaRule(payload: any): Promise<any> {
return await apiClient.post<any>('/quotas/rules', payload);
}
/**
* 更新配额规则
*/
static async updateQuotaRule(id: string, payload: any): Promise<any> {
return await apiClient.put<any>(`/quotas/rules/${id}`, payload);
}
/** 删除配额规则 */
static async deleteQuotaRule(id: string): Promise<any> {
return await apiClient.delete<any>(`/quotas/rules/${id}`);
}
// ===== 用户项目配额 CRUD =====
static async listUserProjectQuota(params: { user_id?: string; enabled?: string; page?: number; size?: number }) {
const qs = new URLSearchParams()
if (params?.user_id) qs.set('user_id', params.user_id)
if (params?.enabled) qs.set('enabled', params.enabled)
if (params?.page) qs.set('page', String(params.page))
if (params?.size) qs.set('size', String(params.size))
return await apiClient.get<any>(`/quotas/user-project?${qs.toString()}`);
}
static async createUserProjectQuota(payload: { user_id: string; project_limit: number; vm_limit: number; process_limit: number; enabled?: boolean; description?: string }) {
const body = { ...payload, user_id: String(payload.user_id ?? '').trim() }
return await apiClient.post<any>('/quotas/user-project', body);
}
static async updateUserProjectQuota(id: number, payload: { project_limit: number; vm_limit: number; process_limit: number; enabled?: boolean; description?: string }) {
return await apiClient.put<any>(`/quotas/user-project/${id}`, payload);
}
static async deleteUserProjectQuota(id: number) {
return await apiClient.delete<any>(`/quotas/user-project/${id}`);
}
}
// 财务相关 API
export class FinanceApiService {
static async listSandboxRecords(params: { page?: number; size?: number; user_id?: string; project?: string; start?: string; end?: string } = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.user_id) qs.set('user_id', params.user_id);
if (params.project) qs.set('project', params.project);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/sandbox-records?${qs.toString()}`);
}
static async listTokenUsages(params: { page?: number; size?: number; user_id?: string; project?: string; start?: string; end?: string } = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.user_id) qs.set('user_id', params.user_id);
if (params.project) qs.set('project', params.project);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/token-usages?${qs.toString()}`);
}
static async listMcpUsages(params: { page?: number; size?: number; user_id?: string; project?: string; start?: string; end?: string } = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.user_id) qs.set('user_id', params.user_id);
if (params.project) qs.set('project', params.project);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/mcp-usages?${qs.toString()}`);
}
static async listTransactionLogs(params: { page?: number; size?: number; user_id?: string; order_id?: string; type?: string; status?: string; start?: string; end?: string } = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.user_id) qs.set('user_id', params.user_id);
if (params.order_id) qs.set('order_id', params.order_id);
if (params.type) qs.set('type', params.type);
if (params.status) qs.set('status', params.status);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/transaction-logs?${qs.toString()}`);
}
static async listPaymentRecords(params: { page?: number; size?: number; user_id?: string; order_id?: string; paypal_order_id?: string; status?: string; refund_status?: string; payer_email?: string; start?: string; end?: string } = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.user_id) qs.set('user_id', params.user_id);
if (params.order_id) qs.set('order_id', params.order_id);
if (params.paypal_order_id) qs.set('paypal_order_id', params.paypal_order_id);
if (params.status) qs.set('status', params.status);
if (params.refund_status) qs.set('refund_status', params.refund_status);
if (params.payer_email) qs.set('payer_email', params.payer_email);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/payment-records?${qs.toString()}`);
}
static async refundPaymentRecord(payload: { order_id?: string; paypal_capture_id?: string; amount?: number }) {
return await apiClient.post<any>('/finance/payment-records/refund', payload);
}
static async listMcpAccountRechargeRecords(params: {
page?: number;
size?: number;
provider?: string;
account?: string;
start?: string;
end?: string;
} = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.provider) qs.set('provider', params.provider);
if (params.account) qs.set('account', params.account);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/mcp-account-recharge-records?${qs.toString()}`);
}
static async createMcpAccountRechargeRecord(payload: {
provider_id: string;
amount: number;
recharge_date: string;
remark?: string;
}) {
return await apiClient.post<any>('/finance/mcp-account-recharge-records', payload);
}
static async updateMcpAccountRechargeRecord(id: string, payload: {
amount?: number;
recharge_date?: string;
remark?: string;
}) {
return await apiClient.put<any>(`/finance/mcp-account-recharge-records/${id}`, payload);
}
static async deleteMcpAccountRechargeRecord(id: string) {
return await apiClient.delete<any>(`/finance/mcp-account-recharge-records/${id}`);
}
static async getMcpProviderAccounts(params?: {
status?: string;
is_used?: boolean;
}) {
const qs = new URLSearchParams();
if (params?.status) qs.set('status', params.status);
if (params?.is_used !== undefined) qs.set('is_used', String(params.is_used));
return await apiClient.get<any>(`/finance/mcp-provider-accounts?${qs.toString()}`);
}
static async getMcpAccountBalances() {
return await apiClient.get<any>('/finance/mcp-account-balances');
}
static async getMcpAccountBalanceHistory(providerId: string, params?: {
start?: string;
end?: string;
}) {
const qs = new URLSearchParams();
if (params?.start) qs.set('start', params.start);
if (params?.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/mcp-account-balances/${providerId}/history?${qs.toString()}`);
}
static async createMcpAccountBalance(payload: {
provider_id: string;
balance: number;
remark?: string;
}) {
return await apiClient.post<any>('/finance/mcp-account-balances', payload);
}
static async adjustMcpAccountBalance(providerId: string, payload: {
balance: number;
remark?: string;
}) {
return await apiClient.put<any>(`/finance/mcp-account-balances/${providerId}`, payload);
}
// 模型账号管理
static async listModelAccountRechargeRecords(params: {
page?: number;
size?: number;
provider?: string;
model_name?: string;
start?: string;
end?: string;
} = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.provider) qs.set('provider', params.provider);
if (params.model_name) qs.set('model_name', params.model_name);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/model-account-recharge-records?${qs.toString()}`);
}
static async createModelAccountRechargeRecord(payload: {
account: string;
amount: number;
recharge_date: string;
remark?: string;
}) {
return await apiClient.post<any>('/finance/model-account-recharge-records', payload);
}
static async updateModelAccountRechargeRecord(id: string, payload: {
amount?: number;
recharge_date?: string;
remark?: string;
}) {
return await apiClient.put<any>(`/finance/model-account-recharge-records/${id}`, payload);
}
static async deleteModelAccountRechargeRecord(id: string) {
return await apiClient.delete<any>(`/finance/model-account-recharge-records/${id}`);
}
static async getModelConfigAccounts(params?: {
enabled?: boolean;
}) {
const qs = new URLSearchParams();
if (params?.enabled !== undefined) qs.set('enabled', String(params.enabled));
return await apiClient.get<any>(`/finance/model-config-accounts?${qs.toString()}`);
}
static async getModelAccountBalances() {
return await apiClient.get<any>('/finance/model-account-balances');
}
static async getModelAccountBalanceHistory(account: string, params?: {
start?: string;
end?: string;
}) {
const qs = new URLSearchParams();
if (params?.start) qs.set('start', params.start);
if (params?.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/model-account-balances/${account}/history?${qs.toString()}`);
}
static async createModelAccountBalance(payload: {
account: string;
balance: number;
remark?: string;
}) {
return await apiClient.post<any>('/finance/model-account-balances', payload);
}
static async adjustModelAccountBalance(account: string, payload: {
balance: number;
remark?: string;
}) {
return await apiClient.put<any>(`/finance/model-account-balances/${account}`, payload);
}
}
// 用户反馈 API 服务类
export class UserFeedbackApiService {
/**
* 获取用户反馈列表
*/
static async getFeedbackList(params: {
page?: number;
page_size?: number;
status?: number;
user_id?: number;
keyword?: string;
start_time?: string;
end_time?: string;
}): Promise<any> {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.page_size) qs.set('page_size', String(params.page_size));
if (params.status !== undefined) qs.set('status', String(params.status));
if (params.user_id) qs.set('user_id', String(params.user_id));
if (params.keyword) qs.set('keyword', params.keyword);
if (params.start_time) qs.set('start_time', params.start_time);
if (params.end_time) qs.set('end_time', params.end_time);
return await apiClient.get<any>(`/admin/user-feedback?${qs.toString()}`);
}
/**
* 获取用户反馈详情
*/
static async getFeedbackDetail(id: number): Promise<any> {
return await apiClient.get<any>(`/admin/user-feedback/${id}`);
}
/**
* 标记反馈为已处理
*/
static async markFeedbackHandled(id: number, note?: string): Promise<any> {
return await apiClient.post<any>(`/admin/user-feedback/${id}/mark-handled`, {
note: note || ''
});
}
/**
* 删除用户反馈
*/
static async deleteFeedback(id: number): Promise<any> {
return await apiClient.delete<any>(`/admin/user-feedback/${id}`);
}
/**
* 获取反馈统计信息
*/
static async getFeedbackStatistics(): Promise<any> {
return await apiClient.get<any>('/admin/user-feedback/statistics');
}
}
/**
* 消息推送API服务
*/
export class MessagePushApiService {
/**
* 发送消息到GoalfyMax用户
*/
static async sendMessage(params: {
title: string;
content: string;
user_ids: number[];
}): Promise<any> {
return await apiClient.post<any>('/admin/message-push/send', params);
}
/**
* 获取推送记录列表
*/
static async getPushLogs(params: {
page?: number;
page_size?: number;
status?: number;
sender_id?: number;
start_time?: string;
end_time?: string;
}): Promise<any> {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.page_size) qs.set('page_size', String(params.page_size));
if (params.status !== undefined) qs.set('status', String(params.status));
if (params.sender_id) qs.set('sender_id', String(params.sender_id));
if (params.start_time) qs.set('start_time', params.start_time);
if (params.end_time) qs.set('end_time', params.end_time);
return await apiClient.get<any>(`/admin/message-push/logs?${qs.toString()}`);
}
/**
* 获取推送记录详情
*/
static async getPushLogDetail(id: number): Promise<any> {
return await apiClient.get<any>(`/admin/message-push/logs/${id}`);
}
/**
* 搜索GoalfyMax用户
*/
static async searchUsers(keyword: string, limit?: number): Promise<any> {
const qs = new URLSearchParams();
qs.set('keyword', keyword);
if (limit) qs.set('limit', String(limit));
return await apiClient.get<any>(`/admin/message-push/users/search?${qs.toString()}`);
}
}
// 供应商模型价格配置API
export const VendorModelPricingApi = {
/**
* 获取供应商模型价格配置列表
*/
async getList(params: {
provider?: string;
model?: string;
status?: string;
page?: number;
size?: number;
} = {}): Promise<any> {
const qs = new URLSearchParams();
if (params.provider) qs.set('provider', params.provider);
if (params.model) qs.set('model', params.model);
if (params.status) qs.set('status', params.status);
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
return await apiClient.get<any>(`/admin/vendor-model-pricing?${qs.toString()}`);
},
/**
* 更新模型价格
*/
async updatePricing(id: number, data: {
input_price?: number;
output_price?: number;
cache_read_price?: number;
cache_create_price?: number;
price_ratio?: number;
enabled?: boolean;
}): Promise<any> {
return await apiClient.put<any>(`/admin/vendor-model-pricing/${id}`, data);
},
/**
* 获取供应商列表
*/
async getProviders(): Promise<any> {
return await apiClient.get<any>('/admin/vendor-model-pricing/providers');
}
};
// 虚拟机价格配置API
export const VmPricingApi = {
/**
* 获取虚拟机规格列表
*/
async getSpecs(params: {
spec_type?: string;
status?: string;
page?: number;
size?: number;
} = {}): Promise<any> {
const qs = new URLSearchParams();
if (params.spec_type) qs.set('spec_type', params.spec_type);
if (params.status) qs.set('status', params.status);
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
return await apiClient.get<any>(`/admin/vm-pricing/specs?${qs.toString()}`);
},
/**
* 创建规格
*/
async createSpec(data: {
spec_type: string;
cpu_cores: number;
memory_gb: number;
description?: string;
cost_price_per_minute: number;
markup_rate?: number;
is_active?: boolean;
}): Promise<any> {
return await apiClient.post<any>('/admin/vm-pricing/specs', data);
},
/**
* 更新规格价格
*/
async updateSpec(id: number, data: {
cost_price_per_minute?: number;
markup_rate?: number;
is_active?: boolean;
}): Promise<any> {
return await apiClient.put<any>(`/admin/vm-pricing/specs/${id}`, data);
},
/**
* 删除规格
*/
async deleteSpec(id: number): Promise<any> {
return await apiClient.delete<any>(`/admin/vm-pricing/specs/${id}`);
},
/**
* 获取模板列表
*/
async getTemplates(params: {
spec_type?: string;
page?: number;
size?: number;
} = {}): Promise<any> {
const qs = new URLSearchParams();
if (params.spec_type) qs.set('spec_type', params.spec_type);
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
return await apiClient.get<any>(`/admin/vm-pricing/templates?${qs.toString()}`);
},
/**
* 创建模板
*/
async createTemplate(data: {
spec_type: string;
template_id: string;
is_default?: boolean;
}): Promise<any> {
return await apiClient.post<any>('/admin/vm-pricing/templates', data);
},
/**
* 删除模板
*/
async deleteTemplate(id: number): Promise<any> {
return await apiClient.delete<any>(`/admin/vm-pricing/templates/${id}`);
},
/**
* 设置默认模板
*/
async setDefaultTemplate(id: number): Promise<any> {
return await apiClient.put<any>(`/admin/vm-pricing/templates/${id}/default`, {});
},
};

View File

@@ -0,0 +1,21 @@
import { apiClient } from './api';
import type { AuditLog, AuditLogListRequest, AuditLogListResponse } from '../types/auditLog';
export class AuditLogApiService {
static async getAuditLogs(params: AuditLogListRequest = {}): Promise<AuditLogListResponse> {
const response = await apiClient.get<any>('/admin/audit-logs', { params });
const data = response?.data ?? {};
return {
list: (data.list ?? []) as AuditLog[],
total: Number(data.total ?? 0),
page: Number(data.page ?? params.page ?? 1),
size: Number(data.size ?? params.size ?? 20),
};
}
static async getAuditLogDetail(id: number): Promise<AuditLog | null> {
const response = await apiClient.get<any>(`/admin/audit-logs/${id}`);
return (response?.data ?? null) as AuditLog | null;
}
}

View File

@@ -0,0 +1,59 @@
import { apiClient } from './api';
import type {
InviteCode,
InviteCodeListRequest,
InviteCodeCreateRequest,
InviteCodeUpdateRequest,
InviteCodeStatistics,
InviteCodeListResponse,
ClientOption,
} from '../types/inviteCode';
export class InviteCodeApiService {
// 获取邀请码列表
static async getInviteCodeList(params?: InviteCodeListRequest): Promise<InviteCodeListResponse> {
const response = await apiClient.get('/admin/invite-codes', { params });
return response.data;
}
// 创建邀请码(支持设置过期时间和邮箱列表)
// 返回可能是单个邀请码对象或邀请码数组
static async createInviteCode(data?: InviteCodeCreateRequest): Promise<InviteCode | InviteCode[]> {
const response = await apiClient.post('/admin/invite-codes', data);
// 处理响应格式data.data 可能是对象或数组
return response.data?.data || response.data;
}
// 更新邀请码(支持更新过期时间)
static async updateInviteCode(id: number, data: InviteCodeUpdateRequest): Promise<InviteCode> {
const response = await apiClient.put(`/admin/invite-codes/${id}`, data);
return response.data;
}
// 删除邀请码
static async deleteInviteCode(id: number): Promise<void> {
await apiClient.delete(`/admin/invite-codes/${id}`);
}
// 获取统计信息
static async getStatistics(): Promise<InviteCodeStatistics> {
const response = await apiClient.get('/admin/invite-codes/statistics');
return response.data;
}
// 标记邀请码为已使用
static async markAsUsed(code: string): Promise<void> {
await apiClient.post('/admin/invite-codes/mark-used', { code });
}
// 验证邀请码是否有效
static async validateInviteCode(code: string): Promise<void> {
await apiClient.post('/admin/invite-codes/validate', { code });
}
// 获取客户端选项列表
static async getClientOptions(): Promise<ClientOption[]> {
const response = await apiClient.get('/admin/invite-codes/client-options');
return response.data;
}
}

View File

@@ -0,0 +1,69 @@
import { apiClient } from './api';
import axios from 'axios';
import type {
InviteCodeApplication,
InviteCodeApplicationListRequest,
InviteCodeApplicationListResponse,
InviteCodeApplicationCreateRequest,
InviteCodeApplicationApproveRequest,
InviteCodeApplicationRejectRequest,
InviteCodeApplicationBatchApproveRequest,
InviteCodeApplicationBatchRejectRequest,
InviteCodeApplicationStatistics,
} from '../types/inviteCodeApplication';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8087';
export const InviteCodeApplicationApiService = {
// 获取申请列表(需要认证)
async getApplicationList(params?: InviteCodeApplicationListRequest): Promise<InviteCodeApplicationListResponse> {
const qs = new URLSearchParams();
if (params?.email) qs.set('email', params.email);
if (params?.status) qs.set('status', params.status);
if (params?.start_time) qs.set('start_time', params.start_time);
if (params?.end_time) qs.set('end_time', params.end_time);
if (params?.page) qs.set('page', String(params.page));
if (params?.size) qs.set('size', String(params.size));
const response = await apiClient.get<any>(`/admin/invite-applications?${qs.toString()}`);
return response.data;
},
// 获取统计信息(需要认证)
async getStatistics(): Promise<InviteCodeApplicationStatistics> {
const response = await apiClient.get<any>('/admin/invite-applications/statistics');
return response.data;
},
// 获取待处理数量(需要认证)
async getPendingCount(): Promise<number> {
const response = await apiClient.get<any>('/admin/invite-applications/pending-count');
return response.data.count;
},
// 审批通过申请(需要认证)
async approveApplication(request: InviteCodeApplicationApproveRequest): Promise<void> {
await apiClient.post('/admin/invite-applications/approve', request);
},
// 审批拒绝申请(需要认证)
async rejectApplication(request: InviteCodeApplicationRejectRequest): Promise<void> {
await apiClient.post('/admin/invite-applications/reject', request);
},
// 批量审批通过(需要认证)
async batchApproveApplications(request: InviteCodeApplicationBatchApproveRequest): Promise<void> {
await apiClient.post('/admin/invite-applications/batch-approve', request);
},
// 批量审批拒绝(需要认证)
async batchRejectApplications(request: InviteCodeApplicationBatchRejectRequest): Promise<void> {
await apiClient.post('/admin/invite-applications/batch-reject', request);
},
// 提交申请(公开接口,不需要认证)
async submitApplication(request: InviteCodeApplicationCreateRequest): Promise<InviteCodeApplication> {
const response = await axios.post(`${API_BASE_URL}/api/public/invite-code/apply`, request);
return response.data.data;
},
};

View File

@@ -0,0 +1,63 @@
import { apiClient } from './api';
import type {
MCPProvider,
MCPProviderListRequest,
MCPProviderListResponse,
MCPProviderCreateRequest,
MCPProviderUpdateRequest,
MCPProviderStatusRequest,
MCPProviderIsUsedRequest,
} from '../types/mcpProvider';
// 获取MCP供应商列表
export const getMCPProviderList = async (
params: MCPProviderListRequest
): Promise<MCPProviderListResponse> => {
const response = await apiClient.get('/admin/mcp-providers', { params });
return response;
};
// 获取MCP供应商详情
export const getMCPProviderById = async (id: string): Promise<MCPProvider> => {
const response = await apiClient.get(`/admin/mcp-providers/${id}`);
return response.data;
};
// 创建MCP供应商
export const createMCPProvider = async (
data: MCPProviderCreateRequest
): Promise<MCPProvider> => {
const response = await apiClient.post('/admin/mcp-providers', data);
return response.data;
};
// 更新MCP供应商
export const updateMCPProvider = async (
id: string,
data: MCPProviderUpdateRequest
): Promise<MCPProvider> => {
const response = await apiClient.put(`/admin/mcp-providers/${id}`, data);
return response.data;
};
// 删除MCP供应商
export const deleteMCPProvider = async (id: string): Promise<void> => {
await apiClient.delete(`/admin/mcp-providers/${id}`);
};
// 更新MCP供应商状态
export const updateMCPProviderStatus = async (
id: string,
data: MCPProviderStatusRequest
): Promise<void> => {
await apiClient.patch(`/admin/mcp-providers/${id}/status`, data);
};
// 更新MCP供应商使用状态
export const updateMCPProviderIsUsed = async (
id: string,
data: MCPProviderIsUsedRequest
): Promise<void> => {
await apiClient.patch(`/admin/mcp-providers/${id}/is-used`, data);
};

118
src/services/roleApi.ts Normal file
View File

@@ -0,0 +1,118 @@
import { apiClient } from './api';
// 角色相关类型定义
export interface Role {
id: number;
name: string;
description: string;
status: number;
createdAt: string;
updatedAt: string;
}
export interface RoleCreateRequest {
name: string;
description?: string;
}
export interface RoleUpdateRequest {
name: string;
description?: string;
status?: number;
}
export interface RoleListRequest {
page?: number;
size?: number;
name?: string;
status?: number;
}
export interface RoleListResponse {
roles: Role[];
total: number;
page: number;
size: number;
}
export interface RolePermissionAssignRequest {
roleId: number;
permissionIds: number[];
}
export interface RolePermissionsResponse {
role: Role;
permissions: any[];
}
// 角色管理API服务
export class RoleApiService {
/**
* 获取角色列表
*/
static async getRoles(params?: RoleListRequest): Promise<RoleListResponse> {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.size) queryParams.set('size', params.size.toString());
if (params?.name) queryParams.set('name', params.name);
if (params?.status !== undefined) queryParams.set('status', params.status.toString());
return await apiClient.get<RoleListResponse>(`/admin/roles?${queryParams.toString()}`);
}
/**
* 获取角色详情
*/
static async getRoleById(id: number): Promise<Role> {
return await apiClient.get<Role>(`/admin/roles/${id}`);
}
/**
* 创建角色
*/
static async createRole(roleData: RoleCreateRequest): Promise<Role> {
return await apiClient.post<Role>('/admin/roles', roleData);
}
/**
* 更新角色
*/
static async updateRole(id: number, roleData: RoleUpdateRequest): Promise<Role> {
return await apiClient.put<Role>(`/admin/roles/${id}`, roleData);
}
/**
* 删除角色
*/
static async deleteRole(id: number): Promise<void> {
await apiClient.delete(`/admin/roles/${id}`);
}
/**
* 更新角色状态
*/
static async updateRoleStatus(id: number, status: number): Promise<void> {
await apiClient.put(`/admin/roles/${id}/status`, { status });
}
/**
* 更新角色权限
*/
static async updateRolePermissions(id: number, permissionIds: number[]): Promise<void> {
await apiClient.put(`/admin/roles/${id}/permissions`, { permissionIds });
}
/**
* 获取角色权限
*/
static async getRolePermissions(id: number): Promise<any[]> {
return await apiClient.get<any[]>(`/admin/roles/${id}/permissions`);
}
/**
* 获取角色权限响应(包含角色和权限)
*/
static async getRolePermissionsResponse(id: number): Promise<RolePermissionsResponse> {
return await apiClient.get<RolePermissionsResponse>(`/admin/rbac/roles/${id}/permissions-response`);
}
}

View File

@@ -0,0 +1,65 @@
import { apiClient } from './api';
import type {
SystemConfig,
SystemConfigListRequest,
SystemConfigListResponse,
SystemConfigCreateRequest,
SystemConfigUpdateRequest,
SystemConfigStatusRequest,
} from '../types/systemConfig';
// 获取系统配置列表
export const getSystemConfigList = async (
params: SystemConfigListRequest
): Promise<SystemConfigListResponse> => {
const response = await apiClient.get('/admin/system-configs', { params });
return response; // 返回完整响应对象,包含 code, message, data, total, page, size
};
// 获取所有系统配置(不分页)
export const getAllSystemConfigs = async (): Promise<SystemConfig[]> => {
const response = await apiClient.get('/admin/system-configs/all');
return response.data;
};
// 获取系统配置详情
export const getSystemConfigById = async (id: number): Promise<SystemConfig> => {
const response = await apiClient.get(`/admin/system-configs/${id}`);
return response.data;
};
// 根据Key获取系统配置
export const getSystemConfigByKey = async (key: string): Promise<SystemConfig> => {
const response = await apiClient.get(`/admin/system-configs/key/${key}`);
return response.data;
};
// 创建系统配置
export const createSystemConfig = async (
data: SystemConfigCreateRequest
): Promise<SystemConfig> => {
const response = await apiClient.post('/admin/system-configs', data);
return response.data;
};
// 更新系统配置
export const updateSystemConfig = async (
id: number,
data: SystemConfigUpdateRequest
): Promise<SystemConfig> => {
const response = await apiClient.put(`/admin/system-configs/${id}`, data);
return response.data;
};
// 删除系统配置
export const deleteSystemConfig = async (id: number): Promise<void> => {
await apiClient.delete(`/admin/system-configs/${id}`);
};
// 更新系统配置状态
export const updateSystemConfigStatus = async (
id: number,
data: SystemConfigStatusRequest
): Promise<void> => {
await apiClient.put(`/admin/system-configs/${id}/status`, data);
};

188
src/services/userApi.ts Normal file
View File

@@ -0,0 +1,188 @@
import { apiClient } from './api';
// 用户相关类型定义
export interface User {
id: number;
username: string;
email: string;
nickname: string;
roleId: number;
status: number;
createdAt: string;
lastLoginAt?: string;
loginCount: number;
ssoUserId?: string;
ssoProvider?: string;
// 角色信息
role?: {
id: number;
name: string;
level: number;
description: string;
};
}
export interface UserCreateRequest {
username: string;
email: string;
nickname?: string;
roleId: number;
ssoProvider: string;
password: string;
}
export interface UserUpdateRequest {
nickname?: string;
email?: string;
avatar?: string;
status?: number;
roleId?: number;
ssoProvider?: string;
}
export interface UserListRequest {
page?: number;
size?: number;
username?: string;
email?: string;
status?: number;
role?: string;
}
export interface UserListResponse {
users: User[];
total: number;
page: number;
size: number;
}
export interface UserRoleAssignRequest {
userId: number;
roleIds: number[];
}
export interface UserPermissionsResponse {
user: User;
roles: any[];
permissions: any[];
menus: any[];
}
// 通用响应处理函数
interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
// 用户管理API服务
export class UserApiService {
/**
* 获取用户列表
*/
static async getUsers(params?: UserListRequest): Promise<UserListResponse> {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.size) queryParams.set('size', params.size.toString());
if (params?.username) queryParams.set('username', params.username);
if (params?.email) queryParams.set('email', params.email);
if (params?.status !== undefined) queryParams.set('status', params.status.toString());
if (params?.role) queryParams.set('role', params.role);
const response = await apiClient.get<ApiResponse<UserListResponse>>(`/admin/users?${queryParams.toString()}`);
return response.data;
}
/**
* 获取用户详情
*/
static async getUserById(id: number): Promise<User> {
const response = await apiClient.get<ApiResponse<User>>(`/admin/users/${id}`);
return response.data;
}
/**
* 创建用户
*/
static async createUser(userData: UserCreateRequest): Promise<User> {
const response = await apiClient.post<ApiResponse<User>>('/admin/users', userData);
return response.data;
}
/**
* 更新用户
*/
static async updateUser(id: number, userData: UserUpdateRequest): Promise<User> {
const response = await apiClient.put<ApiResponse<User>>(`/admin/users/${id}`, userData);
return response.data;
}
/**
* 删除用户
*/
static async deleteUser(id: number): Promise<void> {
await apiClient.delete(`/admin/users/${id}`);
}
/**
* 更新用户状态
*/
static async updateUserStatus(id: number, status: number): Promise<void> {
await apiClient.put(`/admin/users/${id}/status`, { status });
}
/**
* 更新用户角色
*/
static async updateUserRoles(id: number, roleIds: number[]): Promise<void> {
await apiClient.put(`/admin/users/${id}/roles`, { roleIds });
}
/**
* 重置用户密码
*/
static async resetUserPassword(id: number, newPassword: string): Promise<void> {
await apiClient.post(`/admin/users/${id}/reset-password`, { newPassword });
}
/**
* 获取用户角色
*/
static async getUserRoles(id: number): Promise<any[]> {
return await apiClient.get<any[]>(`/admin/users/${id}/roles`);
}
/**
* 获取用户权限
*/
static async getUserPermissions(id: number): Promise<any[]> {
return await apiClient.get<any[]>(`/admin/users/${id}/permissions`);
}
/**
* 获取用户权限响应(包含用户、角色、权限、菜单)
*/
static async getUserPermissionsResponse(id: number): Promise<UserPermissionsResponse> {
return await apiClient.get<UserPermissionsResponse>(`/admin/rbac/users/${id}/permissions-response`);
}
/**
* 检查用户系统角色
*/
static async checkUserRole(userId: number): Promise<{ is_system_admin: boolean; current_role: string }> {
const response = await apiClient.get<ApiResponse<{ is_system_admin: boolean; current_role: string }>>(
`/admin/users/check-role/${userId}`
);
return response.data;
}
/**
* 变更用户系统角色
*/
static async changeUserSystemRole(userId: number, systemRole: 'sys_admin' | 'custom'): Promise<void> {
await apiClient.post('/admin/users/change-system-role', {
user_id: userId,
system_role: systemRole,
});
}
}

View File

@@ -0,0 +1,59 @@
import { apiClient } from './api';
import type {
UserLevelConfig,
UserLevelConfigListRequest,
UserLevelConfigListResponse,
UserLevelConfigCreateRequest,
UserLevelConfigUpdateRequest,
UserLevelConfigStatusRequest,
} from '../types/userLevelConfig';
// 获取用户等级配置列表
export const getUserLevelConfigList = async (
params: UserLevelConfigListRequest
): Promise<UserLevelConfigListResponse> => {
const response = await apiClient.get('/admin/user-level-configs', { params });
return response.data;
};
// 获取所有用户等级配置(不分页)
export const getAllUserLevelConfigs = async (): Promise<UserLevelConfig[]> => {
const response = await apiClient.get('/admin/user-level-configs/all');
return response.data;
};
// 获取用户等级配置详情
export const getUserLevelConfigById = async (id: number): Promise<UserLevelConfig> => {
const response = await apiClient.get(`/admin/user-level-configs/${id}`);
return response.data;
};
// 创建用户等级配置
export const createUserLevelConfig = async (
data: UserLevelConfigCreateRequest
): Promise<UserLevelConfig> => {
const response = await apiClient.post('/admin/user-level-configs', data);
return response.data;
};
// 更新用户等级配置
export const updateUserLevelConfig = async (
id: number,
data: UserLevelConfigUpdateRequest
): Promise<UserLevelConfig> => {
const response = await apiClient.put(`/admin/user-level-configs/${id}`, data);
return response.data;
};
// 删除用户等级配置
export const deleteUserLevelConfig = async (id: number): Promise<void> => {
await apiClient.delete(`/admin/user-level-configs/${id}`);
};
// 更新用户等级配置状态
export const updateUserLevelConfigStatus = async (
id: number,
data: UserLevelConfigStatusRequest
): Promise<void> => {
await apiClient.put(`/admin/user-level-configs/${id}/status`, data);
};

34
src/types/auditLog.ts Normal file
View File

@@ -0,0 +1,34 @@
export interface AuditLog {
id: number;
operation_type: string;
operation_time: string;
operator_id: number;
operator_email: string;
target_type?: string | null;
target_id?: number | null;
target_email?: string | null;
operation_details?: Record<string, any> | null;
ip_address?: string | null;
user_agent?: string | null;
status?: string | null;
error_message?: string | null;
}
export interface AuditLogListRequest {
operation_type?: string;
operator_email?: string;
target_email?: string;
start_time?: string;
end_time?: string;
page?: number;
size?: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
export interface AuditLogListResponse {
list: AuditLog[];
total: number;
page: number;
size: number;
}

56
src/types/inviteCode.ts Normal file
View File

@@ -0,0 +1,56 @@
// 简化版邀请码类型定义
export interface InviteCode {
id: number;
code: string;
is_used: boolean;
client_id?: string;
client_label?: string;
email?: string;
user_level_id?: number;
user_level_name?: string;
expires_at?: string | null;
created_at: string;
invite_url?: string;
}
export interface InviteCodeListRequest {
code?: string;
is_used?: boolean;
start_time?: string;
end_time?: string;
page?: number;
size?: number;
}
export interface InviteCodeCreateRequest {
emails?: string[];
user_level_id?: number;
expires_at?: string;
client_id?: string; // 保留向后兼容
}
export interface InviteCodeUpdateRequest {
client_id?: string;
email?: string;
user_level_id?: number;
expires_at?: string;
}
export interface InviteCodeStatistics {
total: number;
used: number;
unused: number;
today_created: number;
}
export interface InviteCodeListResponse {
list: InviteCode[];
total: number;
}
// 客户端选项
export interface ClientOption {
key: string;
value: string;
label: string;
}

View File

@@ -0,0 +1,64 @@
export interface InviteCodeApplication {
id: number;
email: string;
reason?: string;
status: 'pending' | 'approved' | 'rejected';
invite_code_id?: number;
invite_code?: {
id: number;
code: string;
expires_at?: string;
};
reject_reason?: string;
approved_at?: string;
approved_by?: string;
email_sent_at?: string;
created_at: string;
updated_at: string;
}
export interface InviteCodeApplicationListRequest {
email?: string;
status?: 'pending' | 'approved' | 'rejected';
start_time?: string;
end_time?: string;
page?: number;
size?: number;
}
export interface InviteCodeApplicationListResponse {
list: InviteCodeApplication[];
total: number;
}
export interface InviteCodeApplicationCreateRequest {
email: string;
reason?: string;
}
export interface InviteCodeApplicationApproveRequest {
application_id: number;
valid_days?: number; // 有效期天数默认7天
}
export interface InviteCodeApplicationRejectRequest {
application_id: number;
reject_reason?: string;
}
export interface InviteCodeApplicationBatchApproveRequest {
application_ids: number[];
valid_days?: number;
}
export interface InviteCodeApplicationBatchRejectRequest {
application_ids: number[];
reject_reason?: string;
}
export interface InviteCodeApplicationStatistics {
total_pending: number; // 待处理数量
total_approved: number; // 已通过数量
total_rejected: number; // 已拒绝数量
today_applied: number; // 今日申请数量
}

67
src/types/mcpProvider.ts Normal file
View File

@@ -0,0 +1,67 @@
export interface MCPProvider {
id: string;
provider: string;
base_url: string;
auth?: string;
account?: string;
price_type: string;
price: number;
floating_ratio: number;
is_used: boolean;
status: string;
description?: string;
created_at: string;
updated_at: string;
}
export interface MCPProviderListRequest {
page?: number;
page_size?: number;
provider?: string;
status?: string;
is_used?: boolean;
}
export interface MCPProviderListResponse {
data: MCPProvider[];
total: number;
page: number;
size: number;
}
export interface MCPProviderCreateRequest {
provider: string;
base_url: string;
auth?: string;
account?: string;
price_type: string;
price: number;
floating_ratio?: number;
is_used?: boolean;
status?: string;
description?: string;
}
export interface MCPProviderUpdateRequest {
provider?: string;
base_url?: string;
auth?: string;
account?: string;
price_type?: string;
price?: number;
floating_ratio?: number;
is_used?: boolean;
status?: string;
description?: string;
}
export interface MCPProviderStatusRequest {
status: 'active' | 'inactive';
}
export interface MCPProviderIsUsedRequest {
is_used: boolean;
}

66
src/types/quota.ts Normal file
View File

@@ -0,0 +1,66 @@
// 配额历史查询请求
export interface QuotaHistoryRequest {
start_date: string; // 开始日期 (YYYY-MM-DD)
end_date: string; // 结束日期 (YYYY-MM-DD)
user_id?: string; // 用户ID (可选)
api_group?: string; // API组 (可选)
project_id?: string; // 项目ID (可选)
period?: string; // 周期: daily, monthly
group_by?: string[]; // 分组维度
}
// 配额历史数据项
export interface QuotaHistoryItem {
id: number;
user_id: string;
api_group: string;
project_id: string;
day: string;
account: string;
model: string;
quota_used: number;
created_at: string;
}
// 配额历史查询响应
export interface QuotaHistoryResponse {
success: boolean;
data: QuotaHistoryItem[];
message?: string;
}
// 统计概览数据
export interface QuotaStats {
totalQuota: number;
totalUsers: number;
totalProjects: number;
totalModels: number;
dailyUsage: Array<{
date: string;
quota: number;
}>;
topUsers: Array<{
user_id: string;
quota_used: number;
}>;
topModels: Array<{
model: string;
quota_used: number;
}>;
}
// 图表数据
export interface ChartData {
name: string;
value: number;
color?: string;
}
// 时间范围选项
export interface TimeRange {
label: string;
value: string;
days: number;
}

50
src/types/systemConfig.ts Normal file
View File

@@ -0,0 +1,50 @@
// 系统配置类型定义
export interface SystemConfig {
id: number;
key: string;
name: string;
value: string;
type: string;
desc: string;
status: number; // 1-启用 0-禁用
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
export interface SystemConfigListRequest {
key?: string;
name?: string;
status?: number;
page?: number;
size?: number;
}
export interface SystemConfigListResponse {
code: number;
message: string;
data: SystemConfig[];
total: number;
page: number;
size: number;
}
export interface SystemConfigCreateRequest {
key: string;
name: string;
value: string;
type?: string;
desc?: string;
}
export interface SystemConfigUpdateRequest {
name: string;
value: string;
type?: string;
desc?: string;
}
export interface SystemConfigStatusRequest {
status: number;
}

View File

@@ -0,0 +1,55 @@
// 用户等级配置类型定义
export interface UserLevelConfig {
id: number;
level_name: string;
level_code: string;
project_limit: number;
coder_vm_limit: number; // Coder VM上限
browser_vm_limit: number; // Browser VM上限
process_limit: number; // 进程上限
description: string;
sort_order: number;
status: number; // 1-启用 0-禁用
created_at: string;
updated_at: string;
}
export interface UserLevelConfigListRequest {
level_name?: string;
status?: number;
page?: number;
size?: number;
}
export interface UserLevelConfigListResponse {
data: UserLevelConfig[];
total: number;
page: number;
size: number;
}
export interface UserLevelConfigCreateRequest {
level_name: string;
level_code: string;
project_limit: number;
coder_vm_limit: number;
browser_vm_limit: number;
process_limit: number;
description?: string;
sort_order?: number;
}
export interface UserLevelConfigUpdateRequest {
level_name: string;
project_limit: number;
coder_vm_limit: number;
browser_vm_limit: number;
process_limit: number;
description?: string;
sort_order?: number;
}
export interface UserLevelConfigStatusRequest {
status: number;
}

View File

@@ -0,0 +1,25 @@
export interface UserProjectQuotaItem {
id: number
user_id: string
project_limit: number
coder_vm_limit: number
browser_vm_limit: number
process_limit: number
enabled: boolean
description?: string
created_at?: string
updated_at?: string
}
export interface PageResponse<T> {
code: number
message: string
data: {
data?: T[]
items?: T[]
total: number
page: number
size: number
}
}

View File

@@ -0,0 +1,22 @@
// 存储迁移工具将旧的存储key迁移到新的统一key
export const migrateStorage = () => {
// 迁移 admin_auth_token -> token
const oldToken = localStorage.getItem('admin_auth_token');
if (oldToken) {
// 移除Bearer前缀只存储裸token
const cleanToken = oldToken.replace('Bearer ', '');
localStorage.setItem('token', cleanToken);
localStorage.removeItem('admin_auth_token');
}
// 迁移 admin_refresh_token -> refreshToken
const oldRefreshToken = localStorage.getItem('admin_refresh_token');
if (oldRefreshToken) {
localStorage.setItem('refreshToken', oldRefreshToken);
localStorage.removeItem('admin_refresh_token');
}
// 清理旧的存储
localStorage.removeItem('admin_auth_token');
localStorage.removeItem('admin_refresh_token');
};

14
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_APP_TITLE: string;
readonly VITE_APP_VERSION: string;
readonly VITE_ENABLE_DEVTOOLS: string;
readonly VITE_USE_MOCK: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}