Files
memind_adm/src/admin/pages/DashboardPage.tsx
T
john 731d93843d feat(admin): add Memory V2 personal controls and Skill Runtime page
Wire admin UI and API routes to restored Memind personal memory and skill
runtime modules, including candidate review and runtime status display.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:00:20 +08:00

296 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>¥{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>
);
}