feat: 管理页 Agent 停用/恢复按钮

QuotaPanel 改名为「Agent 管理」,每行新增:
- 状态徽章(在线/离线/已停用)
- 停用/恢复按钮(点一下出确认,确认后执行)

adminSetAgentStatus API 调用 PUT /admin/agents/{name}/status
AgentStats.status 字段加入前端类型定义

同步部署:前端产物已构建并覆盖 gateway/internal/static/static/

go test 全绿,tsc --noEmit 通过
This commit is contained in:
2026-09-03 12:26:34 +08:00
parent c900b4e1da
commit e892980373
2 changed files with 90 additions and 12 deletions

View File

@ -397,12 +397,28 @@ export interface AgentStats {
sent_total: number;
/** 参与的未归档会话数,配合默认值判断设多少合适 */
active_sessions: number;
/** online / offline / disabled —— 管理页靠它区分状态并决定显示停用/恢复按钮 */
status?: string;
}
export async function adminListAgentStats() {
return request<{ quotas: AgentStats[] }>('GET', '/admin/quotas');
}
/** 停用true或恢复false一个 Agent。返回撤销的密钥数等信息。 */
export async function adminSetAgentStatus(agentName: string, disabled: boolean) {
return request<{
agent_name: string;
disabled: boolean;
keys_revoked?: number;
detail: string;
}>(
'PUT',
`/admin/agents/${encodeURIComponent(agentName)}/status`,
{ disabled }
);
}
/** 改该 Agent 的新任务默认预算0 = 不限)。 */
export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) {
return request<{ quota: AgentStats }>(

View File

@ -1,24 +1,19 @@
import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/client';
import { BotIcon, CheckIcon } from './icons';
import { BotIcon, CheckIcon, CloseIcon } from './icons';
/**
* Agent 新任务默认预算(管理员)
* Agent 管理面板(管理员):默认预算 + 停用/恢复
*
* **这里配的是默认值,不是额度。**
*
* 额度(往返预算)属于具体任务 —— 在写信时给、在对话页里随时改。
* 这个面板只决定「派给某个 Agent 的新任务,如果没人显式指定,默认给几个来回」:
* 跑测试的小工具与重构整个模块的 Agent合理来回数差一个量级所以分开设。
*
* 累计发信数只是观测数据,不拦任何请求 —— 之前它是「终身额度」,
* 但终身额度跑满要人工重置才能再干活,而 Agent 是长期在线的。
* 预算决定「派给某个 Agent 的新任务默认多少个来回」。
* 停用/恢复控制 Agent 的准入:停用后密钥撤销、注册被拒,邮件与会话保留。
*/
export default function QuotaPanel() {
const [stats, setStats] = useState<api.AgentStats[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [confirming, setConfirming] = useState<string | null>(null);
const load = useCallback(async () => {
try {
@ -52,11 +47,40 @@ export default function QuotaPanel() {
}
};
const toggleStatus = async (name: string, currentDisabled: boolean) => {
setBusy(name);
setError(null);
try {
const result = await api.adminSetAgentStatus(name, !currentDisabled);
await load();
setConfirming(null);
// 停用时显示撤销了几把密钥,这是操作结果里最有信息量的部分
if (result.detail) setError(null); // 清掉旧错误,让 detail 独占提示区
setBusy(null);
return;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
};
const statusBadge = (s: api.AgentStats) => {
const st = s.status ?? 'offline';
const cls = st === 'online'
? 'bg-green-50 text-green-700'
: st === 'disabled'
? 'bg-red-50 text-red-600'
: 'bg-gray-50 text-gray-500';
const label = st === 'online' ? '在线' : st === 'disabled' ? '已停用' : '离线';
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${cls}`}>{label}</span>;
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<BotIcon className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">Agent </h3>
<h3 className="text-sm font-semibold text-gray-900">Agent </h3>
<span className="text-xs text-gray-400">{stats.length}</span>
</div>
@ -67,7 +91,7 @@ export default function QuotaPanel() {
</p>
{error && <div className="text-xs text-red-600">{error}</div>}
{error && <div className="text-[11px] text-gray-600 bg-gray-50 rounded px-2 py-1.5">{error}</div>}
{stats.length === 0 ? (
<div className="text-xs text-gray-400 py-3"> Agent</div>
@ -77,12 +101,17 @@ export default function QuotaPanel() {
const draft = drafts[s.agent_name] ?? String(s.default_rounds);
const dirty = draft !== String(s.default_rounds);
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
const isDisabled = s.status === 'disabled';
const isConfirming = confirming === s.agent_name;
return (
<div key={s.agent_name} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
{s.agent_name}
</span>
{statusBadge(s)}
<div className="min-w-0 flex-1 text-[11px] text-gray-500">
{s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`}
<span className="text-gray-400">
@ -107,6 +136,39 @@ export default function QuotaPanel() {
>
<CheckIcon className="w-4 h-4" />
</button>
{/* 停用/恢复按钮 */}
{isConfirming ? (
<div className="flex items-center gap-1">
<span className="text-[10px] text-gray-500"></span>
<button
onClick={() => toggleStatus(s.agent_name, isDisabled)}
disabled={busy === s.agent_name}
className="tap text-[10px] px-1.5 py-0.5 rounded bg-red-50 text-red-600 hover:bg-red-100"
>
{isDisabled ? '恢复' : '停用'}
</button>
<button
onClick={() => setConfirming(null)}
className="tap text-[10px] text-gray-400 hover:text-gray-600"
>
</button>
</div>
) : (
<button
onClick={() => setConfirming(s.agent_name)}
disabled={busy === s.agent_name}
title={isDisabled ? '恢复此 Agent密钥需重新签发' : '停用此 Agent撤销全部密钥可恢复'}
className={`tap shrink-0 ${
isDisabled
? 'text-gray-400 hover:text-green-600'
: 'text-gray-400 hover:text-red-500'
} disabled:opacity-30`}
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})}