2026-09-09 16:18:27 +08:00
|
|
|
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", "读取研究数据", "查询目录、历史、运行与结果"],
|
2026-09-10 10:26:44 +08:00
|
|
|
["research:refresh", "刷新研究数据", "更新缓存、检查自相关及恢复 WorldQuant 认证"],
|
2026-09-09 16:18:27 +08:00
|
|
|
["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>
|
|
|
|
|
);
|
|
|
|
|
}
|