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

394 lines
12 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useState } from "react";
import {
Avatar,
Badge,
Banner,
Button,
Input,
LocaleProvider,
Spin,
Toast,
} from "@douyinfe/semi-ui-19";
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" inert={chatOpen && viewport < 1440}>
<div className="brand">
<span className="brand-mark">α</span>
<div>Alpha 研究</div>
</div>
<button
aria-label="Alpha 管理"
className={`nav-item ${page === "alphas" ? "active" : ""}`}
aria-current={page === "alphas" ? "page" : undefined}
onClick={() => changePage("alphas")}
>
Alpha 管理
</button>
<button
aria-label="个人信息"
className={`nav-item ${page === "account" ? "active" : ""}`}
aria-current={page === "account" ? "page" : undefined}
onClick={() => changePage("account")}
>
个人信息
</button>
<div className="sidebar-bottom">
<div className="connection-line">
<i
className={
account?.connection_status === "connected"
? "connected"
: ""
}
/>
{account?.connection_status === "connected"
? "WorldQuant 已连接"
: "WorldQuant 未连接"}
</div>
</div>
</aside>
<div className="main-shell" inert={chatOpen && viewport < 1440}>
<header className="topbar">
<div className="breadcrumbs">研究工作空间</div>
<div className="top-actions">
<Badge count={pending}>
<Button
type="tertiary"
theme="borderless"
onClick={() => {
focusBusiness();
setShowJobs(true);
}}
>
同步任务
</Button>
</Badge>
<Button
aria-label="切换研究助手"
aria-expanded={chatOpen}
aria-controls="research-assistant"
type="tertiary"
theme={chatOpen ? "light" : "borderless"}
onClick={() => setChatOpen(!chatOpen)}
>
AI 助手
</Button>
<div className="top-divider" />
<Avatar size="small" color="grey">
{account?.display_name.slice(0, 1) || "研"}
</Avatar>
<span className="account-name">
{account?.display_name ?? "研究员"}
</span>
<Button
type="tertiary"
aria-label="退出登录"
theme="borderless"
onClick={() => void logout()}
>
退出
</Button>
</div>
</header>
<main
className={`page-content ${page === "alphas" ? "bounded-page" : "account-page"}`}
>
{pollError && (
<Banner
type="warning"
description={`后台状态暂时不可用:${pollError}`}
/>
)}
<div hidden={page !== "account"}>
<AccountPage
account={account}
onChange={actionDone}
onTask={taskCreated}
/>
</div>
<div className="alpha-page-view" 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="light"
type="tertiary"
onClick={() => setChatOpen(true)}
>
AI 研究助手
</Button>
)}
{chatOpen && viewport < 1440 && (
<div
className="ai-mask"
aria-hidden="true"
onClick={() => setChatOpen(false)}
/>
)}
<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");
requestAnimationFrame(() =>
document
.getElementById("model-settings")
?.scrollIntoView({ block: "start" }),
);
}}
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-form-shell">
<form className="login-form" onSubmit={submit}>
<div className="login-brand">Alpha 研究</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}
>
进入工作空间
</Button>
<div className="login-note">WorldQuant 账户在登录后单独连接。</div>
</form>
</section>
</div>
);
}