Files
memind_adm/src/admin/pages/DashboardPage.tsx
T

298 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Link } from 'react-router-dom';
import { useCallback, useEffect, useState } from 'react';
import { getAdminDashboardSummary, restartAdminService } from '../../api/client';
import type { AdminServiceRestartAction } from '../../types';
import type { AdminDashboardSummary } from '../../types';
import { formatTime, formatYuan } from '../utils/format';
const QUICK_LINKS = [
{ to: '/users', label: '用户管理', desc: '创建账号、启用禁用' },
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
{ to: '/asset-gateway', label: '资产能力', desc: '可插拔素材插件、Provider 与专属 LLM 选择' },
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
{ to: '/memory-v2', label: 'Memory V2', desc: '长期记忆 backend 与前置路由开关' },
{ to: '/skill-runtime', label: 'Skill Runtime', desc: 'H5 manifest 关键词路由开关' },
{ to: '/system-tests', label: '系统测试验证', desc: '选择测试账号执行联调并汇总问题反馈' },
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
] as const;
export function DashboardPage() {
const [summary, setSummary] = useState<AdminDashboardSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [serviceBusy, setServiceBusy] = useState<AdminServiceRestartAction | null>(null);
const [serviceMessage, setServiceMessage] = useState<string | null>(null);
const [serviceError, setServiceError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setSummary(await getAdminDashboardSummary());
} catch (err) {
setError(err instanceof Error ? err.message : '加载概览失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const handleServiceRestart = useCallback(async (action: AdminServiceRestartAction) => {
setServiceBusy(action);
setServiceMessage(null);
setServiceError(null);
try {
const result = await restartAdminService(action);
const suffix = result.logFile ? ` 日志: ${result.logFile}` : '';
setServiceMessage(`${result.message}${suffix}`);
if (action === 'local_restart') {
window.setTimeout(() => window.location.reload(), 6000);
}
} catch (err) {
setServiceError(err instanceof Error ? err.message : `${action} 失败`);
} finally {
setServiceBusy(null);
}
}, []);
const safeSummary: AdminDashboardSummary = summary ?? {
users: { total: 0, active: 0, lowBalance: 0, totalBalanceCents: 0 },
usage24h: { count: 0, costCents: 0 },
lowBalanceUsers: [],
recentUsage: [],
recentLedger: [],
llm: null,
};
return (
<div className="admin-page">
<div className="admin-page-head">
<h2>概览</h2>
<p className="muted">平台运行摘要与快捷入口</p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{serviceMessage && <p className="banner banner-info">{serviceMessage}</p>}
{serviceError && <p className="banner banner-error">{serviceError}</p>}
<div className="admin-stat-grid">
<div className="admin-stat-card">
<div className="admin-stat-label">用户总数</div>
<div className="admin-stat-value">{loading ? '—' : safeSummary.users.total}</div>
<div className="muted">活跃 {loading ? '—' : safeSummary.users.active}</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">余额不足</div>
<div className="admin-stat-value">{loading ? '—' : safeSummary.users.lowBalance}</div>
<div className="muted">普通用户余额 ≤ ¥0</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">账户总余额</div>
<div className="admin-stat-value">
{loading ? '—' : `¥${formatYuan(safeSummary.users.totalBalanceCents)}`}
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">24h 用量</div>
<div className="admin-stat-value">{loading ? '—' : safeSummary.usage24h.count}</div>
<div className="muted">
扣费 ¥{loading ? '—' : formatYuan(safeSummary.usage24h.costCents)}
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">统一模型中心</div>
<div className="admin-stat-value">{loading ? '—' : (safeSummary.llm?.keyCount ?? '—')}</div>
<div className="muted">
{loading
? '—'
: safeSummary.llm?.selectedKeyName
? `当前 ${safeSummary.llm.selectedKeyName}`
: '未配置'}
</div>
</div>
</div>
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2>服务重启</h2>
<p className="muted">`local_restart` = 本机开发重启;`pro_restart` = 本机生产脚本重启。</p>
</div>
</div>
<div className="admin-service-grid">
<article className="admin-service-card">
<h3>本机开发重启</h3>
<p className="muted">本机构建并重启 `127.0.0.1:8085` 和 `127.0.0.1:5174`。页面会短暂断开。</p>
<button
type="button"
className="send-btn"
disabled={serviceBusy !== null}
onClick={() => void handleServiceRestart('local_restart')}
>
{serviceBusy === 'local_restart' ? '重启中...' : '执行本机重启'}
</button>
</article>
<article className="admin-service-card">
<h3>生产脚本重启</h3>
<p className="muted">本机直接执行 `remote_restart.sh`。部署在生产机时,用它重启当前生产机服务。</p>
<button
type="button"
className="ghost-btn"
disabled={serviceBusy !== null}
onClick={() => void handleServiceRestart('pro_restart')}
>
{serviceBusy === 'pro_restart' ? '重启中...' : '执行生产重启'}
</button>
</article>
</div>
</section>
{!loading && safeSummary.lowBalanceUsers.length > 0 && (
<section className="admin-card">
<div className="admin-card-head">
<h2>余额告警</h2>
<Link to="/billing/recharge" className="ghost-btn admin-inline-link">
去充值
</Link>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>用户</th>
<th>余额</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{safeSummary.lowBalanceUsers.map((user) => (
<tr key={user.id}>
<td>
<div>{user.displayName}</div>
<div className="muted">@{user.username}</div>
</td>
<td>¥{formatYuan(user.balanceCents)}</td>
<td>
<Link
to={`/users/${user.id}`}
className="ghost-btn admin-inline-link"
>
用户详情
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<section className="admin-card">
<h2>快捷入口</h2>
<div className="admin-link-grid">
{QUICK_LINKS.map((item) => (
<Link key={item.to} to={item.to} className="admin-link-card">
<span className="admin-link-card-title">{item.label}</span>
<span className="muted">{item.desc}</span>
</Link>
))}
</div>
</section>
{!loading && (
<>
<section className="admin-card">
<div className="admin-card-head">
<h2>最近用量</h2>
<Link to="/billing/usage" className="ghost-btn admin-inline-link">
查看全部
</Link>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>时间</th>
<th>用户</th>
<th>Token</th>
<th>扣费</th>
</tr>
</thead>
<tbody>
{safeSummary.recentUsage.length === 0 ? (
<tr>
<td colSpan={4} className="muted">
暂无记录
</td>
</tr>
) : (
safeSummary.recentUsage.map((row) => (
<tr key={row.id}>
<td>{formatTime(row.createdAt)}</td>
<td>@{row.username}</td>
<td>
in {row.inputTokens} / out {row.outputTokens}
</td>
<td>
{row.billingSource === 'subscription' ? '套餐额度' : `¥${formatYuan(row.costCents)}`}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
<section className="admin-card">
<div className="admin-card-head">
<h2>最近流水</h2>
<Link to="/billing/ledger" className="ghost-btn admin-inline-link">
查看全部
</Link>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>时间</th>
<th>用户</th>
<th>类型</th>
<th>金额</th>
</tr>
</thead>
<tbody>
{safeSummary.recentLedger.length === 0 ? (
<tr>
<td colSpan={4} className="muted">
暂无记录
</td>
</tr>
) : (
safeSummary.recentLedger.map((row) => (
<tr key={row.id}>
<td>{formatTime(row.createdAt)}</td>
<td>@{row.username}</td>
<td>{row.type}</td>
<td className={row.amountCents < 0 ? 'text-error' : ''}>
{row.amountCents >= 0 ? '+' : ''}¥
{formatYuan(Math.abs(row.amountCents))}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</>
)}
</div>
);
}