535 lines
17 KiB
TypeScript
535 lines
17 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|