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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const response = await fetch(`/api/v1${path}`, {
|
||||
...options,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-WQ-Request": "1",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (response.status === 401 && path !== "/auth/login")
|
||||
window.dispatchEvent(new Event("session-expired"));
|
||||
throw new ApiError(
|
||||
typeof body.detail === "string"
|
||||
? body.detail
|
||||
: `请求失败(${response.status})`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const post = <T>(path: string, body?: unknown) =>
|
||||
api<T>(path, {
|
||||
method: "POST",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
export const patch = <T>(path: string, body: unknown) =>
|
||||
api<T>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||
|
||||
export function formatNumber(value: unknown, digits = 2): string {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value.toFixed(digits)
|
||||
: "—";
|
||||
}
|
||||
|
||||
export function displayValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "未提供";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatTime(
|
||||
value: string | null | undefined,
|
||||
timezone = "Asia/Shanghai",
|
||||
): string {
|
||||
if (!value) return "尚未同步";
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.valueOf())) return "时间未知";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const stateLabels = {
|
||||
inbox: "待研究",
|
||||
candidate: "候选",
|
||||
optimizing: "优化中",
|
||||
archived: "归档",
|
||||
};
|
||||
export const stateOptions = Object.entries(stateLabels).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
);
|
||||
export const jobLabels: Record<string, string> = {
|
||||
full_sync: "全量同步 Alpha",
|
||||
alpha_refresh: "导入 / 刷新 Alpha",
|
||||
pnl_refresh: "获取 PnL",
|
||||
connect: "连接 WorldQuant",
|
||||
verify: "继续人工验证",
|
||||
profile: "刷新个人资料",
|
||||
};
|
||||
export const jobStateLabels: Record<string, string> = {
|
||||
queued: "排队中",
|
||||
running: "执行中",
|
||||
waiting_auth: "等待人工验证",
|
||||
waiting_connection: "等待连接",
|
||||
completed: "已完成",
|
||||
completed_with_errors: "部分失败",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
export function queryString(
|
||||
values: Record<string, string | number | boolean | undefined>,
|
||||
): string {
|
||||
return new URLSearchParams(
|
||||
Object.entries(values)
|
||||
.filter(([, v]) => v !== undefined && v !== "")
|
||||
.map(([k, v]) => [k, String(v)]),
|
||||
).toString();
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Input,
|
||||
Select,
|
||||
SideSheet,
|
||||
Spin,
|
||||
TabPane,
|
||||
Tabs,
|
||||
Tag,
|
||||
TextArea,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
api,
|
||||
displayValue,
|
||||
formatTime,
|
||||
patch,
|
||||
post,
|
||||
stateOptions,
|
||||
} from "../api";
|
||||
import type {
|
||||
AlphaDetail as Detail,
|
||||
Pnl,
|
||||
Research,
|
||||
ResearchState,
|
||||
} from "../types";
|
||||
import { PnlChart } from "./PnlChart";
|
||||
|
||||
export function AlphaDetail({
|
||||
id,
|
||||
version,
|
||||
timezone,
|
||||
taskPanelOpen,
|
||||
onClose,
|
||||
onSaved,
|
||||
onTask,
|
||||
}: {
|
||||
id: string | null;
|
||||
version: string;
|
||||
timezone?: string;
|
||||
taskPanelOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onTask: () => void;
|
||||
}) {
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [pnl, setPnl] = useState<Pnl | null>(null);
|
||||
const [research, setResearch] = useState<Research | null>(null);
|
||||
const [tagText, setTagText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [tab, setTab] = useState("overview");
|
||||
const dirty = useRef(false);
|
||||
useEffect(() => {
|
||||
dirty.current = false;
|
||||
setDetail(null);
|
||||
setResearch(null);
|
||||
setPnl(null);
|
||||
setError("");
|
||||
setTab("overview");
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (id)
|
||||
api<Detail>(`/alphas/${id}`)
|
||||
.then((value) => {
|
||||
if (active) {
|
||||
setDetail(value);
|
||||
setError("");
|
||||
if (!dirty.current) {
|
||||
setResearch(value.research);
|
||||
setTagText(value.research.tags.join(", "));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id, version]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (id && tab === "pnl")
|
||||
api<Pnl>(`/alphas/${id}/pnl`)
|
||||
.then((p) => {
|
||||
if (active) setPnl(p);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) Toast.error(e.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id, tab, version]);
|
||||
async function fetchPnl() {
|
||||
try {
|
||||
await post("/sync-jobs", { kind: "pnl_refresh", alpha_ids: [id] });
|
||||
onTask();
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (!research) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const { updated_at: _, ...editable } = research;
|
||||
await patch(`/alphas/${id}/research`, {
|
||||
...editable,
|
||||
tags: tagText
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
dirty.current = false;
|
||||
Toast.success("研究记录已保存");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<SideSheet
|
||||
closeOnEsc={!taskPanelOpen}
|
||||
className="alpha-detail"
|
||||
visible={Boolean(id)}
|
||||
width={800}
|
||||
title={
|
||||
<span>
|
||||
Alpha 详情 <code className="muted">{id}</code>
|
||||
</span>
|
||||
}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{error ? (
|
||||
<Banner type="danger" description={error} />
|
||||
) : !detail || !research ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<>
|
||||
<div className="detail-title">
|
||||
<div>
|
||||
<h2>{detail.name || detail.id}</h2>
|
||||
<div className="inline-actions">
|
||||
<Tag>{detail.alpha_type ?? "类型未提供"}</Tag>
|
||||
<Tag>{detail.language ?? "语言未提供"}</Tag>
|
||||
<Tag>{detail.status ?? "状态未提供"}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`https://platform.worldquantbrain.com/alpha/${encodeURIComponent(detail.id)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button>在 BRAIN 中打开 ↗</Button>
|
||||
</a>
|
||||
</div>
|
||||
<Tabs activeKey={tab} onChange={setTab}>
|
||||
<TabPane tab="概览与表达式" itemKey="overview">
|
||||
<div className="detail-section">
|
||||
<Descriptions
|
||||
data={["region", "universe", "stage"].map((key) => ({
|
||||
key,
|
||||
value: displayValue(detail[key as keyof Detail]),
|
||||
}))}
|
||||
/>
|
||||
<p className="muted">
|
||||
平台创建:{formatTime(detail.date_created, timezone)} ·
|
||||
最近同步:{formatTime(detail.synced_at, timezone)}
|
||||
</p>
|
||||
{detail.expression !== null && (
|
||||
<CodeBlock
|
||||
title={
|
||||
detail.language === "PYTHON" ? "Python 代码" : "表达式"
|
||||
}
|
||||
value={detail.expression}
|
||||
/>
|
||||
)}
|
||||
{detail.selection !== null && (
|
||||
<CodeBlock title="Selection" value={detail.selection} />
|
||||
)}
|
||||
{detail.combo !== null && (
|
||||
<CodeBlock title="Combo" value={detail.combo} />
|
||||
)}
|
||||
<h3>回测设置</h3>
|
||||
<Descriptions
|
||||
align="left"
|
||||
data={Object.entries(detail.settings).map(([key, value]) => ({
|
||||
key,
|
||||
value: displayValue(value),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tab="指标与检查" itemKey="metrics">
|
||||
<MetricTable title="IS 指标" values={detail.is_metrics} />
|
||||
<MetricTable title="OS 指标" values={detail.os_metrics} />
|
||||
<h3>平台已有检查结果</h3>
|
||||
<p className="muted">
|
||||
展示已同步结果,查看此页不会触发新的平台检查。
|
||||
</p>
|
||||
{detail.checks.length ? (
|
||||
detail.checks.map((check, i) => (
|
||||
<div className="check-row" key={i}>
|
||||
<span>{displayValue(check.name)}</span>
|
||||
<span>
|
||||
{displayValue(check.value)} / {displayValue(check.limit)}
|
||||
</span>
|
||||
<Tag
|
||||
color={
|
||||
check.result === "PASS"
|
||||
? "green"
|
||||
: check.result === "FAIL"
|
||||
? "red"
|
||||
: "grey"
|
||||
}
|
||||
>
|
||||
{displayValue(check.result)}
|
||||
</Tag>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Empty description="平台尚未返回检查结果" />
|
||||
)}
|
||||
</TabPane>
|
||||
<TabPane tab="PnL 曲线" itemKey="pnl">
|
||||
<div className="section-toolbar">
|
||||
<div>
|
||||
<h3>累计 PnL</h3>
|
||||
<p className="muted">
|
||||
{pnl?.fetched_at
|
||||
? `缓存更新于 ${formatTime(pnl.fetched_at, timezone)}`
|
||||
: "尚未获取 PnL"}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => void fetchPnl()}>
|
||||
{pnl?.cached ? "刷新 PnL" : "获取 PnL"}
|
||||
</Button>
|
||||
</div>
|
||||
{pnl?.cached ? (
|
||||
<>
|
||||
<PnlChart points={pnl.points} />
|
||||
<p className="muted">
|
||||
{pnl.points.length} 条记录 · 缺失数据保留为断点
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<Empty description="点击获取 PnL,从平台读取并缓存曲线数据" />
|
||||
)}
|
||||
</TabPane>
|
||||
<TabPane tab="研究记录" itemKey="research">
|
||||
<div className="detail-section">
|
||||
<p className="muted">
|
||||
这些记录只保存在你的工作空间,平台同步不会覆盖。
|
||||
</p>
|
||||
<label>
|
||||
研究状态
|
||||
<Select
|
||||
aria-label="研究状态"
|
||||
optionList={stateOptions}
|
||||
value={research.state}
|
||||
onChange={(state) => {
|
||||
dirty.current = true;
|
||||
setResearch({
|
||||
...research,
|
||||
state: state as ResearchState,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
标签
|
||||
<Input
|
||||
aria-label="研究标签"
|
||||
value={tagText}
|
||||
onChange={(value) => {
|
||||
dirty.current = true;
|
||||
setTagText(value);
|
||||
}}
|
||||
placeholder="以逗号分隔,例如 低换手, 分析师预期"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
研究备注
|
||||
<TextArea
|
||||
aria-label="研究备注"
|
||||
value={research.note}
|
||||
onChange={(note) => {
|
||||
dirty.current = true;
|
||||
setResearch({ ...research, note });
|
||||
}}
|
||||
rows={8}
|
||||
maxCount={20000}
|
||||
placeholder="记录假设、观察、下一步实验……"
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={research.favorite}
|
||||
onChange={(e) => {
|
||||
dirty.current = true;
|
||||
setResearch({
|
||||
...research,
|
||||
favorite: Boolean(e.target.checked),
|
||||
});
|
||||
}}
|
||||
>
|
||||
收藏此 Alpha
|
||||
</Checkbox>
|
||||
<div className="detail-save">
|
||||
<Button
|
||||
theme="solid"
|
||||
loading={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
保存研究记录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</SideSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ title, value }: { title: string; value: string }) {
|
||||
return (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<pre className="code-block">
|
||||
<code>{value}</code>
|
||||
</pre>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function MetricTable({
|
||||
title,
|
||||
values,
|
||||
}: {
|
||||
title: string;
|
||||
values: Record<string, unknown>;
|
||||
}) {
|
||||
const entries = Object.entries(values).filter(([key]) => key !== "checks");
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<h3>{title}</h3>
|
||||
{entries.length ? (
|
||||
<Descriptions
|
||||
data={entries.map(([key, value]) => ({
|
||||
key,
|
||||
value: displayValue(value),
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<p className="muted">未提供</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Progress,
|
||||
SideSheet,
|
||||
Tag,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { api, formatTime, jobLabels, jobStateLabels, post } from "../api";
|
||||
import type { Job } from "../types";
|
||||
|
||||
export function JobPanel({
|
||||
visible,
|
||||
jobs,
|
||||
timezone,
|
||||
onClose,
|
||||
onChange,
|
||||
onAccount,
|
||||
}: {
|
||||
visible: boolean;
|
||||
jobs: Job[];
|
||||
timezone?: string;
|
||||
onClose: () => void;
|
||||
onChange: () => void;
|
||||
onAccount: () => void;
|
||||
}) {
|
||||
const [errors, setErrors] = useState<
|
||||
Record<string, { alpha_id: string; error: string }[]>
|
||||
>({});
|
||||
const [busy, setBusy] = useState("");
|
||||
async function action(job: Job, name: string) {
|
||||
setBusy(job.id);
|
||||
try {
|
||||
await post(`/sync-jobs/${job.id}/${name}`);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<SideSheet
|
||||
closeOnEsc
|
||||
className="job-panel"
|
||||
title="同步任务"
|
||||
visible={visible}
|
||||
width={520}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<p className="muted">同步进度保存在服务端,关闭此面板不会中断任务。</p>
|
||||
{!jobs.length && <Empty description="暂时没有同步任务" />}
|
||||
{jobs.map((job) => (
|
||||
<article className="job-card" key={job.id}>
|
||||
<div className="job-header">
|
||||
<strong>{jobLabels[job.kind] ?? job.kind}</strong>
|
||||
<Tag
|
||||
color={
|
||||
job.status === "completed"
|
||||
? "green"
|
||||
: ["failed", "completed_with_errors"].includes(job.status)
|
||||
? "red"
|
||||
: "blue"
|
||||
}
|
||||
>
|
||||
{jobStateLabels[job.status] ?? job.status}
|
||||
</Tag>
|
||||
</div>
|
||||
<p className="muted">{formatTime(job.created_at, timezone)}</p>
|
||||
<div className="job-numbers">
|
||||
<span>
|
||||
已处理 <b>{job.processed}</b>
|
||||
</span>
|
||||
<span>
|
||||
失败 <b>{job.failed}</b>
|
||||
</span>
|
||||
<span>
|
||||
总数 <b>{job.total ?? "计算中"}</b>
|
||||
</span>
|
||||
</div>
|
||||
{job.total !== null && job.total > 0 && (
|
||||
<Progress
|
||||
percent={Math.min(
|
||||
100,
|
||||
Math.round(((job.processed + job.failed) / job.total) * 100),
|
||||
)}
|
||||
showInfo={false}
|
||||
/>
|
||||
)}
|
||||
{job.error && <p className="error-text">{job.error}</p>}
|
||||
{job.next_retry_at && (
|
||||
<p className="muted">
|
||||
下次重试:{formatTime(job.next_retry_at, timezone)}
|
||||
</p>
|
||||
)}
|
||||
<div className="inline-actions">
|
||||
{[
|
||||
"queued",
|
||||
"running",
|
||||
"waiting_auth",
|
||||
"waiting_connection",
|
||||
].includes(job.status) && (
|
||||
<Button
|
||||
size="small"
|
||||
loading={busy === job.id}
|
||||
onClick={() => void action(job, "cancel")}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
{["failed", "cancelled", "completed_with_errors"].includes(
|
||||
job.status,
|
||||
) && (
|
||||
<Button
|
||||
size="small"
|
||||
loading={busy === job.id}
|
||||
onClick={() => void action(job, "retry")}
|
||||
>
|
||||
重试未完成部分
|
||||
</Button>
|
||||
)}
|
||||
{["waiting_auth", "waiting_connection"].includes(job.status) && (
|
||||
<Button size="small" onClick={onAccount}>
|
||||
处理账户连接
|
||||
</Button>
|
||||
)}
|
||||
{job.failed > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
api<{ alpha_id: string; error: string }[]>(
|
||||
`/sync-jobs/${job.id}/errors`,
|
||||
)
|
||||
.then((rows) => setErrors({ ...errors, [job.id]: rows }))
|
||||
.catch((e) => Toast.error(e.message))
|
||||
}
|
||||
>
|
||||
查看失败记录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{errors[job.id]?.map((e) => (
|
||||
<p className="error-text" key={e.alpha_id}>
|
||||
{e.alpha_id}:{e.error}
|
||||
</p>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
</SideSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Pnl } from "../types";
|
||||
|
||||
export function PnlChart({ points }: { points: Pnl["points"] }) {
|
||||
const values = points
|
||||
.map((p) => p.value)
|
||||
.filter((v): v is number => v !== null && Number.isFinite(v));
|
||||
if (!values.length)
|
||||
return <div className="chart-empty">暂无可绘制的 PnL 数据</div>;
|
||||
const low = Math.min(...values),
|
||||
high = Math.max(...values),
|
||||
range = high - low || Math.max(1, Math.abs(high) * 0.1);
|
||||
const x = (index: number) =>
|
||||
62 + (index / Math.max(1, points.length - 1)) * 610;
|
||||
const y = (v: number) => 24 + ((high - v) / range) * 180;
|
||||
let segments = "";
|
||||
let connected = false;
|
||||
points.forEach((point, i) => {
|
||||
if (point.value === null) {
|
||||
connected = false;
|
||||
return;
|
||||
}
|
||||
segments += `${connected ? " L" : " M"}${x(i)} ${y(point.value)}`;
|
||||
connected = true;
|
||||
});
|
||||
return (
|
||||
<svg
|
||||
className="pnl-chart"
|
||||
viewBox="0 0 700 240"
|
||||
role="img"
|
||||
aria-label={`PnL 曲线,共 ${points.length} 条记录`}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1="62"
|
||||
x2="672"
|
||||
y1={24 + i * 60}
|
||||
y2={24 + i * 60}
|
||||
stroke="var(--line)"
|
||||
/>
|
||||
<text x="52" y={28 + i * 60} textAnchor="end">
|
||||
{(high - (i * range) / 3).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<path d={segments} fill="none" stroke="var(--accent)" strokeWidth="2" />
|
||||
{points.length === 1 && points[0].value !== null && (
|
||||
<circle cx={x(0)} cy={y(points[0].value)} r="3" fill="var(--accent)" />
|
||||
)}
|
||||
<text x="62" y="230">
|
||||
{points[0]?.date.slice(0, 10)}
|
||||
</text>
|
||||
<text x="672" y="230" textAnchor="end">
|
||||
{points.at(-1)?.date.slice(0, 10)}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import "@douyinfe/semi-ui-19/react19-adapter";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./style.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Spin,
|
||||
Tag,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
IconRefresh,
|
||||
IconLink,
|
||||
IconShield,
|
||||
IconUser,
|
||||
} from "@douyinfe/semi-icons";
|
||||
import { api, displayValue, formatTime, patch, post } from "../api";
|
||||
import type { Account } from "../types";
|
||||
|
||||
const connectionLabels: Record<string, string> = {
|
||||
connected: "已连接",
|
||||
disconnected: "未连接",
|
||||
expired: "会话待恢复",
|
||||
connecting: "连接中",
|
||||
verification_required: "需要人工验证",
|
||||
error: "连接异常",
|
||||
};
|
||||
|
||||
export function AccountPage({
|
||||
account,
|
||||
onChange,
|
||||
onTask,
|
||||
}: {
|
||||
account: Account | null;
|
||||
onChange: () => void;
|
||||
onTask: () => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
const [preferences, setPreferences] = useState({
|
||||
display_name: "研究员",
|
||||
theme: "light",
|
||||
timezone: "Asia/Shanghai",
|
||||
page_size: 25,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (account) setEmail(account.email ?? "");
|
||||
}, [account?.email]);
|
||||
useEffect(() => {
|
||||
if (account)
|
||||
setPreferences({
|
||||
display_name: account.display_name,
|
||||
theme: account.theme,
|
||||
timezone: account.timezone,
|
||||
page_size: account.page_size,
|
||||
});
|
||||
}, [
|
||||
account?.display_name,
|
||||
account?.theme,
|
||||
account?.timezone,
|
||||
account?.page_size,
|
||||
]);
|
||||
if (!account) return <Spin size="large" />;
|
||||
async function action(key: string, fn: () => Promise<unknown>, task = false) {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
if (task) onTask();
|
||||
else {
|
||||
onChange();
|
||||
Toast.success("已保存");
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
const profile = account.profile;
|
||||
const profileRows = [
|
||||
["用户 ID", account.wq_user_id],
|
||||
["昵称", profile.name ?? profile.username ?? profile.firstName],
|
||||
["平台邮箱", profile.email ?? account.email],
|
||||
["身份", profile.role ?? profile.roles ?? profile.type],
|
||||
["权限", profile.permissions],
|
||||
[
|
||||
"资料同步时间",
|
||||
account.last_synced_at
|
||||
? formatTime(account.last_synced_at, account.timezone)
|
||||
: null,
|
||||
],
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="eyebrow">PERSONAL WORKSPACE</div>
|
||||
<h1>个人信息</h1>
|
||||
<p>管理账户连接和工作空间偏好。</p>
|
||||
</div>
|
||||
<Tag
|
||||
color={account.connection_status === "connected" ? "green" : "grey"}
|
||||
size="large"
|
||||
>
|
||||
{connectionLabels[account.connection_status] ??
|
||||
account.connection_status}
|
||||
</Tag>
|
||||
</div>
|
||||
{account.connection_error && (
|
||||
<Banner
|
||||
type={
|
||||
account.connection_status === "verification_required"
|
||||
? "warning"
|
||||
: "danger"
|
||||
}
|
||||
description={account.connection_error}
|
||||
/>
|
||||
)}
|
||||
{account.verification_url && (
|
||||
<div className="verification-callout">
|
||||
<strong>请完成 WorldQuant 人工验证</strong>
|
||||
<p>在新窗口完成平台验证后,回到这里继续。验证链接只属于当前会话。</p>
|
||||
<div className="inline-actions">
|
||||
<a href={account.verification_url} target="_blank" rel="noreferrer">
|
||||
<Button theme="solid">打开验证页面</Button>
|
||||
</a>
|
||||
<Button
|
||||
loading={busy === "verify"}
|
||||
onClick={() =>
|
||||
void action("verify", () => post("/account/verify"), true)
|
||||
}
|
||||
>
|
||||
已完成,继续验证
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="account-grid">
|
||||
<section className="panel">
|
||||
<div className="panel-title">
|
||||
<IconLink />
|
||||
<div>
|
||||
<h2>WorldQuant 连接</h2>
|
||||
<p>连接你的个人 BRAIN 账户</p>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void action("save", async () => {
|
||||
await api("/account/credentials", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
setPassword("");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
WorldQuant 邮箱
|
||||
<Input
|
||||
aria-label="WorldQuant 邮箱"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="name@example.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
WorldQuant 密码
|
||||
<Input
|
||||
aria-label="WorldQuant 密码"
|
||||
mode="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder={
|
||||
account.configured
|
||||
? "已保存 · 输入新密码可更新配置"
|
||||
: "输入你的平台密码"
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<div className="credential-note">
|
||||
<IconShield />
|
||||
密码加密存储,仅用于服务端连接平台。
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<Button
|
||||
htmlType="submit"
|
||||
disabled={!email || !password || Boolean(busy)}
|
||||
loading={busy === "save"}
|
||||
>
|
||||
保存凭据
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={!account.configured || Boolean(busy)}
|
||||
loading={busy === "connect"}
|
||||
onClick={() =>
|
||||
void action("connect", () => post("/account/connect"), true)
|
||||
}
|
||||
>
|
||||
{account.connection_status === "connected"
|
||||
? "重新连接"
|
||||
: "连接 WorldQuant"}
|
||||
</Button>
|
||||
<Button
|
||||
theme="borderless"
|
||||
disabled={
|
||||
Boolean(busy) || account.connection_status === "disconnected"
|
||||
}
|
||||
onClick={() =>
|
||||
void action("disconnect", () => post("/account/disconnect"))
|
||||
}
|
||||
>
|
||||
断开连接
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<div className="panel-title">
|
||||
<IconUser />
|
||||
<div>
|
||||
<h2>平台个人资料</h2>
|
||||
<p>展示最近一次成功获取的信息</p>
|
||||
</div>
|
||||
<Button
|
||||
theme="borderless"
|
||||
icon={<IconRefresh />}
|
||||
aria-label="刷新个人资料"
|
||||
disabled={!account.configured || Boolean(busy)}
|
||||
onClick={() =>
|
||||
void action("profile", () => post("/account/refresh"), true)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<dl className="profile-grid">
|
||||
{profileRows.map(([key, value]) => (
|
||||
<div key={String(key)}>
|
||||
<dt>{String(key)}</dt>
|
||||
<dd>{displayValue(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
<section className="panel full-width">
|
||||
<div className="panel-title">
|
||||
<div>
|
||||
<h2>工作空间偏好</h2>
|
||||
<p>只影响你的本地研究界面</p>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void action("preferences", () =>
|
||||
patch("/account/preferences", preferences),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="preferences-grid">
|
||||
<label>
|
||||
显示名称
|
||||
<Input
|
||||
aria-label="显示名称"
|
||||
value={preferences.display_name}
|
||||
onChange={(display_name) =>
|
||||
setPreferences({ ...preferences, display_name })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
主题
|
||||
<Select
|
||||
aria-label="主题"
|
||||
value={preferences.theme}
|
||||
optionList={[
|
||||
{ value: "light", label: "浅色" },
|
||||
{ value: "dark", label: "深色" },
|
||||
]}
|
||||
onChange={(theme) =>
|
||||
setPreferences({ ...preferences, theme: String(theme) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
时区
|
||||
<Input
|
||||
aria-label="时区"
|
||||
value={preferences.timezone}
|
||||
onChange={(timezone) =>
|
||||
setPreferences({ ...preferences, timezone })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
每页数量
|
||||
<Select
|
||||
aria-label="每页数量"
|
||||
value={preferences.page_size}
|
||||
optionList={[25, 50, 100].map((value) => ({
|
||||
value,
|
||||
label: `${value} 条`,
|
||||
}))}
|
||||
onChange={(page_size) =>
|
||||
setPreferences({
|
||||
...preferences,
|
||||
page_size: Number(page_size),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
theme="solid"
|
||||
loading={busy === "preferences"}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
保存偏好
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Popover,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
TextArea,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
IconSearch,
|
||||
IconRefresh,
|
||||
IconDownload,
|
||||
IconPlus,
|
||||
IconSetting,
|
||||
IconStar,
|
||||
} from "@douyinfe/semi-icons";
|
||||
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
||||
import {
|
||||
api,
|
||||
formatNumber,
|
||||
formatTime,
|
||||
patch,
|
||||
post,
|
||||
queryString,
|
||||
stateLabels,
|
||||
stateOptions,
|
||||
} from "../api";
|
||||
import type { Account, Alpha, AlphaPage as Page, Facets } from "../types";
|
||||
import { AlphaDetail } from "../components/AlphaDetail";
|
||||
|
||||
const metricLabels = {
|
||||
sharpe: "Sharpe",
|
||||
fitness: "Fitness",
|
||||
returns: "Returns",
|
||||
turnover: "Turnover",
|
||||
margin: "Margin",
|
||||
drawdown: "Drawdown",
|
||||
};
|
||||
const initialColumns = [
|
||||
"name",
|
||||
"expression",
|
||||
"region",
|
||||
"status",
|
||||
"sharpe",
|
||||
"fitness",
|
||||
"turnover",
|
||||
"research",
|
||||
];
|
||||
const columnLabels: Record<string, string> = {
|
||||
name: "Alpha",
|
||||
expression: "表达式",
|
||||
region: "地区 / Universe",
|
||||
status: "平台状态",
|
||||
sharpe: "Sharpe",
|
||||
fitness: "Fitness",
|
||||
returns: "Returns",
|
||||
turnover: "Turnover",
|
||||
margin: "Margin",
|
||||
drawdown: "Drawdown",
|
||||
research: "研究状态",
|
||||
tags: "本地标签",
|
||||
language: "语言",
|
||||
created: "创建时间",
|
||||
};
|
||||
|
||||
export function AlphaPage({
|
||||
account,
|
||||
taskPanelOpen,
|
||||
version,
|
||||
onTask,
|
||||
onAccount,
|
||||
}: {
|
||||
account: Account | null;
|
||||
taskPanelOpen: boolean;
|
||||
version: string;
|
||||
onTask: () => void;
|
||||
onAccount: () => void;
|
||||
}) {
|
||||
const [data, setData] = useState<Page>({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
});
|
||||
const [facets, setFacets] = useState<Facets>({});
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [sort, setSort] = useState("date_created");
|
||||
const [direction, setDirection] = useState("desc");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
|
||||
try {
|
||||
const stored: unknown = JSON.parse(
|
||||
localStorage.getItem("alpha-columns") || "null",
|
||||
);
|
||||
return Array.isArray(stored)
|
||||
? [
|
||||
...new Set([
|
||||
"name",
|
||||
...stored.filter(
|
||||
(v): v is string => typeof v === "string" && v in columnLabels,
|
||||
),
|
||||
]),
|
||||
]
|
||||
: initialColumns;
|
||||
} catch {
|
||||
return initialColumns;
|
||||
}
|
||||
});
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [idText, setIdText] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulk, setBulk] = useState({
|
||||
add_tags: "",
|
||||
remove_tags: "",
|
||||
state: "",
|
||||
});
|
||||
const [localVersion, setLocalVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
setPageSize(account.page_size);
|
||||
setPage(1);
|
||||
}
|
||||
}, [account?.page_size]);
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
...filters,
|
||||
sort,
|
||||
direction,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
}),
|
||||
[filters, sort, direction, pageSize, page],
|
||||
);
|
||||
const query = queryString(params);
|
||||
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
Promise.all([
|
||||
api<Page>(`/alphas?${query}`, { signal: controller.signal }),
|
||||
api<Facets>("/alphas/facets", { signal: controller.signal }),
|
||||
])
|
||||
.then(([results, nextFacets]) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setData(results);
|
||||
setFacets(nextFacets);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!controller.signal.aborted) setError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [query, version, localVersion]);
|
||||
useEffect(() => {
|
||||
setSelected([]);
|
||||
}, [query]);
|
||||
function updateDraft(key: string, value: unknown) {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
[key]: value === undefined || value === null ? "" : String(value),
|
||||
}));
|
||||
}
|
||||
function applyFilters() {
|
||||
setFilters({ ...draft });
|
||||
setPage(1);
|
||||
}
|
||||
function clearFilters() {
|
||||
setDraft({});
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
}
|
||||
async function newTask(kind: string, ids: string[] = []) {
|
||||
setBusy(kind);
|
||||
try {
|
||||
await post("/sync-jobs", { kind, alpha_ids: ids });
|
||||
setImporting(false);
|
||||
setIdText("");
|
||||
onTask();
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
async function bulkSave() {
|
||||
setBusy("bulk");
|
||||
const split = (value: string) =>
|
||||
value
|
||||
.split(/[,,]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
try {
|
||||
await patch("/alphas/research/bulk", {
|
||||
alpha_ids: selected,
|
||||
add_tags: split(bulk.add_tags),
|
||||
remove_tags: split(bulk.remove_tags),
|
||||
state: bulk.state || null,
|
||||
});
|
||||
setBulkOpen(false);
|
||||
setBulk({ add_tags: "", remove_tags: "", state: "" });
|
||||
refresh();
|
||||
Toast.success("批量研究记录已更新");
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
const columnDefinitions: ColumnProps<Alpha>[] = [
|
||||
{
|
||||
key: "name",
|
||||
title: "Alpha",
|
||||
dataIndex: "name",
|
||||
fixed: "left",
|
||||
width: 190,
|
||||
render: (_, row) => (
|
||||
<button className="alpha-link" onClick={() => setDetailId(row!.id)}>
|
||||
<span>
|
||||
{row!.research.favorite && <IconStar className="favorite-icon" />}
|
||||
{row!.name || row!.id}
|
||||
</span>
|
||||
<small>
|
||||
{row!.name ? row!.id : (row!.alpha_type ?? "未提供类型")}
|
||||
</small>
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "expression",
|
||||
title: "表达式",
|
||||
width: 260,
|
||||
render: (_, row) => (
|
||||
<code className="expression-cell" title={row!.expression_preview}>
|
||||
{row!.expression_preview || "—"}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
title: "地区 / Universe",
|
||||
width: 155,
|
||||
render: (_, row) => (
|
||||
<div className="stacked-cell">
|
||||
<span>{row!.region ?? "—"}</span>
|
||||
<small>{row!.universe ?? "—"}</small>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
title: "平台状态",
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<div className="stacked-cell">
|
||||
<span>{row!.status ?? "未提供"}</span>
|
||||
<small>
|
||||
{row!.stage ?? "—"}
|
||||
{row!.hidden ? " · 已隐藏" : ""}
|
||||
</small>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...Object.entries(metricLabels).map(
|
||||
([key, title]): ColumnProps<Alpha> => ({
|
||||
key,
|
||||
title,
|
||||
width: 112,
|
||||
align: "right",
|
||||
render: (_, row) => (
|
||||
<span className="numeric">
|
||||
{formatNumber(row![key as keyof Alpha], key === "margin" ? 6 : 3)}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
),
|
||||
{
|
||||
key: "research",
|
||||
fixed: "right",
|
||||
title: "研究状态",
|
||||
width: 115,
|
||||
render: (_, row) => (
|
||||
<Tag
|
||||
color={
|
||||
row!.research.state === "candidate"
|
||||
? "green"
|
||||
: row!.research.state === "optimizing"
|
||||
? "blue"
|
||||
: "grey"
|
||||
}
|
||||
>
|
||||
{stateLabels[row!.research.state]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
title: "本地标签",
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<div className="inline-tags">
|
||||
{row!.research.tags.map((t) => (
|
||||
<Tag key={t}>{t}</Tag>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "language", title: "语言", width: 125, dataIndex: "language" },
|
||||
{
|
||||
key: "created",
|
||||
title: "创建时间",
|
||||
width: 165,
|
||||
render: (_, row) => formatTime(row!.date_created, account?.timezone),
|
||||
},
|
||||
];
|
||||
const columns = columnDefinitions.filter((column) =>
|
||||
visibleColumns.includes(String(column.key)),
|
||||
);
|
||||
const activeFilterCount = Object.values(filters).filter(Boolean).length;
|
||||
const options = (key: string) =>
|
||||
(Array.isArray(facets[key]) ? (facets[key] as string[]) : []).map(
|
||||
(value) => ({ value, label: value }),
|
||||
);
|
||||
const connected =
|
||||
account?.connection_status === "connected" ||
|
||||
account?.connection_status === "expired";
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="eyebrow">ALPHA LIBRARY</div>
|
||||
<h1>Alpha 管理</h1>
|
||||
<p>集中查看平台信号,记录每一步研究判断。</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<Button icon={<IconPlus />} onClick={() => setImporting(true)}>
|
||||
导入 Alpha ID
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
icon={<IconRefresh />}
|
||||
loading={busy === "full_sync"}
|
||||
disabled={!connected}
|
||||
onClick={() => void newTask("full_sync")}
|
||||
>
|
||||
同步平台数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="library-stats">
|
||||
<div>
|
||||
<span>已同步 Alpha</span>
|
||||
<strong>{Number(facets.total ?? 0).toLocaleString()}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>我的收藏</span>
|
||||
<strong>{Number(facets.favorites ?? 0).toLocaleString()}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>覆盖地区</span>
|
||||
<strong>
|
||||
{Array.isArray(facets.region) ? facets.region.length : 0}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="last-sync">
|
||||
<span>最近数据更新</span>
|
||||
<strong>
|
||||
{formatTime(
|
||||
typeof facets.last_sync === "string" ? facets.last_sync : null,
|
||||
account?.timezone,
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
{!connected && (
|
||||
<Banner
|
||||
type="info"
|
||||
description={
|
||||
<span>
|
||||
连接 WorldQuant 后即可同步平台数据。
|
||||
<button className="text-link" onClick={onAccount}>
|
||||
前往个人信息
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
<section className="library-panel">
|
||||
<form
|
||||
className="filter-panel"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
applyFilters();
|
||||
}}
|
||||
>
|
||||
<div className="primary-filters">
|
||||
<Input
|
||||
aria-label="搜索 Alpha"
|
||||
prefix={<IconSearch />}
|
||||
placeholder="搜索 ID、名称或表达式"
|
||||
value={draft.q ?? ""}
|
||||
onChange={(v) => updateDraft("q", v)}
|
||||
showClear
|
||||
/>
|
||||
<Select
|
||||
aria-label="地区"
|
||||
placeholder="全部地区"
|
||||
showClear
|
||||
value={draft.region || undefined}
|
||||
optionList={options("region")}
|
||||
onChange={(v) => updateDraft("region", v)}
|
||||
/>
|
||||
<Select
|
||||
aria-label="平台状态"
|
||||
placeholder="全部平台状态"
|
||||
showClear
|
||||
value={draft.status || undefined}
|
||||
optionList={options("status")}
|
||||
onChange={(v) => updateDraft("status", v)}
|
||||
/>
|
||||
<Select
|
||||
aria-label="研究状态筛选"
|
||||
placeholder="全部研究状态"
|
||||
showClear
|
||||
value={draft.research_state || undefined}
|
||||
optionList={stateOptions}
|
||||
onChange={(v) => updateDraft("research_state", v)}
|
||||
/>
|
||||
<Button htmlType="submit" theme="solid">
|
||||
查询
|
||||
</Button>
|
||||
<Button theme="borderless" onClick={clearFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<details className="advanced-filters">
|
||||
<summary>
|
||||
更多筛选{" "}
|
||||
{activeFilterCount ? `· ${activeFilterCount} 项已应用` : ""}
|
||||
</summary>
|
||||
<div className="advanced-grid">
|
||||
{[
|
||||
["universe", "Universe"],
|
||||
["alpha_type", "Alpha 类型"],
|
||||
["language", "语言"],
|
||||
["stage", "IS / OS"],
|
||||
].map(([key, label]) => (
|
||||
<label key={key}>
|
||||
{label}
|
||||
<Select
|
||||
showClear
|
||||
aria-label={label}
|
||||
value={draft[key] || undefined}
|
||||
optionList={options(key)}
|
||||
placeholder="全部"
|
||||
onChange={(v) => updateDraft(key, v)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label>
|
||||
平台隐藏状态
|
||||
<Select
|
||||
showClear
|
||||
value={draft.hidden || undefined}
|
||||
optionList={[
|
||||
{ value: "false", label: "可见" },
|
||||
{ value: "true", label: "隐藏" },
|
||||
]}
|
||||
placeholder="全部"
|
||||
onChange={(v) => updateDraft("hidden", v)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
本地标签
|
||||
<Select
|
||||
showClear
|
||||
filter
|
||||
value={draft.tag || undefined}
|
||||
optionList={options("tags")}
|
||||
placeholder="全部标签"
|
||||
onChange={(v) => updateDraft("tag", v)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
创建时间起(UTC)
|
||||
<Input
|
||||
type="date"
|
||||
value={draft.created_from?.slice(0, 10) || ""}
|
||||
onChange={(v) =>
|
||||
updateDraft("created_from", v ? `${v}T00:00:00Z` : "")
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
创建时间止(UTC)
|
||||
<Input
|
||||
type="date"
|
||||
value={draft.created_to?.slice(0, 10) || ""}
|
||||
onChange={(v) =>
|
||||
updateDraft("created_to", v ? `${v}T23:59:59.999999Z` : "")
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{Object.entries(metricLabels).map(([key, label]) => (
|
||||
<label key={key}>
|
||||
{label}
|
||||
<div className="range-inputs">
|
||||
<Input
|
||||
type="number"
|
||||
aria-label={`${label} 最小值`}
|
||||
placeholder="最小值"
|
||||
value={draft[`${key}_min`] || ""}
|
||||
onChange={(v) => updateDraft(`${key}_min`, v)}
|
||||
/>
|
||||
<span>—</span>
|
||||
<Input
|
||||
type="number"
|
||||
aria-label={`${label} 最大值`}
|
||||
placeholder="最大值"
|
||||
value={draft[`${key}_max`] || ""}
|
||||
onChange={(v) => updateDraft(`${key}_max`, v)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
<Checkbox
|
||||
checked={draft.favorite === "true"}
|
||||
onChange={(e) =>
|
||||
updateDraft("favorite", e.target.checked ? "true" : "")
|
||||
}
|
||||
>
|
||||
只看收藏
|
||||
</Checkbox>
|
||||
</div>
|
||||
<p className="muted">
|
||||
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
|
||||
</p>
|
||||
</details>
|
||||
</form>
|
||||
<div className="table-toolbar">
|
||||
<div>
|
||||
<strong>全部 Alpha</strong>
|
||||
<span className="count-pill">{data.total.toLocaleString()}</span>
|
||||
{selected.length > 0 && (
|
||||
<span className="selection-text">
|
||||
已选择 {selected.length} 条
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<Button size="small" onClick={() => setBulkOpen(true)}>
|
||||
批量编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!connected}
|
||||
loading={busy === "alpha_refresh"}
|
||||
onClick={() => void newTask("alpha_refresh", selected)}
|
||||
>
|
||||
刷新选中
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Select
|
||||
aria-label="排序字段"
|
||||
size="small"
|
||||
value={sort}
|
||||
optionList={[
|
||||
{ value: "date_created", label: "创建时间" },
|
||||
{ value: "date_submitted", label: "提交时间" },
|
||||
{ value: "synced_at", label: "同步时间" },
|
||||
...Object.entries(metricLabels).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
{ value: "name", label: "名称" },
|
||||
]}
|
||||
onChange={(v) => {
|
||||
setSort(String(v));
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
aria-label="排序方向"
|
||||
size="small"
|
||||
value={direction}
|
||||
optionList={[
|
||||
{ value: "desc", label: "降序" },
|
||||
{ value: "asc", label: "升序" },
|
||||
]}
|
||||
onChange={(v) => {
|
||||
setDirection(String(v));
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<a href={`/api/v1/alphas/export?${query}`}>
|
||||
<Button icon={<IconDownload />} size="small">
|
||||
导出
|
||||
</Button>
|
||||
</a>
|
||||
<Popover
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
content={
|
||||
<div className="columns-picker">
|
||||
<strong>显示列</strong>
|
||||
<CheckboxGroup
|
||||
direction="vertical"
|
||||
value={visibleColumns}
|
||||
options={Object.entries(columnLabels).map(
|
||||
([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
disabled: value === "name",
|
||||
}),
|
||||
)}
|
||||
onChange={(values) => {
|
||||
const next = values.map(String);
|
||||
setVisibleColumns(next);
|
||||
localStorage.setItem(
|
||||
"alpha-columns",
|
||||
JSON.stringify(next),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
aria-label="显示列设置"
|
||||
icon={<IconSetting />}
|
||||
size="small"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<Table<Alpha>
|
||||
columns={columns}
|
||||
dataSource={data.items}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="middle"
|
||||
scroll={{
|
||||
y: "max(260px, calc(100vh - 670px))",
|
||||
x: columns.reduce((sum, c) => sum + Number(c.width || 120), 0) + 60,
|
||||
}}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected((keys ?? []).map(String)),
|
||||
fixed: true,
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: page,
|
||||
pageSize,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOpts: [25, 50, 100],
|
||||
onPageChange: (next) => setPage(next),
|
||||
onPageSizeChange: (size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
},
|
||||
}}
|
||||
empty={
|
||||
<Empty
|
||||
className="alpha-empty"
|
||||
image={<div className="empty-alpha">α</div>}
|
||||
title={
|
||||
activeFilterCount
|
||||
? "没有符合条件的 Alpha"
|
||||
: "从第一个 Alpha 开始"
|
||||
}
|
||||
description={
|
||||
activeFilterCount
|
||||
? "调整筛选条件,探索更多信号。"
|
||||
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。"
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
<p className="page-footnote">
|
||||
平台数据与本地研究记录独立保存 · 记录不因再次同步而覆盖
|
||||
</p>
|
||||
<Modal
|
||||
title="导入 Alpha ID"
|
||||
visible={importing}
|
||||
onCancel={() => setImporting(false)}
|
||||
confirmLoading={busy === "alpha_refresh"}
|
||||
okText="开始导入"
|
||||
onOk={() => {
|
||||
const ids = [...new Set(idText.split(/[\s,,]+/).filter(Boolean))];
|
||||
if (!ids.length || ids.length > 100) {
|
||||
Toast.warning("每次请输入 1–100 个 Alpha ID");
|
||||
return;
|
||||
}
|
||||
void newTask("alpha_refresh", ids);
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
读取指定 Alpha 的平台详情。每次最多 100 个,使用空格、逗号或换行分隔。
|
||||
</p>
|
||||
<TextArea
|
||||
aria-label="Alpha IDs"
|
||||
value={idText}
|
||||
onChange={setIdText}
|
||||
rows={6}
|
||||
placeholder="例如:aB12cD34"
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={`批量编辑 ${selected.length} 条研究记录`}
|
||||
visible={bulkOpen}
|
||||
onCancel={() => setBulkOpen(false)}
|
||||
confirmLoading={busy === "bulk"}
|
||||
onOk={() => void bulkSave()}
|
||||
okText="保存修改"
|
||||
>
|
||||
<label>
|
||||
添加标签
|
||||
<Input
|
||||
aria-label="批量添加标签"
|
||||
placeholder="用逗号分隔"
|
||||
value={bulk.add_tags}
|
||||
onChange={(add_tags) => setBulk({ ...bulk, add_tags })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
移除标签
|
||||
<Input
|
||||
aria-label="批量移除标签"
|
||||
value={bulk.remove_tags}
|
||||
onChange={(remove_tags) => setBulk({ ...bulk, remove_tags })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
研究状态
|
||||
<Select
|
||||
showClear
|
||||
placeholder="保持现有状态"
|
||||
value={bulk.state || undefined}
|
||||
optionList={stateOptions}
|
||||
onChange={(state) =>
|
||||
setBulk({ ...bulk, state: state ? String(state) : "" })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</Modal>
|
||||
<AlphaDetail
|
||||
taskPanelOpen={taskPanelOpen}
|
||||
id={detailId}
|
||||
version={`${version}:${localVersion}`}
|
||||
timezone={account?.timezone}
|
||||
onClose={() => setDetailId(null)}
|
||||
onSaved={refresh}
|
||||
onTask={onTask}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
:root {
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
color: #293346;
|
||||
background: #f5f6fa;
|
||||
font-synthesis: none;
|
||||
--accent: #6355d9;
|
||||
--surface: #fff;
|
||||
--canvas: #f5f6fa;
|
||||
--line: #e8ebf1;
|
||||
--ink: #293346;
|
||||
--muted: #81899a;
|
||||
--semi-color-primary: var(--accent);
|
||||
--semi-color-primary-hover: #7567e5;
|
||||
--semi-color-primary-active: #5143c5;
|
||||
--semi-color-primary-light-default: #efedfb;
|
||||
--semi-border-radius-small: 6px;
|
||||
--semi-border-radius-medium: 8px;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body[theme-mode] {
|
||||
--semi-color-primary: var(--accent);
|
||||
--semi-color-primary-hover: #7567e5;
|
||||
--semi-color-primary-active: #5143c5;
|
||||
--semi-color-primary-light-default: #efedfb;
|
||||
margin: 0;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
body[theme-mode="dark"] {
|
||||
--surface: #242630;
|
||||
--canvas: #191b24;
|
||||
--line: #343744;
|
||||
--ink: #e0e4ee;
|
||||
--muted: #9ba3b6;
|
||||
--semi-color-primary-light-default: #343046;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
letter-spacing: -0.7px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin: 24px 0 12px;
|
||||
}
|
||||
p {
|
||||
line-height: 1.7;
|
||||
}
|
||||
label:not(.semi-checkbox):not(.semi-radio) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.9px;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.screen-center {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.workspace {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
background: #191e30;
|
||||
color: #e5e6f3;
|
||||
width: 224px;
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
padding: 31px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 5;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.brand > div > span {
|
||||
display: block;
|
||||
font-size: 7px;
|
||||
letter-spacing: 1.8px;
|
||||
font-weight: 400;
|
||||
margin-top: 5px;
|
||||
color: #8f95b3;
|
||||
}
|
||||
.brand-mark {
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 30px;
|
||||
font-family: Georgia, serif;
|
||||
background: #6d60d9;
|
||||
color: white;
|
||||
line-height: 1;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.nav-label {
|
||||
font-size: 10px;
|
||||
color: #7e86a2;
|
||||
margin: 51px 15px 13px;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
gap: 13px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #a3abc4;
|
||||
padding: 13px 15px;
|
||||
margin-bottom: 7px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: #232a40;
|
||||
color: white;
|
||||
}
|
||||
.nav-item.active {
|
||||
background: #30304c;
|
||||
color: #c7bffe;
|
||||
}
|
||||
.sidebar-bottom {
|
||||
margin-top: auto;
|
||||
color: #717c99;
|
||||
font-size: 10px;
|
||||
padding: 20px 12px 0;
|
||||
line-height: 2;
|
||||
}
|
||||
.connection-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #a8b1c8;
|
||||
font-size: 11px;
|
||||
}
|
||||
.connection-line i {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
border-radius: 50%;
|
||||
background: #727d91;
|
||||
}
|
||||
.connection-line i.connected {
|
||||
background: #6cc1a0;
|
||||
}
|
||||
.main-shell {
|
||||
margin-left: 224px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.topbar {
|
||||
height: 72px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
padding: 0 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
.breadcrumbs {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.breadcrumbs span {
|
||||
margin: 0 13px;
|
||||
color: #c1c7d1;
|
||||
}
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
}
|
||||
.account-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
.top-divider {
|
||||
height: 21px;
|
||||
border-left: 1px solid var(--line);
|
||||
margin: 0 6px;
|
||||
}
|
||||
.page-content {
|
||||
padding: 36px;
|
||||
max-width: 1800px;
|
||||
margin: auto;
|
||||
}
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.page-heading p {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
.library-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1.6fr;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 24px;
|
||||
padding: 23px 0;
|
||||
}
|
||||
.library-stats > div {
|
||||
padding: 0 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.library-stats > div:last-child {
|
||||
border: 0;
|
||||
}
|
||||
.library-stats span {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.library-stats strong {
|
||||
font-size: 25px;
|
||||
font-weight: 550;
|
||||
letter-spacing: -0.6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.library-stats .last-sync strong {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
margin-top: 9px;
|
||||
font-weight: 450;
|
||||
}
|
||||
.library-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.filter-panel {
|
||||
padding: 21px 22px 17px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.primary-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.primary-filters > .semi-input-wrapper {
|
||||
flex: 2;
|
||||
min-width: 180px;
|
||||
}
|
||||
.primary-filters > .semi-select {
|
||||
flex: 1;
|
||||
min-width: 122px;
|
||||
}
|
||||
.advanced-filters {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.advanced-filters > summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
width: max-content;
|
||||
}
|
||||
.advanced-grid {
|
||||
padding-top: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 4px 16px;
|
||||
}
|
||||
.advanced-grid label {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.range-inputs {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
.range-inputs > span {
|
||||
color: var(--muted);
|
||||
}
|
||||
.table-toolbar {
|
||||
padding: 20px 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.table-toolbar strong {
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.table-toolbar .semi-select {
|
||||
min-width: 86px;
|
||||
}
|
||||
.count-pill {
|
||||
background: var(--canvas);
|
||||
padding: 3px 8px;
|
||||
margin-left: 9px;
|
||||
border-radius: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.selection-text {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
margin-left: 14px;
|
||||
}
|
||||
.columns-picker {
|
||||
padding: 18px;
|
||||
min-width: 190px;
|
||||
}
|
||||
.columns-picker > strong {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.alpha-link {
|
||||
border: 0;
|
||||
background: none;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
color: var(--ink);
|
||||
max-width: 165px;
|
||||
display: block;
|
||||
}
|
||||
.alpha-link > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
font-weight: 550;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.alpha-link > small,
|
||||
.stacked-cell > small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-top: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.favorite-icon {
|
||||
color: #d8aa45;
|
||||
}
|
||||
.stacked-cell {
|
||||
font-size: 12px;
|
||||
}
|
||||
.expression-cell {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.numeric {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
}
|
||||
.inline-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.semi-table-thead > .semi-table-row > .semi-table-row-head {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: var(--canvas);
|
||||
color: var(--muted);
|
||||
}
|
||||
.semi-table-row-cell {
|
||||
border-color: var(--line) !important;
|
||||
}
|
||||
.semi-table-pagination-outer {
|
||||
padding: 8px 22px;
|
||||
}
|
||||
.page-footnote {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin: 19px 0;
|
||||
}
|
||||
.semi-banner {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.text-link {
|
||||
color: var(--accent);
|
||||
font-size: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.alpha-empty {
|
||||
padding: 52px 18px 60px;
|
||||
}
|
||||
.empty-alpha {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 22px;
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--line);
|
||||
color: #c1beda;
|
||||
font:
|
||||
52px Georgia,
|
||||
serif;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 26px;
|
||||
}
|
||||
.panel.full-width {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 27px;
|
||||
}
|
||||
.panel-title > .semi-icon {
|
||||
color: var(--accent);
|
||||
background: var(--semi-color-primary-light-default);
|
||||
padding: 11px;
|
||||
box-sizing: content-box;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.panel-title > div {
|
||||
flex: 1;
|
||||
}
|
||||
.panel-title h2 {
|
||||
font-size: 15px;
|
||||
margin: 0 0 3px;
|
||||
}
|
||||
.panel-title p {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
.credential-note {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
margin: -2px 0 24px;
|
||||
}
|
||||
.profile-grid {
|
||||
margin: 0;
|
||||
}
|
||||
.profile-grid > div {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
gap: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.profile-grid > div:last-child {
|
||||
border: 0;
|
||||
}
|
||||
.profile-grid dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
.profile-grid dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.preferences-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
.verification-callout {
|
||||
background: var(--surface);
|
||||
border: 1px solid #e7d2a0;
|
||||
border-radius: 9px;
|
||||
padding: 22px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.verification-callout p {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 7px 0 15px;
|
||||
}
|
||||
.job-card {
|
||||
border: 1px solid var(--line);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin: 18px 0;
|
||||
}
|
||||
.job-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.job-header strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.job-card p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.job-numbers {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin: 16px 0;
|
||||
}
|
||||
.job-numbers b {
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.job-card .inline-actions {
|
||||
margin-top: 17px;
|
||||
}
|
||||
.error-text {
|
||||
color: #d35968;
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.detail-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 25px;
|
||||
align-items: center;
|
||||
}
|
||||
.detail-title h2 {
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.detail-section {
|
||||
padding: 18px 0;
|
||||
}
|
||||
.detail-section .semi-descriptions {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.detail-section .semi-descriptions-item {
|
||||
font-size: 12px;
|
||||
}
|
||||
.code-block {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--canvas);
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
}
|
||||
.check-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
align-items: center;
|
||||
}
|
||||
.check-row > span:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
.detail-save {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.section-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.section-toolbar h3 {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.pnl-chart {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 350px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.pnl-chart text {
|
||||
font: 10px system-ui;
|
||||
fill: var(--muted);
|
||||
}
|
||||
.chart-empty {
|
||||
padding: 65px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.login-story {
|
||||
background: #191e30;
|
||||
color: #e5e6f3;
|
||||
padding: 48px 64px;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.login-story:after {
|
||||
content: "α";
|
||||
position: absolute;
|
||||
bottom: 45px;
|
||||
right: -60px;
|
||||
font:
|
||||
600px Georgia,
|
||||
serif;
|
||||
color: #ffffff03;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.story-copy {
|
||||
margin: auto 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.story-copy .eyebrow {
|
||||
color: #8f89c9;
|
||||
font-size: 10px;
|
||||
letter-spacing: 2.5px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.story-copy h1 {
|
||||
font-size: 42px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 550;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.story-copy p {
|
||||
color: #949db6;
|
||||
font-size: 14px;
|
||||
line-height: 2.1;
|
||||
}
|
||||
.story-signal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
color: #aaa1eb;
|
||||
margin-top: 40px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.login-footnote {
|
||||
font-size: 10px;
|
||||
color: #747e9b;
|
||||
}
|
||||
.login-form-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 48px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.login-form {
|
||||
max-width: 365px;
|
||||
width: 100%;
|
||||
}
|
||||
.login-form h2 {
|
||||
font-size: 26px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.login-form > p {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 33px;
|
||||
}
|
||||
.login-form label {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.login-form label > .semi-input-wrapper {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.login-note {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.semi-sidesheet-inner {
|
||||
max-width: 100vw;
|
||||
}
|
||||
.semi-tabs-content {
|
||||
padding-top: 10px;
|
||||
}
|
||||
@media (min-width: 1500px) {
|
||||
.page-content {
|
||||
padding: 42px 48px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.sidebar {
|
||||
width: 190px;
|
||||
padding: 26px 14px;
|
||||
}
|
||||
.main-shell {
|
||||
margin-left: 190px;
|
||||
}
|
||||
.page-content {
|
||||
padding: 26px 22px;
|
||||
}
|
||||
.topbar {
|
||||
padding: 0 22px;
|
||||
}
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.library-stats > div {
|
||||
padding: 0 18px;
|
||||
}
|
||||
.advanced-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.preferences-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.login-story {
|
||||
padding: 40px;
|
||||
}
|
||||
.story-copy h1 {
|
||||
font-size: 34px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.sidebar {
|
||||
width: 64px;
|
||||
padding: 23px 8px;
|
||||
}
|
||||
.sidebar .brand > div,
|
||||
.nav-label,
|
||||
.sidebar-bottom {
|
||||
display: none;
|
||||
}
|
||||
.sidebar .brand {
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.nav-item {
|
||||
font-size: 0;
|
||||
padding: 13px;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav-item .semi-icon {
|
||||
font-size: 19px;
|
||||
}
|
||||
.main-shell {
|
||||
margin-left: 64px;
|
||||
}
|
||||
.page-content {
|
||||
padding: 22px 16px;
|
||||
}
|
||||
.topbar {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.account-name,
|
||||
.breadcrumbs {
|
||||
display: none;
|
||||
}
|
||||
.top-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.page-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
.library-stats {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
row-gap: 22px;
|
||||
}
|
||||
.library-stats > div:nth-child(2) {
|
||||
border: 0;
|
||||
}
|
||||
.library-stats .last-sync strong {
|
||||
font-size: 11px;
|
||||
}
|
||||
.primary-filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.primary-filters > .semi-input-wrapper {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.primary-filters > .semi-select {
|
||||
min-width: 0;
|
||||
}
|
||||
.advanced-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.account-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.preferences-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.login-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.login-story {
|
||||
display: none;
|
||||
}
|
||||
.login-form-shell {
|
||||
min-height: 100vh;
|
||||
padding: 30px;
|
||||
}
|
||||
.table-toolbar {
|
||||
padding: 16px;
|
||||
}
|
||||
.table-toolbar > .inline-actions {
|
||||
gap: 5px;
|
||||
}
|
||||
.profile-grid > div {
|
||||
grid-template-columns: 80px 1fr;
|
||||
}
|
||||
.detail-title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export type ResearchState = "inbox" | "candidate" | "optimizing" | "archived";
|
||||
export type Research = {
|
||||
note: string;
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
state: ResearchState;
|
||||
updated_at?: string;
|
||||
};
|
||||
export type Alpha = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
expression_preview: string;
|
||||
alpha_type: string | null;
|
||||
language: string | null;
|
||||
stage: string | null;
|
||||
status: string | null;
|
||||
hidden: boolean;
|
||||
region: string | null;
|
||||
universe: string | null;
|
||||
sharpe: number | null;
|
||||
fitness: number | null;
|
||||
returns: number | null;
|
||||
turnover: number | null;
|
||||
margin: number | null;
|
||||
drawdown: number | null;
|
||||
date_created: string | null;
|
||||
date_submitted: string | null;
|
||||
synced_at: string;
|
||||
research: Research;
|
||||
};
|
||||
export type AlphaDetail = Alpha & {
|
||||
expression: string | null;
|
||||
selection: string | null;
|
||||
combo: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
is_metrics: Record<string, unknown>;
|
||||
os_metrics: Record<string, unknown>;
|
||||
checks: Record<string, unknown>[];
|
||||
};
|
||||
export type Account = {
|
||||
email: string | null;
|
||||
configured: boolean;
|
||||
wq_user_id: string | null;
|
||||
profile: Record<string, unknown>;
|
||||
connection_status: string;
|
||||
connection_error: string | null;
|
||||
verification_url: string | null;
|
||||
last_synced_at: string | null;
|
||||
display_name: string;
|
||||
theme: "light" | "dark";
|
||||
timezone: string;
|
||||
page_size: number;
|
||||
};
|
||||
export type Job = {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
processed: number;
|
||||
failed: number;
|
||||
total: number | null;
|
||||
error: string | null;
|
||||
next_retry_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
export type AlphaPage = {
|
||||
items: Alpha[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
export type Pnl = {
|
||||
cached: boolean;
|
||||
points: { date: string; value: number | null }[];
|
||||
fetched_at: string | null;
|
||||
};
|
||||
export type Facets = Record<string, string[] | number | string | null>;
|
||||
Reference in New Issue
Block a user