feat():learning后台管理前端页面初始化
This commit is contained in:
214
src/App.css
Normal file
214
src/App.css
Normal 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
276
src/App.tsx
Normal 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
1
src/assets/react.svg
Normal 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
36
src/atoms/auth.ts
Normal 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);
|
||||
90
src/components/AuthGuard.tsx
Normal file
90
src/components/AuthGuard.tsx
Normal 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;
|
||||
108
src/components/DynamicMenu.css
Normal file
108
src/components/DynamicMenu.css
Normal 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;
|
||||
}
|
||||
250
src/components/DynamicMenu.tsx
Normal file
250
src/components/DynamicMenu.tsx
Normal 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
391
src/components/Layout.tsx
Normal 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;
|
||||
24
src/components/PagePermissionGuard.tsx
Normal file
24
src/components/PagePermissionGuard.tsx
Normal 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;
|
||||
57
src/components/PermissionGuard.tsx
Normal file
57
src/components/PermissionGuard.tsx
Normal 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;
|
||||
142
src/components/QuotaCharts.tsx
Normal file
142
src/components/QuotaCharts.tsx
Normal 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;
|
||||
|
||||
|
||||
162
src/components/QuotaFilters.tsx
Normal file
162
src/components/QuotaFilters.tsx
Normal 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;
|
||||
|
||||
|
||||
118
src/components/QuotaHistoryTable.tsx
Normal file
118
src/components/QuotaHistoryTable.tsx
Normal 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;
|
||||
|
||||
|
||||
82
src/components/QuotaStats.tsx
Normal file
82
src/components/QuotaStats.tsx
Normal 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;
|
||||
|
||||
|
||||
87
src/components/UserProjectQuotaPage.tsx
Normal file
87
src/components/UserProjectQuotaPage.tsx
Normal 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
415
src/hooks/useAuth.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
74
src/hooks/usePagePermissions.ts
Normal file
74
src/hooks/usePagePermissions.ts
Normal 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
|
||||
};
|
||||
};
|
||||
46
src/hooks/usePermissions.ts
Normal file
46
src/hooks/usePermissions.ts
Normal 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
1
src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
/* 基础样式重置 - 不干扰 App.css 的设计 */
|
||||
28
src/main.tsx
Normal file
28
src/main.tsx
Normal 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
315
src/pages/AuditLogs.tsx
Normal 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
42
src/pages/Dashboard.tsx
Normal 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
47
src/pages/Finance.tsx
Normal 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;
|
||||
544
src/pages/FinanceMcpAccountRechargeRecords.tsx
Normal file
544
src/pages/FinanceMcpAccountRechargeRecords.tsx
Normal 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;
|
||||
|
||||
85
src/pages/FinanceMcpUsage.tsx
Normal file
85
src/pages/FinanceMcpUsage.tsx
Normal 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;
|
||||
|
||||
|
||||
567
src/pages/FinanceModelAccountRechargeRecords.tsx
Normal file
567
src/pages/FinanceModelAccountRechargeRecords.tsx
Normal 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;
|
||||
|
||||
165
src/pages/FinancePaymentRecords.tsx
Normal file
165
src/pages/FinancePaymentRecords.tsx
Normal 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;
|
||||
|
||||
79
src/pages/FinanceSandboxCosts.tsx
Normal file
79
src/pages/FinanceSandboxCosts.tsx
Normal 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;
|
||||
|
||||
|
||||
92
src/pages/FinanceTokenUsage.tsx
Normal file
92
src/pages/FinanceTokenUsage.tsx
Normal 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;
|
||||
|
||||
|
||||
111
src/pages/FinanceTransactionLogs.tsx
Normal file
111
src/pages/FinanceTransactionLogs.tsx
Normal 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;
|
||||
|
||||
|
||||
|
||||
532
src/pages/GoalfyMaxUsers.tsx
Normal file
532
src/pages/GoalfyMaxUsers.tsx
Normal 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
954
src/pages/InviteCodes.tsx
Normal 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="请输入邮箱地址,例如: user1@example.com user2@example.com 或: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;
|
||||
517
src/pages/MCPProviderPricing.tsx
Normal file
517
src/pages/MCPProviderPricing.tsx
Normal 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
566
src/pages/MessagePush.tsx
Normal 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
83
src/pages/Monitoring.tsx
Normal 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;
|
||||
42
src/pages/NoPermission.tsx
Normal file
42
src/pages/NoPermission.tsx
Normal 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
51
src/pages/Operations.tsx
Normal 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
42
src/pages/Overview.tsx
Normal 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;
|
||||
634
src/pages/RoleManagement.tsx
Normal file
634
src/pages/RoleManagement.tsx
Normal 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
283
src/pages/SystemConfigs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/pages/SystemHealth.tsx
Normal file
27
src/pages/SystemHealth.tsx
Normal 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;
|
||||
27
src/pages/TokenAnalytics.tsx
Normal file
27
src/pages/TokenAnalytics.tsx
Normal 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
190
src/pages/TokenHistory.tsx
Normal 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
489
src/pages/UserFeedback.tsx
Normal 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;
|
||||
328
src/pages/UserLevelConfigs.tsx
Normal file
328
src/pages/UserLevelConfigs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
585
src/pages/UserManagement.tsx
Normal file
585
src/pages/UserManagement.tsx
Normal 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;
|
||||
202
src/pages/UserProjectQuota.tsx
Normal file
202
src/pages/UserProjectQuota.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
418
src/pages/VendorModelPricing.tsx
Normal file
418
src/pages/VendorModelPricing.tsx
Normal 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
732
src/pages/VmPricing.tsx
Normal 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;
|
||||
|
||||
109
src/pages/__tests__/InviteCodes.test.tsx
Normal file
109
src/pages/__tests__/InviteCodes.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
95
src/routes/DynamicRoutes.tsx
Normal file
95
src/routes/DynamicRoutes.tsx
Normal 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
911
src/services/api.ts
Normal 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`, {});
|
||||
},
|
||||
};
|
||||
21
src/services/auditLogApi.ts
Normal file
21
src/services/auditLogApi.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
59
src/services/inviteCodeApi.ts
Normal file
59
src/services/inviteCodeApi.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
69
src/services/inviteCodeApplicationApi.ts
Normal file
69
src/services/inviteCodeApplicationApi.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
63
src/services/mcpProviderApi.ts
Normal file
63
src/services/mcpProviderApi.ts
Normal 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
118
src/services/roleApi.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
65
src/services/systemConfigApi.ts
Normal file
65
src/services/systemConfigApi.ts
Normal 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
188
src/services/userApi.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
59
src/services/userLevelConfigApi.ts
Normal file
59
src/services/userLevelConfigApi.ts
Normal 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
34
src/types/auditLog.ts
Normal 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
56
src/types/inviteCode.ts
Normal 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;
|
||||
}
|
||||
64
src/types/inviteCodeApplication.ts
Normal file
64
src/types/inviteCodeApplication.ts
Normal 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
67
src/types/mcpProvider.ts
Normal 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
66
src/types/quota.ts
Normal 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
50
src/types/systemConfig.ts
Normal 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;
|
||||
}
|
||||
55
src/types/userLevelConfig.ts
Normal file
55
src/types/userLevelConfig.ts
Normal 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;
|
||||
}
|
||||
25
src/types/userProjectQuota.ts
Normal file
25
src/types/userProjectQuota.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
22
src/utils/storageMigration.ts
Normal file
22
src/utils/storageMigration.ts
Normal 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
14
src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user