Files
worldquant-alpha-system/frontend/src/pages/MCPKeysPage.tsx
T

334 lines
10 KiB
TypeScript
Raw Normal View History

import { useEffect, useState } from "react";
import { Banner, Button, Input, Toast } from "@douyinfe/semi-ui-19";
import { api, formatTime, post } from "../api";
import "./mcp-keys.css";
type Token = {
id: string;
name: string;
scopes: string[];
created_at: string;
expires_at: string;
revoked_at: string | null;
status: "active" | "expired" | "revoked" | "invalid_binding";
};
type TokenPage = {
items: Token[];
total: number;
has_more: boolean;
enabled: boolean;
endpoint: string;
can_create: boolean;
};
const scopes = [
["research:read", "读取研究数据", "查询目录、历史、运行与结果"],
["research:refresh", "刷新研究数据", "更新缓存、检查自相关及恢复 WorldQuant 认证"],
2026-09-11 23:05:01 +08:00
["research:write", "保存研究模板", "保存大模型总结的模板及来源,供后续批量回测"],
["backtests:execute", "执行回测", "提交新的回测批次"],
["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"],
] as const;
const labels = {
active: "有效",
expired: "已过期",
revoked: "已撤销",
invalid_binding: "账户绑定失效",
};
export function MCPKeysPage() {
const [data, setData] = useState<TokenPage | null>(null);
const [offset, setOffset] = useState(0);
const [version, setVersion] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [name, setName] = useState("");
const [days, setDays] = useState("90");
const [permissions, setPermissions] = useState<string[]>(["research:read"]);
const [busy, setBusy] = useState(false);
const [created, setCreated] = useState<(Token & { token: string }) | null>(
null,
);
const [revoking, setRevoking] = useState<string | null>(null);
useEffect(() => {
let active = true;
setLoading(true);
api<TokenPage>(`/mcp-tokens?offset=${offset}&limit=25`)
.then((next) => {
if (active) {
setData(next);
setError("");
}
})
.catch((e: Error) => {
if (active) setError(e.message);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [offset, version]);
async function create(event: React.FormEvent) {
event.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
const result = await post<Token & { token: string }>("/mcp-tokens", {
name: name.trim(),
days: Number(days),
scopes: permissions,
});
setCreated(result);
setName("");
setOffset(0);
setVersion((v) => v + 1);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function revoke(id: string) {
setBusy(true);
try {
await post(`/mcp-tokens/${id}/revoke`);
if (created?.id === id) setCreated(null);
setRevoking(null);
setVersion((v) => v + 1);
Toast.success("MCP Key 已撤销");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function copy(value: string) {
try {
await navigator.clipboard.writeText(value);
Toast.success("已复制");
} catch {
Toast.error("复制失败,请手动选择并复制");
}
}
return (
<section className="mcp-keys-page">
<header>
<div>
<h1>MCP Key</h1>
<p>管理 Codex 等客户端访问研究数据和执行回测的权限。</p>
</div>
<Button
disabled={loading || busy}
onClick={() => setVersion((v) => v + 1)}
>
刷新列表
</Button>
</header>
{error && <Banner type="danger" description={error} />}
{data && !data.enabled && (
<Banner
type="warning"
description="MCP 服务未启用。可以管理 Key,启用服务后客户端才能连接。"
/>
)}
{data && !data.can_create && (
<Banner
type="warning"
description="请先在个人信息页连接并确认 WorldQuant 账户身份,再创建 Key。"
/>
)}
{data && (
<section className="mcp-key-card">
<h2>连接地址</h2>
<div className="mcp-key-address">
<code>{data.endpoint}</code>
<Button onClick={() => void copy(data.endpoint)}>复制地址</Button>
</div>
<p>
客户端使用 Bearer Token 认证。Key 仅用于 MCP,不提供网页管理或正式
Alpha 提交权限。
</p>
</section>
)}
{created ? (
<section
className="mcp-key-card mcp-key-secret"
aria-label="新创建的 MCP Key"
>
<h2>Key 已创建</h2>
<p>
明文仅在这里显示一次。请保存到客户端凭据配置中;关闭、刷新或离开此页后无法再次查看。
</p>
<Input aria-label="新 Key 明文" value={created.token} readOnly />
<div className="mcp-key-actions">
<Button onClick={() => void copy(created.token)}>复制 Key</Button>
<Button onClick={() => setCreated(null)}>已保存,关闭明文</Button>
</div>
</section>
) : (
<form className="mcp-key-card" onSubmit={(event) => void create(event)}>
<h2>创建 MCP Key</h2>
<div className="mcp-key-fields">
<label>
名称
<Input
aria-label="Key 名称"
value={name}
maxLength={100}
onChange={setName}
placeholder="例如 Codex 研究助手"
/>
</label>
<label>
有效期(天)
<input
aria-label="有效期(天)"
type="number"
min="1"
max="365"
step="1"
required
value={days}
onChange={(e) => setDays(e.target.value)}
/>
</label>
</div>
<fieldset>
<legend>访问权限</legend>
<div className="mcp-key-permissions">
{scopes.map(([value, label, description]) => (
<label key={value}>
<input
type="checkbox"
checked={permissions.includes(value)}
disabled={value === "research:read" || busy}
onChange={(e) =>
setPermissions((old) =>
e.target.checked
? [...old, value]
: old.filter((item) => item !== value),
)
}
/>
<span>
<strong>{label}</strong>
<small>
{description}
{value === "research:read" ? "(必选)" : ""}
</small>
</span>
</label>
))}
</div>
</fieldset>
<Button
htmlType="submit"
theme="solid"
loading={busy}
disabled={
!data?.can_create ||
!name.trim() ||
!Number.isInteger(Number(days)) ||
Number(days) < 1 ||
Number(days) > 365
}
>
创建 Key
</Button>
</form>
)}
<section
className="mcp-key-card"
aria-label="Key 列表"
aria-busy={loading}
>
<h2>
已创建的 Key <span className="mcp-key-count">{data?.total ?? 0}</span>
</h2>
<div className="mcp-key-table">
<table>
<thead>
<tr>
<th>名称</th>
<th>权限</th>
<th>状态</th>
<th>创建时间</th>
<th>到期时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{data?.items.map((item) => (
<tr key={item.id}>
<td>
<strong>{item.name}</strong>
<small>{item.id}</small>
</td>
<td>
{item.scopes.map((scope) => (
<small key={scope}>
{scopes.find(([value]) => value === scope)?.[1] ??
scope}
</small>
))}
</td>
<td>{labels[item.status]}</td>
<td>{formatTime(item.created_at)}</td>
<td>{formatTime(item.expires_at)}</td>
<td>
{item.revoked_at ? (
"—"
) : revoking === item.id ? (
<div>
<p>撤销后新请求立即失效,已受理回测继续执行。</p>
<Button
type="danger"
disabled={busy}
onClick={() => void revoke(item.id)}
>
确认撤销
</Button>
<Button
disabled={busy}
onClick={() => setRevoking(null)}
>
取消
</Button>
</div>
) : (
<Button
type="danger"
disabled={busy}
onClick={() => setRevoking(item.id)}
>
撤销
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{!loading && !data?.items.length && <p>暂无 MCP Key。</p>}
<div className="mcp-key-actions">
<Button
disabled={offset === 0 || loading || busy}
onClick={() => setOffset((v) => Math.max(0, v - 25))}
>
上一页
</Button>
<span>第 {Math.floor(offset / 25) + 1} 页</span>
<Button
disabled={!data?.has_more || loading || busy}
onClick={() => setOffset((v) => v + 25)}
>
下一页
</Button>
</div>
</section>
</section>
);
}