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; sent_total: number;
/** 参与的未归档会话数,配合默认值判断设多少合适 */ /** 参与的未归档会话数,配合默认值判断设多少合适 */
active_sessions: number; active_sessions: number;
/** online / offline / disabled —— 管理页靠它区分状态并决定显示停用/恢复按钮 */
status?: string;
} }
export async function adminListAgentStats() { export async function adminListAgentStats() {
return request<{ quotas: AgentStats[] }>('GET', '/admin/quotas'); 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 = 不限)。 */ /** 改该 Agent 的新任务默认预算(0 = 不限)。 */
export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) { export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) {
return request<{ quota: AgentStats }>( return request<{ quota: AgentStats }>(

View File

@ -1,24 +1,19 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/client'; 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() { export default function QuotaPanel() {
const [stats, setStats] = useState<api.AgentStats[]>([]); const [stats, setStats] = useState<api.AgentStats[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({}); const [drafts, setDrafts] = useState<Record<string, string>>({});
const [confirming, setConfirming] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
try { 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 ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BotIcon className="w-4 h-4 text-gray-500" /> <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> <span className="text-xs text-gray-400">{stats.length}</span>
</div> </div>
@ -67,7 +91,7 @@ export default function QuotaPanel() {
插件自动转发的最终总结与权限询问不占用预算。 插件自动转发的最终总结与权限询问不占用预算。
</p> </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 ? ( {stats.length === 0 ? (
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div> <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 draft = drafts[s.agent_name] ?? String(s.default_rounds);
const dirty = draft !== String(s.default_rounds); const dirty = draft !== String(s.default_rounds);
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim()); const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
const isDisabled = s.status === 'disabled';
const isConfirming = confirming === s.agent_name;
return ( return (
<div key={s.agent_name} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap"> <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"> <span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
{s.agent_name} {s.agent_name}
</span> </span>
{statusBadge(s)}
<div className="min-w-0 flex-1 text-[11px] text-gray-500"> <div className="min-w-0 flex-1 text-[11px] text-gray-500">
{s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`} {s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`}
<span className="text-gray-400"> <span className="text-gray-400">
@ -107,6 +136,39 @@ export default function QuotaPanel() {
> >
<CheckIcon className="w-4 h-4" /> <CheckIcon className="w-4 h-4" />
</button> </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> </div>
); );
})} })}