329 lines
9.8 KiB
TypeScript
329 lines
9.8 KiB
TypeScript
|
|
import { useCallback, useEffect, useState } from "react";
|
|||
|
|
import {
|
|||
|
|
Avatar,
|
|||
|
|
Badge,
|
|||
|
|
Banner,
|
|||
|
|
Button,
|
|||
|
|
Input,
|
|||
|
|
LocaleProvider,
|
|||
|
|
Spin,
|
|||
|
|
Toast,
|
|||
|
|
} from "@douyinfe/semi-ui-19";
|
|||
|
|
import {
|
|||
|
|
IconGridView,
|
|||
|
|
IconUser,
|
|||
|
|
IconBell,
|
|||
|
|
IconExit,
|
|||
|
|
IconArrowRight,
|
|||
|
|
IconPulse,
|
|||
|
|
} from "@douyinfe/semi-icons";
|
|||
|
|
import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN";
|
|||
|
|
import { api, post } from "./api";
|
|||
|
|
import type { Account, Job } from "./types";
|
|||
|
|
import { AccountPage } from "./pages/AccountPage";
|
|||
|
|
import { AlphaPage } from "./pages/AlphaPage";
|
|||
|
|
import { JobPanel } from "./components/JobPanel";
|
|||
|
|
|
|||
|
|
export default function App() {
|
|||
|
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
|||
|
|
const [account, setAccount] = useState<Account | null>(null);
|
|||
|
|
const [jobs, setJobs] = useState<Job[]>([]);
|
|||
|
|
const [page, setPage] = useState(
|
|||
|
|
location.hash === "#account" ? "account" : "alphas",
|
|||
|
|
);
|
|||
|
|
const [showJobs, setShowJobs] = useState(false);
|
|||
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|||
|
|
const [pollError, setPollError] = useState("");
|
|||
|
|
const refresh = useCallback(async () => {
|
|||
|
|
try {
|
|||
|
|
const [nextAccount, nextJobs] = await Promise.all([
|
|||
|
|
api<Account>("/account"),
|
|||
|
|
api<Job[]>("/sync-jobs"),
|
|||
|
|
]);
|
|||
|
|
setAccount(nextAccount);
|
|||
|
|
setJobs(nextJobs);
|
|||
|
|
setPollError("");
|
|||
|
|
} catch (error) {
|
|||
|
|
setPollError((error as Error).message);
|
|||
|
|
}
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
api("/auth/me")
|
|||
|
|
.then(() => setAuthenticated(true))
|
|||
|
|
.catch(() => setAuthenticated(false));
|
|||
|
|
const expired = () => {
|
|||
|
|
setAuthenticated(false);
|
|||
|
|
setAccount(null);
|
|||
|
|
setJobs([]);
|
|||
|
|
};
|
|||
|
|
window.addEventListener("session-expired", expired);
|
|||
|
|
const hash = () =>
|
|||
|
|
setPage(location.hash === "#account" ? "account" : "alphas");
|
|||
|
|
window.addEventListener("hashchange", hash);
|
|||
|
|
return () => {
|
|||
|
|
window.removeEventListener("session-expired", expired);
|
|||
|
|
window.removeEventListener("hashchange", hash);
|
|||
|
|
};
|
|||
|
|
}, []);
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!authenticated) return;
|
|||
|
|
void refresh();
|
|||
|
|
const timer = window.setInterval(() => void refresh(), 3000);
|
|||
|
|
return () => clearInterval(timer);
|
|||
|
|
}, [authenticated, refresh]);
|
|||
|
|
useEffect(() => {
|
|||
|
|
document.body.setAttribute("theme-mode", account?.theme ?? "light");
|
|||
|
|
}, [account?.theme]);
|
|||
|
|
|
|||
|
|
const completedVersion = jobs
|
|||
|
|
.filter(
|
|||
|
|
(j) => j.status === "completed" || j.status === "completed_with_errors",
|
|||
|
|
)
|
|||
|
|
.map((j) => `${j.id}:${j.updated_at}`)
|
|||
|
|
.join("|");
|
|||
|
|
const pending = jobs.filter((j) =>
|
|||
|
|
["queued", "running", "waiting_auth", "waiting_connection"].includes(
|
|||
|
|
j.status,
|
|||
|
|
),
|
|||
|
|
).length;
|
|||
|
|
const actionDone = () => {
|
|||
|
|
void refresh();
|
|||
|
|
setRefreshKey((k) => k + 1);
|
|||
|
|
};
|
|||
|
|
const taskCreated = () => {
|
|||
|
|
actionDone();
|
|||
|
|
setShowJobs(true);
|
|||
|
|
};
|
|||
|
|
const changePage = (next: string) => {
|
|||
|
|
location.hash = next;
|
|||
|
|
setPage(next);
|
|||
|
|
};
|
|||
|
|
const logout = async () => {
|
|||
|
|
try {
|
|||
|
|
await post("/auth/logout");
|
|||
|
|
setAuthenticated(false);
|
|||
|
|
} catch (e) {
|
|||
|
|
Toast.error((e as Error).message);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<LocaleProvider locale={zhCN}>
|
|||
|
|
{authenticated === null ? (
|
|||
|
|
<div className="screen-center">
|
|||
|
|
<Spin size="large" />
|
|||
|
|
</div>
|
|||
|
|
) : !authenticated ? (
|
|||
|
|
<Login
|
|||
|
|
onLogin={() => {
|
|||
|
|
setAuthenticated(true);
|
|||
|
|
void refresh();
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<div className="workspace">
|
|||
|
|
<aside className="sidebar">
|
|||
|
|
<div className="brand">
|
|||
|
|
<span className="brand-mark">α</span>
|
|||
|
|
<div>
|
|||
|
|
ALPHA<span>RESEARCH WORKSPACE</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="nav-label">工作空间</div>
|
|||
|
|
<button
|
|||
|
|
aria-label="Alpha 管理"
|
|||
|
|
className={`nav-item ${page === "alphas" ? "active" : ""}`}
|
|||
|
|
onClick={() => changePage("alphas")}
|
|||
|
|
>
|
|||
|
|
<IconGridView size="large" />
|
|||
|
|
Alpha 管理
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
aria-label="个人信息"
|
|||
|
|
className={`nav-item ${page === "account" ? "active" : ""}`}
|
|||
|
|
onClick={() => changePage("account")}
|
|||
|
|
>
|
|||
|
|
<IconUser size="large" />
|
|||
|
|
个人信息
|
|||
|
|
</button>
|
|||
|
|
<div className="sidebar-bottom">
|
|||
|
|
<div className="connection-line">
|
|||
|
|
<i
|
|||
|
|
className={
|
|||
|
|
account?.connection_status === "connected"
|
|||
|
|
? "connected"
|
|||
|
|
: ""
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
{account?.connection_status === "connected"
|
|||
|
|
? "WorldQuant 已连接"
|
|||
|
|
: "WorldQuant 未连接"}
|
|||
|
|
</div>
|
|||
|
|
<span>个人研究空间 · v0.1</span>
|
|||
|
|
</div>
|
|||
|
|
</aside>
|
|||
|
|
<div className="main-shell">
|
|||
|
|
<header className="topbar">
|
|||
|
|
<div className="breadcrumbs">
|
|||
|
|
工作空间 <span>/</span>{" "}
|
|||
|
|
{page === "alphas" ? "Alpha 管理" : "个人信息"}
|
|||
|
|
</div>
|
|||
|
|
<div className="top-actions">
|
|||
|
|
<Badge count={pending}>
|
|||
|
|
<Button
|
|||
|
|
theme="borderless"
|
|||
|
|
icon={<IconBell />}
|
|||
|
|
onClick={() => setShowJobs(true)}
|
|||
|
|
>
|
|||
|
|
同步任务
|
|||
|
|
</Button>
|
|||
|
|
</Badge>
|
|||
|
|
<div className="top-divider" />
|
|||
|
|
<Avatar size="small" color="purple">
|
|||
|
|
{account?.display_name.slice(0, 1) || "研"}
|
|||
|
|
</Avatar>
|
|||
|
|
<span className="account-name">
|
|||
|
|
{account?.display_name ?? "研究员"}
|
|||
|
|
</span>
|
|||
|
|
<Button
|
|||
|
|
aria-label="退出登录"
|
|||
|
|
theme="borderless"
|
|||
|
|
icon={<IconExit />}
|
|||
|
|
onClick={() => void logout()}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
<main className="page-content">
|
|||
|
|
{pollError && (
|
|||
|
|
<Banner
|
|||
|
|
type="warning"
|
|||
|
|
description={`后台状态暂时不可用:${pollError}`}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
{page === "account" ? (
|
|||
|
|
<AccountPage
|
|||
|
|
account={account}
|
|||
|
|
onChange={actionDone}
|
|||
|
|
onTask={taskCreated}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<AlphaPage
|
|||
|
|
taskPanelOpen={showJobs}
|
|||
|
|
account={account}
|
|||
|
|
version={`${refreshKey}:${completedVersion}`}
|
|||
|
|
onTask={taskCreated}
|
|||
|
|
onAccount={() => changePage("account")}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
<JobPanel
|
|||
|
|
visible={showJobs}
|
|||
|
|
jobs={jobs}
|
|||
|
|
timezone={account?.timezone}
|
|||
|
|
onClose={() => setShowJobs(false)}
|
|||
|
|
onChange={actionDone}
|
|||
|
|
onAccount={() => {
|
|||
|
|
setShowJobs(false);
|
|||
|
|
changePage("account");
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</LocaleProvider>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function Login({ onLogin }: { onLogin: () => void }) {
|
|||
|
|
const [username, setUsername] = useState("admin");
|
|||
|
|
const [password, setPassword] = useState("");
|
|||
|
|
const [busy, setBusy] = useState(false);
|
|||
|
|
const [error, setError] = useState("");
|
|||
|
|
async function submit(event: React.FormEvent) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
setBusy(true);
|
|||
|
|
setError("");
|
|||
|
|
try {
|
|||
|
|
await post("/auth/login", { username, password });
|
|||
|
|
setPassword("");
|
|||
|
|
onLogin();
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message);
|
|||
|
|
} finally {
|
|||
|
|
setBusy(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return (
|
|||
|
|
<div className="login-page">
|
|||
|
|
<section className="login-story">
|
|||
|
|
<div className="brand">
|
|||
|
|
<span className="brand-mark">α</span>
|
|||
|
|
<div>
|
|||
|
|
ALPHA<span>RESEARCH WORKSPACE</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="story-copy">
|
|||
|
|
<div className="eyebrow">WORLDQUANT RESEARCH</div>
|
|||
|
|
<h1>
|
|||
|
|
让每一次研究,
|
|||
|
|
<br />
|
|||
|
|
都有迹可循。
|
|||
|
|
</h1>
|
|||
|
|
<p>
|
|||
|
|
从想法到验证,从信号到洞察。
|
|||
|
|
<br />
|
|||
|
|
在一个工作空间里,管理你的 Alpha 研究。
|
|||
|
|
</p>
|
|||
|
|
<div className="story-signal">
|
|||
|
|
<IconPulse size="extra-large" />
|
|||
|
|
<span>观察 · 记录 · 迭代</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="login-footnote">你的个人 Alpha 研究工作空间</div>
|
|||
|
|
</section>
|
|||
|
|
<section className="login-form-shell">
|
|||
|
|
<form className="login-form" onSubmit={submit}>
|
|||
|
|
<div className="eyebrow">欢迎回来</div>
|
|||
|
|
<h2>登录研究工作空间</h2>
|
|||
|
|
<p>使用部署时设置的系统账户登录。</p>
|
|||
|
|
{error && <Banner type="danger" description={error} />}
|
|||
|
|
<label>
|
|||
|
|
用户名
|
|||
|
|
<Input
|
|||
|
|
aria-label="用户名"
|
|||
|
|
value={username}
|
|||
|
|
onChange={setUsername}
|
|||
|
|
autoComplete="username"
|
|||
|
|
size="large"
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
<label>
|
|||
|
|
密码
|
|||
|
|
<Input
|
|||
|
|
aria-label="密码"
|
|||
|
|
mode="password"
|
|||
|
|
value={password}
|
|||
|
|
onChange={setPassword}
|
|||
|
|
autoComplete="current-password"
|
|||
|
|
size="large"
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
<Button
|
|||
|
|
htmlType="submit"
|
|||
|
|
theme="solid"
|
|||
|
|
size="large"
|
|||
|
|
block
|
|||
|
|
loading={busy}
|
|||
|
|
icon={<IconArrowRight />}
|
|||
|
|
iconPosition="right"
|
|||
|
|
>
|
|||
|
|
进入工作空间
|
|||
|
|
</Button>
|
|||
|
|
<div className="login-note">WorldQuant 账户在登录后单独连接。</div>
|
|||
|
|
</form>
|
|||
|
|
</section>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|