feat: add initial frontend setup with styles, types, and testing framework

- Created a global CSS file for styling the frontend with responsive design.
- Introduced TypeScript types for various entities including Research, Alpha, and Account.
- Implemented Playwright tests for account management and data synchronization workflows.
- Configured TypeScript with strict settings and included necessary libraries.
- Set up Vite as the build tool with React plugin and API proxy configuration.
- Added a Python script to initialize environment variables securely.
This commit is contained in:
yuxuanhui
2026-09-07 14:54:20 +08:00
commit 3cd280d068
53 changed files with 10888 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
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>
);
}