331 lines
9.8 KiB
TypeScript
331 lines
9.8 KiB
TypeScript
|
|
import { useEffect, useState } from "react";
|
||
|
|
import {
|
||
|
|
Banner,
|
||
|
|
Button,
|
||
|
|
Input,
|
||
|
|
Select,
|
||
|
|
Spin,
|
||
|
|
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";
|
||
|
|
|
||
|
|
const connectionLabels: Record<string, string> = {
|
||
|
|
connected: "已连接",
|
||
|
|
disconnected: "未连接",
|
||
|
|
expired: "会话待恢复",
|
||
|
|
connecting: "连接中",
|
||
|
|
verification_required: "需要人工验证",
|
||
|
|
error: "连接异常",
|
||
|
|
};
|
||
|
|
|
||
|
|
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 [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 <Spin size="large" />;
|
||
|
|
async function action(key: string, fn: () => Promise<unknown>, task = false) {
|
||
|
|
setBusy(key);
|
||
|
|
try {
|
||
|
|
await fn();
|
||
|
|
if (task) onTask();
|
||
|
|
else {
|
||
|
|
onChange();
|
||
|
|
Toast.success("已保存");
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
Toast.error((e as Error).message);
|
||
|
|
} finally {
|
||
|
|
setBusy("");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const profile = account.profile;
|
||
|
|
const profileRows = [
|
||
|
|
["用户 ID", account.wq_user_id],
|
||
|
|
["昵称", profile.name ?? profile.username ?? profile.firstName],
|
||
|
|
["平台邮箱", profile.email ?? account.email],
|
||
|
|
["身份", profile.role ?? profile.roles ?? profile.type],
|
||
|
|
["权限", profile.permissions],
|
||
|
|
[
|
||
|
|
"资料同步时间",
|
||
|
|
account.last_synced_at
|
||
|
|
? formatTime(account.last_synced_at, account.timezone)
|
||
|
|
: null,
|
||
|
|
],
|
||
|
|
];
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<div className="page-heading">
|
||
|
|
<div>
|
||
|
|
<div className="eyebrow">PERSONAL WORKSPACE</div>
|
||
|
|
<h1>个人信息</h1>
|
||
|
|
<p>管理账户连接和工作空间偏好。</p>
|
||
|
|
</div>
|
||
|
|
<Tag
|
||
|
|
color={account.connection_status === "connected" ? "green" : "grey"}
|
||
|
|
size="large"
|
||
|
|
>
|
||
|
|
{connectionLabels[account.connection_status] ??
|
||
|
|
account.connection_status}
|
||
|
|
</Tag>
|
||
|
|
</div>
|
||
|
|
{account.connection_error && (
|
||
|
|
<Banner
|
||
|
|
type={
|
||
|
|
account.connection_status === "verification_required"
|
||
|
|
? "warning"
|
||
|
|
: "danger"
|
||
|
|
}
|
||
|
|
description={account.connection_error}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{account.verification_url && (
|
||
|
|
<div className="verification-callout">
|
||
|
|
<strong>请完成 WorldQuant 人工验证</strong>
|
||
|
|
<p>在新窗口完成平台验证后,回到这里继续。验证链接只属于当前会话。</p>
|
||
|
|
<div className="inline-actions">
|
||
|
|
<a href={account.verification_url} target="_blank" rel="noreferrer">
|
||
|
|
<Button theme="solid">打开验证页面</Button>
|
||
|
|
</a>
|
||
|
|
<Button
|
||
|
|
loading={busy === "verify"}
|
||
|
|
onClick={() =>
|
||
|
|
void action("verify", () => post("/account/verify"), true)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
已完成,继续验证
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div className="account-grid">
|
||
|
|
<section className="panel">
|
||
|
|
<div className="panel-title">
|
||
|
|
<IconLink />
|
||
|
|
<div>
|
||
|
|
<h2>WorldQuant 连接</h2>
|
||
|
|
<p>连接你的个人 BRAIN 账户</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<form
|
||
|
|
onSubmit={(e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
void action("save", async () => {
|
||
|
|
await api("/account/credentials", {
|
||
|
|
method: "PUT",
|
||
|
|
body: JSON.stringify({ email, password }),
|
||
|
|
});
|
||
|
|
setPassword("");
|
||
|
|
});
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<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>
|
||
|
|
<div className="inline-actions">
|
||
|
|
<Button
|
||
|
|
htmlType="submit"
|
||
|
|
disabled={!email || !password || Boolean(busy)}
|
||
|
|
loading={busy === "save"}
|
||
|
|
>
|
||
|
|
保存凭据
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
theme="solid"
|
||
|
|
disabled={!account.configured || Boolean(busy)}
|
||
|
|
loading={busy === "connect"}
|
||
|
|
onClick={() =>
|
||
|
|
void action("connect", () => post("/account/connect"), true)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{account.connection_status === "connected"
|
||
|
|
? "重新连接"
|
||
|
|
: "连接 WorldQuant"}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
theme="borderless"
|
||
|
|
disabled={
|
||
|
|
Boolean(busy) || account.connection_status === "disconnected"
|
||
|
|
}
|
||
|
|
onClick={() =>
|
||
|
|
void action("disconnect", () => post("/account/disconnect"))
|
||
|
|
}
|
||
|
|
>
|
||
|
|
断开连接
|
||
|
|
</Button>
|
||
|
|
</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>
|
||
|
|
<dl className="profile-grid">
|
||
|
|
{profileRows.map(([key, value]) => (
|
||
|
|
<div key={String(key)}>
|
||
|
|
<dt>{String(key)}</dt>
|
||
|
|
<dd>{displayValue(value)}</dd>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</dl>
|
||
|
|
</section>
|
||
|
|
<section className="panel full-width">
|
||
|
|
<div className="panel-title">
|
||
|
|
<div>
|
||
|
|
<h2>工作空间偏好</h2>
|
||
|
|
<p>只影响你的本地研究界面</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<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>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|