feat: add MCP research access and browser key management
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
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", "刷新研究数据", "更新元数据和 PnL 缓存"],
|
||||
["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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user