Files
worldquant-alpha-system/frontend/src/App.tsx
T

417 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import { ChatPanel } from "./ai/ChatPanel";
import type { PageContext, UIAction } from "./ai/types";
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 [chatOpen, setChatOpen] = useState(false);
const [chatWidth, setChatWidth] = useState(420);
const [viewport, setViewport] = useState(window.innerWidth);
const [alphaContext, setAlphaContext] = useState<PageContext>({
page: "alphas",
});
const [aiAction, setAIAction] = useState<UIAction | null>(null);
const chatOffset = viewport >= 1440 && chatOpen ? chatWidth : 0;
const focusBusiness = useCallback(() => {
if (window.innerWidth < 1440) setChatOpen(false);
}, []);
useEffect(() => {
const resize = () => setViewport(window.innerWidth);
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
useEffect(() => {
document.documentElement.style.setProperty(
"--chat-space",
`${chatOffset}px`,
);
document.documentElement.style.setProperty(
"--chat-width",
`${chatWidth}px`,
);
return () => {
document.documentElement.style.setProperty("--chat-space", "0px");
};
}, [chatOffset, chatWidth]);
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([]);
setChatOpen(false);
setShowJobs(false);
};
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);
setChatOpen(false);
setShowJobs(false);
setAccount(null);
setJobs([]);
setAIAction(null);
setAlphaContext({ page: "alphas" });
} 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={() => {
focusBusiness();
setShowJobs(true);
}}
>
同步任务
</Button>
</Badge>
<Button
aria-label="切换研究助手"
theme={chatOpen ? "light" : "borderless"}
onClick={() => setChatOpen(!chatOpen)}
>
AI 助手
</Button>
<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}`}
/>
)}
<div hidden={page !== "account"}>
<AccountPage
account={account}
onChange={actionDone}
onTask={taskCreated}
/>
</div>
<div hidden={page !== "alphas"}>
<AlphaPage
taskPanelOpen={showJobs}
account={account}
version={`${refreshKey}:${completedVersion}`}
onTask={taskCreated}
onAccount={() => changePage("account")}
active={page === "alphas"}
overlaySuspended={
page !== "alphas" || (viewport < 1440 && chatOpen)
}
chatOffset={chatOffset}
onContext={setAlphaContext}
action={aiAction}
onOverlay={focusBusiness}
/>
</div>
</main>
</div>
<JobPanel
visible={showJobs && !(viewport < 1440 && chatOpen)}
chatOffset={chatOffset}
jobs={jobs}
timezone={account?.timezone}
onClose={() => setShowJobs(false)}
onChange={actionDone}
onAccount={() => {
setShowJobs(false);
changePage("account");
}}
/>
{!chatOpen && (
<Button
className="ai-launcher"
aria-label="打开研究助手"
theme="solid"
onClick={() => setChatOpen(true)}
>
AI 研究助手
</Button>
)}
<ChatPanel
jobs={jobs}
open={chatOpen}
width={chatWidth}
onWidth={setChatWidth}
onClose={() => setChatOpen(false)}
context={page === "alphas" ? alphaContext : { page: "account" }}
timezone={account?.timezone}
onSettings={() => {
focusBusiness();
changePage("account");
}}
onChanged={actionDone}
onAction={(action) => {
focusBusiness();
changePage("alphas");
setAIAction(action);
}}
/>
</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>
);
}