2026-09-07 14:54:20 +08:00
|
|
|
|
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";
|
2026-09-07 23:02:55 +08:00
|
|
|
|
import { ChatPanel } from "./ai/ChatPanel";
|
|
|
|
|
|
import type { PageContext, UIAction } from "./ai/types";
|
2026-09-07 14:54:20 +08:00
|
|
|
|
|
|
|
|
|
|
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("");
|
2026-09-07 23:02:55 +08:00
|
|
|
|
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]);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
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([]);
|
2026-09-07 23:02:55 +08:00
|
|
|
|
setChatOpen(false);
|
|
|
|
|
|
setShowJobs(false);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
};
|
|
|
|
|
|
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);
|
2026-09-07 23:02:55 +08:00
|
|
|
|
setChatOpen(false);
|
|
|
|
|
|
setShowJobs(false);
|
|
|
|
|
|
setAccount(null);
|
|
|
|
|
|
setJobs([]);
|
|
|
|
|
|
setAIAction(null);
|
|
|
|
|
|
setAlphaContext({ page: "alphas" });
|
2026-09-07 14:54:20 +08:00
|
|
|
|
} 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">
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<aside className="sidebar" inert={chatOpen && viewport < 1440}>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<div className="brand">
|
|
|
|
|
|
<span className="brand-mark">α</span>
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<div>Alpha 研究</div>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
aria-label="Alpha 管理"
|
|
|
|
|
|
className={`nav-item ${page === "alphas" ? "active" : ""}`}
|
2026-09-07 23:31:23 +08:00
|
|
|
|
aria-current={page === "alphas" ? "page" : undefined}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
onClick={() => changePage("alphas")}
|
|
|
|
|
|
>
|
|
|
|
|
|
Alpha 管理
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
aria-label="个人信息"
|
|
|
|
|
|
className={`nav-item ${page === "account" ? "active" : ""}`}
|
2026-09-07 23:31:23 +08:00
|
|
|
|
aria-current={page === "account" ? "page" : undefined}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
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>
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<div className="main-shell" inert={chatOpen && viewport < 1440}>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<header className="topbar">
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<div className="breadcrumbs">研究工作空间</div>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<div className="top-actions">
|
|
|
|
|
|
<Badge count={pending}>
|
|
|
|
|
|
<Button
|
2026-09-07 23:31:23 +08:00
|
|
|
|
type="tertiary"
|
2026-09-07 14:54:20 +08:00
|
|
|
|
theme="borderless"
|
2026-09-07 23:02:55 +08:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
focusBusiness();
|
|
|
|
|
|
setShowJobs(true);
|
|
|
|
|
|
}}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
>
|
|
|
|
|
|
同步任务
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Badge>
|
2026-09-07 23:02:55 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
aria-label="切换研究助手"
|
2026-09-07 23:31:23 +08:00
|
|
|
|
aria-expanded={chatOpen}
|
|
|
|
|
|
aria-controls="research-assistant"
|
|
|
|
|
|
type="tertiary"
|
2026-09-07 23:02:55 +08:00
|
|
|
|
theme={chatOpen ? "light" : "borderless"}
|
|
|
|
|
|
onClick={() => setChatOpen(!chatOpen)}
|
|
|
|
|
|
>
|
|
|
|
|
|
AI 助手
|
|
|
|
|
|
</Button>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<div className="top-divider" />
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<Avatar size="small" color="grey">
|
2026-09-07 14:54:20 +08:00
|
|
|
|
{account?.display_name.slice(0, 1) || "研"}
|
|
|
|
|
|
</Avatar>
|
|
|
|
|
|
<span className="account-name">
|
|
|
|
|
|
{account?.display_name ?? "研究员"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
2026-09-07 23:31:23 +08:00
|
|
|
|
type="tertiary"
|
2026-09-07 14:54:20 +08:00
|
|
|
|
aria-label="退出登录"
|
|
|
|
|
|
theme="borderless"
|
|
|
|
|
|
onClick={() => void logout()}
|
2026-09-07 23:31:23 +08:00
|
|
|
|
>
|
|
|
|
|
|
退出
|
|
|
|
|
|
</Button>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<main
|
|
|
|
|
|
className={`page-content ${page === "alphas" ? "bounded-page" : "account-page"}`}
|
|
|
|
|
|
>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
{pollError && (
|
|
|
|
|
|
<Banner
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
description={`后台状态暂时不可用:${pollError}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-09-07 23:02:55 +08:00
|
|
|
|
<div hidden={page !== "account"}>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<AccountPage
|
|
|
|
|
|
account={account}
|
|
|
|
|
|
onChange={actionDone}
|
|
|
|
|
|
onTask={taskCreated}
|
|
|
|
|
|
/>
|
2026-09-07 23:02:55 +08:00
|
|
|
|
</div>
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<div className="alpha-page-view" hidden={page !== "alphas"}>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<AlphaPage
|
|
|
|
|
|
taskPanelOpen={showJobs}
|
|
|
|
|
|
account={account}
|
|
|
|
|
|
version={`${refreshKey}:${completedVersion}`}
|
|
|
|
|
|
onTask={taskCreated}
|
|
|
|
|
|
onAccount={() => changePage("account")}
|
2026-09-07 23:02:55 +08:00
|
|
|
|
active={page === "alphas"}
|
|
|
|
|
|
overlaySuspended={
|
|
|
|
|
|
page !== "alphas" || (viewport < 1440 && chatOpen)
|
|
|
|
|
|
}
|
|
|
|
|
|
chatOffset={chatOffset}
|
|
|
|
|
|
onContext={setAlphaContext}
|
|
|
|
|
|
action={aiAction}
|
|
|
|
|
|
onOverlay={focusBusiness}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
/>
|
2026-09-07 23:02:55 +08:00
|
|
|
|
</div>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<JobPanel
|
2026-09-07 23:02:55 +08:00
|
|
|
|
visible={showJobs && !(viewport < 1440 && chatOpen)}
|
|
|
|
|
|
chatOffset={chatOffset}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
jobs={jobs}
|
|
|
|
|
|
timezone={account?.timezone}
|
|
|
|
|
|
onClose={() => setShowJobs(false)}
|
|
|
|
|
|
onChange={actionDone}
|
|
|
|
|
|
onAccount={() => {
|
|
|
|
|
|
setShowJobs(false);
|
|
|
|
|
|
changePage("account");
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-09-07 23:02:55 +08:00
|
|
|
|
{!chatOpen && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
className="ai-launcher"
|
|
|
|
|
|
aria-label="打开研究助手"
|
2026-09-07 23:31:23 +08:00
|
|
|
|
theme="light"
|
|
|
|
|
|
type="tertiary"
|
2026-09-07 23:02:55 +08:00
|
|
|
|
onClick={() => setChatOpen(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
AI 研究助手
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-09-07 23:31:23 +08:00
|
|
|
|
{chatOpen && viewport < 1440 && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="ai-mask"
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
onClick={() => setChatOpen(false)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-09-07 23:02:55 +08:00
|
|
|
|
<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");
|
2026-09-07 23:31:23 +08:00
|
|
|
|
requestAnimationFrame(() =>
|
|
|
|
|
|
document
|
|
|
|
|
|
.getElementById("model-settings")
|
|
|
|
|
|
?.scrollIntoView({ block: "start" }),
|
|
|
|
|
|
);
|
2026-09-07 23:02:55 +08:00
|
|
|
|
}}
|
|
|
|
|
|
onChanged={actionDone}
|
|
|
|
|
|
onAction={(action) => {
|
|
|
|
|
|
focusBusiness();
|
|
|
|
|
|
changePage("alphas");
|
|
|
|
|
|
setAIAction(action);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</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}>
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<div className="login-brand">Alpha 研究</div>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|