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

535 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import {
Avatar,
Banner,
Button,
Input,
Select,
Spin,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
import { api, displayValue, formatTime, patch, post } from "../api";
import type { Account, AccountUsage } from "../types";
import { ModelSettingsPanel } from "../ai/ModelSettingsPanel";
const connectionLabels: Record<string, string> = {
connected: "已连接",
disconnected: "未连接",
expired: "会话待恢复",
connecting: "连接中",
verification_required: "需要人工验证",
error: "连接异常",
};
const permissionLabels: Record<string, string> = {
BEFORE_AND_AFTER_PERFORMANCE_V2: "提交前后表现",
BRAIN_LABS: "BRAIN Labs",
BRAIN_LABS_JUPYTER_LAB: "Jupyter Lab",
CONSULTANT: "顾问权限",
MULTI_SIMULATION: "批量回测",
PROD_ALPHAS: "生产 Alpha",
REFERRAL: "推荐邀请",
SUPER_ALPHA: "Super Alpha",
VISUALIZATION: "数据可视化",
WORKDAY: "Workday",
};
const count = (value: unknown) =>
typeof value === "number" ? value.toLocaleString("zh-CN") : "未提供";
const duration = (seconds: number | null | undefined) => {
if (seconds == null) return "未提供";
if (seconds <= 0) return "已到期";
const minutes = Math.ceil(seconds / 60);
return minutes >= 60
? `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟`
: `${minutes} 分钟`;
};
export function AccountPage({
account,
onChange,
onTask,
}: {
account: Account | null;
onChange: () => void;
onTask: () => void;
}) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
const [preferences, setPreferences] = useState({
display_name: "研究员",
theme: "light",
timezone: "Asia/Shanghai",
page_size: 25,
});
useEffect(() => {
if (account) setEmail(account.email ?? "");
}, [account?.email]);
useEffect(() => {
if (account)
setPreferences({
display_name: account.display_name,
theme: account.theme,
timezone: account.timezone,
page_size: account.page_size,
});
}, [
account?.display_name,
account?.theme,
account?.timezone,
account?.page_size,
]);
if (!account)
return (
<div className="screen-center">
<Spin />
</div>
);
async function action(key: string, fn: () => Promise<unknown>, task = false) {
setBusy(key);
try {
await fn();
if (task) onTask();
else {
onChange();
Toast.success(
key === "profile"
? "正在刷新资料"
: key === "disconnect"
? "已断开连接"
: "已保存",
);
}
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy("");
}
}
const profile = account.profile;
const name = displayValue(
profile.fullName ??
profile.name ??
profile.username ??
[profile.firstName, profile.lastName].filter(Boolean).join(" "),
);
const permissions = Array.isArray(profile.permissions)
? profile.permissions.filter((p): p is string => typeof p === "string")
: null;
const usage = profile.usage as AccountUsage | undefined;
const connected =
account.connection_status === "connected" && account.session.authenticated;
const connecting = account.connection_status === "connecting";
const blocked = Boolean(busy) || connecting;
const profileRows: [string, unknown][] = [
["用户 ID", account.wq_user_id],
["平台邮箱", profile.email ?? account.email],
["账户等级", profile.level ?? profile.role ?? profile.type],
["Genius 等级", profile.geniusLevel],
[
"邮箱验证",
typeof profile.verified === "boolean"
? profile.verified
? "已验证"
: "未验证"
: null,
],
[
"账户审批",
typeof profile.approved === "boolean"
? profile.approved
? "已通过"
: "未通过"
: null,
],
[
"注册时间",
typeof profile.dateCreated === "string"
? formatTime(profile.dateCreated, account.timezone)
: null,
],
[
"顾问身份",
permissions
? permissions.includes("CONSULTANT")
? "顾问"
: "无顾问权限"
: null,
],
];
return (
<>
<div className="page-heading">
<h1>个人信息</h1>
<div className="inline-actions">
<Button
type="tertiary"
disabled={!account.configured || blocked}
loading={busy === "profile"}
aria-label="刷新个人资料"
onClick={() =>
void action("profile", () => post("/account/refresh"))
}
>
刷新资料
</Button>
<Button
type="tertiary"
aria-expanded={settingsOpen || !account.configured}
onClick={() => setSettingsOpen(!settingsOpen)}
>
连接设置
</Button>
</div>
</div>
{account.connection_error && (
<Banner
type={
account.connection_status === "verification_required"
? "warning"
: "danger"
}
description={account.connection_error}
/>
)}
{account.verification_url && (
<div className="verification-callout">
<span>在 WorldQuant 完成人工验证后,返回继续。</span>
<div className="inline-actions">
<a
className="text-link"
href={account.verification_url}
target="_blank"
rel="noreferrer"
>
打开验证页面
</a>
<Button
theme="solid"
disabled={blocked}
loading={busy === "verify"}
onClick={() =>
void action("verify", () => post("/account/verify"), true)
}
>
已完成,继续验证
</Button>
</div>
</div>
)}
<section className="account-identity" aria-label="平台账户">
<Avatar size="default" color="grey">
{name === "未提供"
? account.display_name.slice(0, 1)
: name.slice(0, 1)}
</Avatar>
<div className="identity-name">
<h2>{name === "未提供" ? account.display_name : name}</h2>
<span>{account.wq_user_id ?? "尚未连接 WorldQuant"}</span>
</div>
<Tag color={connected ? "green" : "grey"}>
{account.connection_status === "connected" &&
!account.session.authenticated
? "会话待恢复"
: (connectionLabels[account.connection_status] ??
account.connection_status)}
</Tag>
<div className="session-summary">
<span>
会话剩余{" "}
<strong>{duration(account.session.remaining_seconds)}</strong>
</span>
<span
title={
account.session.expires_at
? formatTime(account.session.expires_at, account.timezone)
: undefined
}
>
有效期 {duration(account.session.total_seconds)}
</span>
</div>
</section>
{(settingsOpen || !account.configured) && (
<section
className="connection-settings"
aria-label="WorldQuant 连接设置"
>
<h2>WorldQuant 连接</h2>
<form
onSubmit={(e) => {
e.preventDefault();
void action("save", async () => {
await api("/account/credentials", {
method: "PUT",
body: JSON.stringify({ email, password }),
});
setPassword("");
setSettingsOpen(true);
});
}}
>
<div className="credentials-grid">
<label>
WorldQuant 邮箱
<Input
aria-label="WorldQuant 邮箱"
value={email}
onChange={setEmail}
autoComplete="off"
/>
</label>
<label>
WorldQuant 密码
<Input
aria-label="WorldQuant 密码"
mode="password"
value={password}
onChange={setPassword}
placeholder={
account.configured
? "已保存,输入新密码可更新"
: "输入平台密码"
}
autoComplete="new-password"
/>
</label>
</div>
<div className="inline-actions">
<Button
type="tertiary"
htmlType="submit"
disabled={!email || !password || blocked}
loading={busy === "save"}
>
保存凭据
</Button>
<Button
theme="solid"
disabled={!account.configured || blocked}
loading={busy === "connect" || connecting}
onClick={() =>
void action("connect", () => post("/account/connect"), true)
}
>
{connected ? "重新连接" : "连接 WorldQuant"}
</Button>
<Button
type="tertiary"
theme="borderless"
disabled={
blocked || account.connection_status === "disconnected"
}
onClick={() =>
void action("disconnect", () => post("/account/disconnect"))
}
>
断开连接
</Button>
<span className="muted">凭据仅在服务端加密保存</span>
</div>
</form>
</section>
)}
<div className="account-grid">
<section className="account-section">
<h2>基本资料</h2>
<dl className="profile-grid">
{profileRows.map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{displayValue(value)}</dd>
</div>
))}
</dl>
</section>
<section className="account-section">
<div className="section-heading">
<h2>账户权限</h2>
<span className="muted">
{permissions ? `${permissions.length} 项` : "未提供"}
</span>
</div>
{permissions?.length ? (
<ul className="permission-list">
{permissions.map((permission) => (
<li key={permission} title={permission}>
<span>{permissionLabels[permission] ?? permission}</span>
<span className="permission-state">已开通</span>
</li>
))}
</ul>
) : (
<div className="inline-empty">
{permissions ? "平台未授予权限" : "连接后读取平台权限"}
</div>
)}
</section>
<section className="account-section full-width">
<div className="section-heading">
<h2>提交与用量</h2>
<span className="muted">
{usage ? `美东日期 ${usage.date}` : "尚未同步"}
</span>
</div>
<div className="account-counts">
{[
["待提交 Alpha", usage?.alphas.unsubmitted],
["已启用 Alpha", usage?.alphas.active],
["已停用 Alpha", usage?.alphas.decommissioned],
["累计提交", usage?.submissions?.total],
].map(([label, value]) => (
<div key={String(label)}>
<span>{label}</span>
<strong>{count(value)}</strong>
</div>
))}
</div>
<div className="usage-table-wrap">
<table className="usage-table">
<thead>
<tr>
<th>用量类型</th>
<th>今日</th>
<th>昨日</th>
<th>每日上限</th>
<th>剩余额度</th>
</tr>
</thead>
<tbody>
{(
[
["submissions", "Alpha 提交"],
["simulations", "回测"],
] as const
).map(([key, label]) => (
<tr key={key}>
<th scope="row">{label}</th>
<td>
{usage?.[key]?.today == null
? "尚无当日记录"
: count(usage[key]?.today)}
</td>
<td title={usage?.[key]?.yesterday_date ?? undefined}>
{count(usage?.[key]?.yesterday)}
</td>
<td>
{usage?.[key]?.limit == null
? "平台未返回"
: count(usage[key]?.limit)}
{key === "simulations" &&
usage?.simulations?.limit != null &&
"(本地设定)"}
</td>
<td>{count(usage?.[key]?.remaining)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="usage-note">
回测按本地每日额度计算剩余次数;活动统计可能延迟,当日次数未知时不估算余额。
</p>
{usage &&
Object.entries(usage.errors).map(([key, message]) => (
<p role="status" className="error-text" key={key}>
{
(
{
simulations: "回测用量",
submissions: "提交用量",
alphas: "Alpha 概览",
} as Record<string, string>
)[key]
}
:{message}
</p>
))}
</section>
<ModelSettingsPanel />
<section className="account-section full-width">
<div className="section-heading">
<h2>工作空间偏好</h2>
<span className="muted">
更新于 {formatTime(account.last_synced_at, account.timezone)}
</span>
</div>
<form
className="preferences-form"
onSubmit={(e) => {
e.preventDefault();
void action("preferences", () =>
patch("/account/preferences", preferences),
);
}}
>
<div className="preferences-grid">
<label>
显示名称
<Input
aria-label="显示名称"
value={preferences.display_name}
onChange={(display_name) =>
setPreferences({ ...preferences, display_name })
}
/>
</label>
<label>
主题
<Select
aria-label="主题"
value={preferences.theme}
optionList={[
{ value: "light", label: "浅色" },
{ value: "dark", label: "深色" },
]}
onChange={(theme) =>
setPreferences({ ...preferences, theme: String(theme) })
}
/>
</label>
<label>
时区
<Input
aria-label="时区"
value={preferences.timezone}
onChange={(timezone) =>
setPreferences({ ...preferences, timezone })
}
/>
</label>
<label>
每页数量
<Select
aria-label="每页数量"
value={preferences.page_size}
optionList={[25, 50, 100].map((value) => ({
value,
label: `${value} 条`,
}))}
onChange={(page_size) =>
setPreferences({
...preferences,
page_size: Number(page_size),
})
}
/>
</label>
</div>
<Button
htmlType="submit"
theme="solid"
loading={busy === "preferences"}
disabled={Boolean(busy)}
>
保存偏好
</Button>
</form>
</section>
</div>
</>
);
}