fix: restore polished account experience and align AI with Lark design
This commit is contained in:
+302
-100
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Banner,
|
||||
Button,
|
||||
Input,
|
||||
@@ -8,14 +9,8 @@ import {
|
||||
Tag,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
IconRefresh,
|
||||
IconLink,
|
||||
IconShield,
|
||||
IconUser,
|
||||
} from "@douyinfe/semi-icons";
|
||||
import { api, displayValue, formatTime, patch, post } from "../api";
|
||||
import type { Account } from "../types";
|
||||
import type { Account, AccountUsage } from "../types";
|
||||
import { ModelSettingsPanel } from "../ai/ModelSettingsPanel";
|
||||
|
||||
const connectionLabels: Record<string, string> = {
|
||||
@@ -26,6 +21,28 @@ const connectionLabels: Record<string, string> = {
|
||||
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,
|
||||
@@ -39,6 +56,7 @@ export function AccountPage({
|
||||
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",
|
||||
@@ -62,7 +80,13 @@ export function AccountPage({
|
||||
account?.timezone,
|
||||
account?.page_size,
|
||||
]);
|
||||
if (!account) return <Spin size="large" />;
|
||||
if (!account)
|
||||
return (
|
||||
<div className="screen-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
|
||||
async function action(key: string, fn: () => Promise<unknown>, task = false) {
|
||||
setBusy(key);
|
||||
try {
|
||||
@@ -70,7 +94,13 @@ export function AccountPage({
|
||||
if (task) onTask();
|
||||
else {
|
||||
onChange();
|
||||
Toast.success("已保存");
|
||||
Toast.success(
|
||||
key === "profile"
|
||||
? "正在刷新资料"
|
||||
: key === "disconnect"
|
||||
? "已断开连接"
|
||||
: "已保存",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
@@ -79,34 +109,80 @@ export function AccountPage({
|
||||
}
|
||||
}
|
||||
const profile = account.profile;
|
||||
const profileRows = [
|
||||
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.name ?? profile.username ?? profile.firstName],
|
||||
["平台邮箱", profile.email ?? account.email],
|
||||
["身份", profile.role ?? profile.roles ?? profile.type],
|
||||
["权限", profile.permissions],
|
||||
["账户等级", profile.level ?? profile.role ?? profile.type],
|
||||
["Genius 等级", profile.geniusLevel],
|
||||
[
|
||||
"资料同步时间",
|
||||
account.last_synced_at
|
||||
? formatTime(account.last_synced_at, account.timezone)
|
||||
"邮箱验证",
|
||||
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">
|
||||
<div>
|
||||
<div className="eyebrow">PERSONAL WORKSPACE</div>
|
||||
<h1>个人信息</h1>
|
||||
<p>管理账户连接和工作空间偏好。</p>
|
||||
<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>
|
||||
<Tag
|
||||
color={account.connection_status === "connected" ? "green" : "grey"}
|
||||
size="large"
|
||||
>
|
||||
{connectionLabels[account.connection_status] ??
|
||||
account.connection_status}
|
||||
</Tag>
|
||||
</div>
|
||||
{account.connection_error && (
|
||||
<Banner
|
||||
@@ -120,13 +196,19 @@ export function AccountPage({
|
||||
)}
|
||||
{account.verification_url && (
|
||||
<div className="verification-callout">
|
||||
<strong>请完成 WorldQuant 人工验证</strong>
|
||||
<p>在新窗口完成平台验证后,回到这里继续。验证链接只属于当前会话。</p>
|
||||
<span>在 WorldQuant 完成人工验证后,返回继续。</span>
|
||||
<div className="inline-actions">
|
||||
<a href={account.verification_url} target="_blank" rel="noreferrer">
|
||||
<Button theme="solid">打开验证页面</Button>
|
||||
<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)
|
||||
@@ -137,16 +219,45 @@ export function AccountPage({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="account-grid">
|
||||
<ModelSettingsPanel />
|
||||
<section className="panel">
|
||||
<div className="panel-title">
|
||||
<IconLink />
|
||||
<div>
|
||||
<h2>WorldQuant 连接</h2>
|
||||
<p>连接你的个人 BRAIN 账户</p>
|
||||
</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();
|
||||
@@ -156,62 +267,60 @@ export function AccountPage({
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
setPassword("");
|
||||
setSettingsOpen(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
WorldQuant 邮箱
|
||||
<Input
|
||||
aria-label="WorldQuant 邮箱"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="name@example.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
WorldQuant 密码
|
||||
<Input
|
||||
aria-label="WorldQuant 密码"
|
||||
mode="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder={
|
||||
account.configured
|
||||
? "已保存 · 输入新密码可更新配置"
|
||||
: "输入你的平台密码"
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<div className="credential-note">
|
||||
<IconShield />
|
||||
密码加密存储,仅用于服务端连接平台。
|
||||
<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 || Boolean(busy)}
|
||||
disabled={!email || !password || blocked}
|
||||
loading={busy === "save"}
|
||||
>
|
||||
保存凭据
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={!account.configured || Boolean(busy)}
|
||||
loading={busy === "connect"}
|
||||
disabled={!account.configured || blocked}
|
||||
loading={busy === "connect" || connecting}
|
||||
onClick={() =>
|
||||
void action("connect", () => post("/account/connect"), true)
|
||||
}
|
||||
>
|
||||
{account.connection_status === "connected"
|
||||
? "重新连接"
|
||||
: "连接 WorldQuant"}
|
||||
{connected ? "重新连接" : "连接 WorldQuant"}
|
||||
</Button>
|
||||
<Button
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
disabled={
|
||||
Boolean(busy) || account.connection_status === "disconnected"
|
||||
blocked || account.connection_status === "disconnected"
|
||||
}
|
||||
onClick={() =>
|
||||
void action("disconnect", () => post("/account/disconnect"))
|
||||
@@ -219,43 +328,136 @@ export function AccountPage({
|
||||
>
|
||||
断开连接
|
||||
</Button>
|
||||
<span className="muted">凭据仅在服务端加密保存</span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<div className="panel-title">
|
||||
<IconUser />
|
||||
<div>
|
||||
<h2>平台个人资料</h2>
|
||||
<p>展示最近一次成功获取的信息</p>
|
||||
</div>
|
||||
<Button
|
||||
theme="borderless"
|
||||
icon={<IconRefresh />}
|
||||
aria-label="刷新个人资料"
|
||||
disabled={!account.configured || Boolean(busy)}
|
||||
onClick={() =>
|
||||
void action("profile", () => post("/account/refresh"), true)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="account-grid">
|
||||
<section className="account-section">
|
||||
<h2>基本资料</h2>
|
||||
<dl className="profile-grid">
|
||||
{profileRows.map(([key, value]) => (
|
||||
<div key={String(key)}>
|
||||
<dt>{String(key)}</dt>
|
||||
<div key={key}>
|
||||
<dt>{key}</dt>
|
||||
<dd>{displayValue(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
<section className="panel full-width">
|
||||
<div className="panel-title">
|
||||
<div>
|
||||
<h2>工作空间偏好</h2>
|
||||
<p>只影响你的本地研究界面</p>
|
||||
<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", () =>
|
||||
|
||||
Reference in New Issue
Block a user