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

535 lines
17 KiB
TypeScript
Raw Normal View History

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>
</>
);
}