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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user