fix: restore polished account experience and align AI with Lark design

This commit is contained in:
yuxuanhui
2026-09-07 23:31:23 +08:00
parent 3f3d417296
commit 7dcf46836f
23 changed files with 1669 additions and 807 deletions
+34 -57
View File
@@ -9,14 +9,6 @@ import {
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";
@@ -162,28 +154,25 @@ export default function App() {
/>
) : (
<div className="workspace">
<aside className="sidebar">
<aside className="sidebar" inert={chatOpen && viewport < 1440}>
<div className="brand">
<span className="brand-mark">α</span>
<div>
ALPHA<span>RESEARCH WORKSPACE</span>
</div>
<div>Alpha 研究</div>
</div>
<div className="nav-label">工作空间</div>
<button
aria-label="Alpha 管理"
className={`nav-item ${page === "alphas" ? "active" : ""}`}
aria-current={page === "alphas" ? "page" : undefined}
onClick={() => changePage("alphas")}
>
<IconGridView size="large" />
Alpha 管理
</button>
<button
aria-label="个人信息"
className={`nav-item ${page === "account" ? "active" : ""}`}
aria-current={page === "account" ? "page" : undefined}
onClick={() => changePage("account")}
>
<IconUser size="large" />
个人信息
</button>
<div className="sidebar-bottom">
@@ -199,20 +188,16 @@ export default function App() {
? "WorldQuant 已连接"
: "WorldQuant 未连接"}
</div>
<span>个人研究空间 · v0.1</span>
</div>
</aside>
<div className="main-shell">
<div className="main-shell" inert={chatOpen && viewport < 1440}>
<header className="topbar">
<div className="breadcrumbs">
工作空间 <span>/</span>{" "}
{page === "alphas" ? "Alpha 管理" : "个人信息"}
</div>
<div className="breadcrumbs">研究工作空间</div>
<div className="top-actions">
<Badge count={pending}>
<Button
type="tertiary"
theme="borderless"
icon={<IconBell />}
onClick={() => {
focusBusiness();
setShowJobs(true);
@@ -223,27 +208,34 @@ export default function App() {
</Badge>
<Button
aria-label="切换研究助手"
aria-expanded={chatOpen}
aria-controls="research-assistant"
type="tertiary"
theme={chatOpen ? "light" : "borderless"}
onClick={() => setChatOpen(!chatOpen)}
>
AI 助手
</Button>
<div className="top-divider" />
<Avatar size="small" color="purple">
<Avatar size="small" color="grey">
{account?.display_name.slice(0, 1) || "研"}
</Avatar>
<span className="account-name">
{account?.display_name ?? "研究员"}
</span>
<Button
type="tertiary"
aria-label="退出登录"
theme="borderless"
icon={<IconExit />}
onClick={() => void logout()}
/>
>
退出
</Button>
</div>
</header>
<main className="page-content">
<main
className={`page-content ${page === "alphas" ? "bounded-page" : "account-page"}`}
>
{pollError && (
<Banner
type="warning"
@@ -257,7 +249,7 @@ export default function App() {
onTask={taskCreated}
/>
</div>
<div hidden={page !== "alphas"}>
<div className="alpha-page-view" hidden={page !== "alphas"}>
<AlphaPage
taskPanelOpen={showJobs}
account={account}
@@ -292,12 +284,20 @@ export default function App() {
<Button
className="ai-launcher"
aria-label="打开研究助手"
theme="solid"
theme="light"
type="tertiary"
onClick={() => setChatOpen(true)}
>
AI 研究助手
</Button>
)}
{chatOpen && viewport < 1440 && (
<div
className="ai-mask"
aria-hidden="true"
onClick={() => setChatOpen(false)}
/>
)}
<ChatPanel
jobs={jobs}
open={chatOpen}
@@ -309,6 +309,11 @@ export default function App() {
onSettings={() => {
focusBusiness();
changePage("account");
requestAnimationFrame(() =>
document
.getElementById("model-settings")
?.scrollIntoView({ block: "start" }),
);
}}
onChanged={actionDone}
onAction={(action) => {
@@ -344,35 +349,9 @@ function Login({ onLogin }: { onLogin: () => void }) {
}
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>
<div className="login-brand">Alpha 研究</div>
<h2>登录研究工作空间</h2>
<p>使用部署时设置的系统账户登录。</p>
{error && <Banner type="danger" description={error} />}
@@ -403,8 +382,6 @@ function Login({ onLogin }: { onLogin: () => void }) {
size="large"
block
loading={busy}
icon={<IconArrowRight />}
iconPosition="right"
>
进入工作空间
</Button>
+56 -26
View File
@@ -188,7 +188,12 @@ export function ChatPanel({
useEffect(() => {
if (!open) return;
previousFocus.current = document.activeElement as HTMLElement;
input.current?.querySelector("textarea")?.focus();
const textarea = input.current?.querySelector("textarea");
if (textarea && !textarea.disabled) textarea.focus();
else
root.current
?.querySelector<HTMLButtonElement>('button[aria-label="收起研究助手"]')
?.focus();
return () => {
requestAnimationFrame(() => {
if (
@@ -265,6 +270,7 @@ export function ChatPanel({
ref={root}
hidden={!open}
className="ai-chat"
id="research-assistant"
aria-label="AI 研究助手"
onKeyDown={(event) => {
if (event.key === "Escape") {
@@ -314,11 +320,13 @@ export function ChatPanel({
}}
/>
<header className="ai-header">
<div>
<strong>AI 研究助手</strong>
<span>与你一起查看、分析和记录</span>
</div>
<Button aria-label="收起研究助手" theme="borderless" onClick={onClose}>
<h2>AI 研究助手</h2>
<Button
type="tertiary"
aria-label="收起研究助手"
theme="borderless"
onClick={onClose}
>
收起
</Button>
</header>
@@ -337,7 +345,11 @@ export function ChatPanel({
}}
disabled={loading}
/>
<Button onClick={() => void createConversation()} disabled={loading}>
<Button
type="tertiary"
onClick={() => void createConversation()}
disabled={loading}
>
新会话
</Button>
</div>
@@ -359,10 +371,11 @@ export function ChatPanel({
{loading && <Spin />}
{!chat.messages.length && (
<div className="ai-empty">
<strong>从当前研究出发</strong>
<h3>从当前研究出发</h3>
<p>可以让我筛选 Alpha、解释已有指标,或提出研究记录修改。</p>
{!conversationId && (
<Button
type="tertiary"
onClick={() => void createConversation()}
disabled={loading}
>
@@ -451,7 +464,9 @@ export function ChatPanel({
<div className="ai-send">
<small>Enter 发送 · Shift + Enter 换行</small>
{activeRun ? (
<Button onClick={() => void stop()}>停止生成</Button>
<Button type="tertiary" onClick={() => void stop()}>
停止生成
</Button>
) : (
<Button
theme="solid"
@@ -513,7 +528,7 @@ function BusinessCard({
const openAlpha = (id: string) =>
onAction({ type: "open_alpha", alpha_id: id, nonce: Date.now() });
return (
<section className="ai-tool-card">
<section className="ai-tool-card" data-status={call.status}>
<div className="ai-card-title">
<strong>{toolLabels[call.name] ?? "业务操作"}</strong>
<Tag
@@ -546,8 +561,14 @@ function BusinessCard({
}[key]
}
</strong>
<del>{researchValue(key, target.before[key])}</del>
<ins>{researchValue(key, target.after[key])}</ins>
<del>
<span>修改前</span>
{researchValue(key, target.before[key])}
</del>
<ins>
<span>修改后</span>
{researchValue(key, target.after[key])}
</ins>
</div>
))}
</details>
@@ -569,20 +590,29 @@ function BusinessCard({
</p>
)}
{pending && (
<div className="inline-actions">
<Button
theme="solid"
disabled={disabled}
onClick={() => void onDecision(call.id, true)}
>
确认执行
</Button>
<Button
disabled={disabled}
onClick={() => void onDecision(call.id, false)}
>
拒绝
</Button>
<div className="ai-approval">
<p className="muted">
{call.preview.targets?.length
? `将修改 ${call.preview.targets.length} 条研究记录。`
: "将执行以上任务操作。"}
确认后执行。
</p>
<div className="inline-actions">
<Button
theme="solid"
disabled={disabled}
onClick={() => void onDecision(call.id, true)}
>
确认执行
</Button>
<Button
type="tertiary"
disabled={disabled}
onClick={() => void onDecision(call.id, false)}
>
拒绝
</Button>
</div>
</div>
)}
{typeof result.error === "string" && (
+13 -7
View File
@@ -80,12 +80,13 @@ export function ModelSettingsPanel() {
}
}
return (
<section className="panel full-width ai-settings">
<div className="panel-title">
<div>
<h2>大模型服务</h2>
<p>配置你的 OpenAI 兼容服务,供研究助手使用。</p>
</div>
<section
className="account-section full-width ai-settings"
id="model-settings"
aria-labelledby="model-settings-title"
>
<div className="section-heading">
<h2 id="model-settings-title">大模型服务</h2>
<Tag color={saved?.enabled && saved.ready ? "green" : "grey"}>
{saved?.enabled && saved.ready ? "已启用" : "未启用"}
</Tag>
@@ -167,6 +168,7 @@ export function ModelSettingsPanel() {
保存模型配置
</Button>
<Button
type="tertiary"
loading={busy === "test"}
disabled={!!busy || changed || !saved?.configured}
onClick={() => void test()}
@@ -177,7 +179,11 @@ export function ModelSettingsPanel() {
<p className="muted">
测试连接会向已保存的服务发送少量合成请求,按供应商规则计费。
</p>
<div className="ai-test-results">
<div
className="ai-test-results"
role="status"
aria-label="模型能力测试结果"
>
{Object.entries(saved?.test_results ?? {}).map(([name, result]) => (
<div key={name}>
<Tag color={result.ok ? "green" : "red"}>
+184 -83
View File
@@ -4,6 +4,18 @@
.workspace {
padding-right: var(--chat-space, 0px);
}
/* Page mounts stay alive for drafts without breaking the bounded table layout. */
.alpha-page-view {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.main-shell {
container-type: inline-size;
}
.ai-chat {
position: fixed;
inset: 0 0 0 auto;
@@ -13,162 +25,215 @@
flex-direction: column;
background: var(--surface);
border-left: 1px solid var(--line);
box-shadow: -8px 0 32px #151b2c0b;
color: var(--ink);
}
.ai-mask {
position: fixed;
inset: 0;
z-index: 1000;
background: #1f232926;
}
.ai-header {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
padding: 22px 20px 16px;
height: 56px;
padding: 0 var(--space-4);
border-bottom: 1px solid var(--line);
}
.ai-header strong {
font-size: 17px;
}
.ai-header span {
display: block;
font-size: 11px;
color: var(--muted);
margin-top: 6px;
.ai-header h2 {
font-size: 16px;
font-weight: 500;
}
.ai-conversations {
display: flex;
gap: 8px;
padding: 14px 16px;
flex-shrink: 0;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
}
.ai-conversations .semi-select {
flex: 1;
min-width: 0;
}
.ai-chat > .semi-banner {
margin: 0 var(--space-4) var(--space-3);
}
.ai-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px 18px 24px;
padding: var(--space-3) var(--space-4) var(--space-4);
}
.ai-message {
margin: 0 0 24px;
margin-bottom: 24px;
overflow-wrap: anywhere;
}
.ai-role {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 8px;
font-size: 12px;
line-height: 20px;
margin-bottom: var(--space-2);
}
.ai-text {
white-space: pre-wrap;
line-height: 1.8;
line-height: 24px;
font-weight: 400;
}
.ai-message.user .ai-text {
background: var(--semi-color-primary-light-default);
padding: 12px 14px;
border-radius: 10px;
background: var(--canvas);
padding: var(--space-3);
border-radius: 8px;
}
.ai-empty {
margin: 48px 8px;
margin: 32px 0;
color: var(--muted);
line-height: 1.8;
line-height: 24px;
}
.ai-empty strong {
display: block;
.ai-empty h3 {
color: var(--ink);
font-size: 18px;
margin-bottom: 10px;
font-size: 16px;
font-weight: 500;
margin: 0 0 var(--space-2);
}
.ai-empty .semi-button {
margin-top: var(--space-3);
}
.ai-empty span {
display: block;
font-size: 12px;
margin-top: 16px;
line-height: 20px;
margin-top: var(--space-4);
}
.ai-composer {
padding: 14px 16px;
flex-shrink: 0;
padding: var(--space-3) var(--space-4) var(--space-4);
border-top: 1px solid var(--line);
background: var(--surface);
}
.ai-context {
color: var(--muted);
font-size: 11px;
margin-bottom: 10px;
font-size: 12px;
line-height: 20px;
margin-bottom: var(--space-2);
overflow-wrap: anywhere;
}
.ai-send {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 10px;
gap: var(--space-2);
margin-top: var(--space-2);
}
.ai-send small,
.ai-tool-card small,
.ai-run-state small {
color: var(--muted);
font-size: 11px;
font-size: 12px;
line-height: 20px;
font-weight: 400;
}
.ai-send .semi-button {
flex-shrink: 0;
}
.ai-run-state small,
.ai-tool-card small {
display: block;
margin-top: 6px;
margin-top: var(--space-1);
overflow-wrap: anywhere;
}
.ai-tool-card {
border: 1px solid var(--line);
border-radius: 9px;
padding: 12px;
margin: 12px 0;
font-size: 12px;
border-radius: 8px;
padding: var(--space-3);
margin: var(--space-3) 0;
font-size: 13px;
line-height: 20px;
}
.ai-card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.ai-card-title strong {
font-size: 14px;
font-weight: 500;
}
.ai-tool-card p {
margin: 10px 0;
margin: var(--space-2) 0;
}
.ai-tool-card summary {
cursor: pointer;
padding: 8px 0;
padding: var(--space-2) 0;
}
.ai-approval {
border-top: 1px solid var(--line);
padding-top: var(--space-2);
margin-top: var(--space-3);
}
.ai-diff {
display: grid;
gap: 6px;
margin: 8px 0 16px;
gap: var(--space-2);
margin: var(--space-2) 0 var(--space-3);
}
.ai-diff strong {
font-weight: 400;
color: var(--muted);
}
.ai-diff del,
.ai-diff ins {
display: block;
padding: 8px;
padding: var(--space-2) var(--space-3);
white-space: pre-wrap;
border-radius: 4px;
text-decoration: none;
}
.ai-diff del > span,
.ai-diff ins > span {
display: block;
color: var(--muted);
font-size: 12px;
margin-bottom: var(--space-1);
}
.ai-diff del {
background: #f5636310;
text-decoration: none;
border-left: 2px solid #c35757;
background: var(--canvas);
}
.ai-diff ins {
background: #32925c10;
text-decoration: none;
border-left: 2px solid #32925c;
background: var(--semi-color-success-light-default);
}
.ai-alpha-results > div {
padding: 10px 0;
padding: var(--space-3) 0;
border-top: 1px solid var(--line);
}
.ai-alpha-results .text-link {
margin-left: 0;
padding: 0;
text-align: left;
overflow-wrap: anywhere;
}
.ai-alpha-results table {
width: 100%;
text-align: left;
margin-top: 10px;
margin-top: var(--space-2);
border-collapse: collapse;
table-layout: fixed;
font-variant-numeric: tabular-nums;
}
.ai-alpha-results th,
.ai-alpha-results td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 400;
padding: var(--space-1) 0;
}
.ai-alpha-results th {
color: var(--muted);
font-weight: 400;
font-size: 10px;
padding-bottom: 4px;
font-size: 12px;
}
.ai-resize {
position: absolute;
@@ -184,30 +249,77 @@
background: var(--accent);
opacity: 0.3;
}
.ai-settings {
scroll-margin-top: var(--space-4);
}
.ai-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 24px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0 var(--space-4);
}
.ai-settings form > .muted {
margin: var(--space-2) 0 var(--space-3);
}
.ai-settings .inline-actions {
margin-top: 14px;
margin-top: var(--space-3);
}
.ai-settings .panel-title {
.ai-test-results {
display: grid;
gap: var(--space-2);
font-size: 13px;
}
.ai-test-results > div {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: baseline;
gap: var(--space-2);
}
.text-link {
border: none;
background: transparent;
color: var(--accent);
padding: 0 3px;
cursor: pointer;
.ai-test-results .semi-tag {
flex-shrink: 0;
}
@media (max-width: 1439px) {
.ai-chat {
width: min(var(--chat-width, 420px), 100vw);
box-shadow: -30px 0 120px #151b2c30;
.ai-launcher {
position: fixed;
right: var(--space-4);
bottom: 64px;
z-index: 1002;
border: 1px solid var(--line);
background: var(--surface);
box-shadow: 0 4px 12px #1f232914;
}
/* Reacting to available business width also covers an expanded 640px chat rail. */
@container (max-width: 900px) {
.account-grid,
.profile-grid,
.permission-list {
grid-template-columns: 1fr;
}
.preferences-grid,
.advanced-grid {
grid-template-columns: 1fr 1fr;
}
.preferences-form {
flex-wrap: wrap;
}
.library-stats .last-sync {
display: none;
}
}
@container (max-width: 600px) {
.ai-settings-grid {
grid-template-columns: 1fr;
}
.account-counts {
grid-template-columns: 1fr 1fr;
row-gap: var(--space-3);
}
.account-counts > div:nth-child(2) {
border: 0;
}
.account-name,
.breadcrumbs {
display: none;
}
.top-actions {
margin-left: auto;
}
}
@media (max-width: 640px) {
@@ -217,15 +329,4 @@
.ai-resize {
display: none;
}
.ai-settings-grid {
grid-template-columns: 1fr;
}
}
.ai-launcher {
position: fixed;
right: 20px;
bottom: 24px;
z-index: 1002;
box-shadow: 0 4px 18px #29213e24;
}
+302 -100
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import {
Avatar,
Banner,
Button,
Input,
@@ -8,14 +9,8 @@ import {
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";
import type { Account, AccountUsage } from "../types";
import { ModelSettingsPanel } from "../ai/ModelSettingsPanel";
const connectionLabels: Record<string, string> = {
@@ -26,6 +21,28 @@ const connectionLabels: Record<string, string> = {
verification_required: "需要人工验证",
error: "连接异常",
};
const permissionLabels: Record<string, string> = {
BEFORE_AND_AFTER_PERFORMANCE_V2: "提交前后表现",
BRAIN_LABS: "BRAIN Labs",
BRAIN_LABS_JUPYTER_LAB: "Jupyter Lab",
CONSULTANT: "顾问权限",
MULTI_SIMULATION: "批量回测",
PROD_ALPHAS: "生产 Alpha",
REFERRAL: "推荐邀请",
SUPER_ALPHA: "Super Alpha",
VISUALIZATION: "数据可视化",
WORKDAY: "Workday",
};
const count = (value: unknown) =>
typeof value === "number" ? value.toLocaleString("zh-CN") : "未提供";
const duration = (seconds: number | null | undefined) => {
if (seconds == null) return "未提供";
if (seconds <= 0) return "已到期";
const minutes = Math.ceil(seconds / 60);
return minutes >= 60
? `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟`
: `${minutes} 分钟`;
};
export function AccountPage({
account,
@@ -39,6 +56,7 @@ export function AccountPage({
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
const [preferences, setPreferences] = useState({
display_name: "研究员",
theme: "light",
@@ -62,7 +80,13 @@ export function AccountPage({
account?.timezone,
account?.page_size,
]);
if (!account) return <Spin size="large" />;
if (!account)
return (
<div className="screen-center">
<Spin />
</div>
);
async function action(key: string, fn: () => Promise<unknown>, task = false) {
setBusy(key);
try {
@@ -70,7 +94,13 @@ export function AccountPage({
if (task) onTask();
else {
onChange();
Toast.success("已保存");
Toast.success(
key === "profile"
? "正在刷新资料"
: key === "disconnect"
? "已断开连接"
: "已保存",
);
}
} catch (e) {
Toast.error((e as Error).message);
@@ -79,34 +109,80 @@ export function AccountPage({
}
}
const profile = account.profile;
const profileRows = [
const name = displayValue(
profile.fullName ??
profile.name ??
profile.username ??
[profile.firstName, profile.lastName].filter(Boolean).join(" "),
);
const permissions = Array.isArray(profile.permissions)
? profile.permissions.filter((p): p is string => typeof p === "string")
: null;
const usage = profile.usage as AccountUsage | undefined;
const connected =
account.connection_status === "connected" && account.session.authenticated;
const connecting = account.connection_status === "connecting";
const blocked = Boolean(busy) || connecting;
const profileRows: [string, unknown][] = [
["用户 ID", account.wq_user_id],
["昵称", profile.name ?? profile.username ?? profile.firstName],
["平台邮箱", profile.email ?? account.email],
["身份", profile.role ?? profile.roles ?? profile.type],
["权限", profile.permissions],
["账户等级", profile.level ?? profile.role ?? profile.type],
["Genius 等级", profile.geniusLevel],
[
"资料同步时间",
account.last_synced_at
? formatTime(account.last_synced_at, account.timezone)
"邮箱验证",
typeof profile.verified === "boolean"
? profile.verified
? "已验证"
: "未验证"
: null,
],
[
"账户审批",
typeof profile.approved === "boolean"
? profile.approved
? "已通过"
: "未通过"
: null,
],
[
"注册时间",
typeof profile.dateCreated === "string"
? formatTime(profile.dateCreated, account.timezone)
: null,
],
[
"顾问身份",
permissions
? permissions.includes("CONSULTANT")
? "顾问"
: "无顾问权限"
: null,
],
];
return (
<>
<div className="page-heading">
<div>
<div className="eyebrow">PERSONAL WORKSPACE</div>
<h1>个人信息</h1>
<p>管理账户连接和工作空间偏好。</p>
<h1>个人信息</h1>
<div className="inline-actions">
<Button
type="tertiary"
disabled={!account.configured || blocked}
loading={busy === "profile"}
aria-label="刷新个人资料"
onClick={() =>
void action("profile", () => post("/account/refresh"))
}
>
刷新资料
</Button>
<Button
type="tertiary"
aria-expanded={settingsOpen || !account.configured}
onClick={() => setSettingsOpen(!settingsOpen)}
>
连接设置
</Button>
</div>
<Tag
color={account.connection_status === "connected" ? "green" : "grey"}
size="large"
>
{connectionLabels[account.connection_status] ??
account.connection_status}
</Tag>
</div>
{account.connection_error && (
<Banner
@@ -120,13 +196,19 @@ export function AccountPage({
)}
{account.verification_url && (
<div className="verification-callout">
<strong>请完成 WorldQuant 人工验证</strong>
<p>在新窗口完成平台验证后,回到这里继续。验证链接只属于当前会话。</p>
<span>在 WorldQuant 完成人工验证后,返回继续。</span>
<div className="inline-actions">
<a href={account.verification_url} target="_blank" rel="noreferrer">
<Button theme="solid">打开验证页面</Button>
<a
className="text-link"
href={account.verification_url}
target="_blank"
rel="noreferrer"
>
打开验证页面
</a>
<Button
theme="solid"
disabled={blocked}
loading={busy === "verify"}
onClick={() =>
void action("verify", () => post("/account/verify"), true)
@@ -137,16 +219,45 @@ export function AccountPage({
</div>
</div>
)}
<div className="account-grid">
<ModelSettingsPanel />
<section className="panel">
<div className="panel-title">
<IconLink />
<div>
<h2>WorldQuant 连接</h2>
<p>连接你的个人 BRAIN 账户</p>
</div>
</div>
<section className="account-identity" aria-label="平台账户">
<Avatar size="default" color="grey">
{name === "未提供"
? account.display_name.slice(0, 1)
: name.slice(0, 1)}
</Avatar>
<div className="identity-name">
<h2>{name === "未提供" ? account.display_name : name}</h2>
<span>{account.wq_user_id ?? "尚未连接 WorldQuant"}</span>
</div>
<Tag color={connected ? "green" : "grey"}>
{account.connection_status === "connected" &&
!account.session.authenticated
? "会话待恢复"
: (connectionLabels[account.connection_status] ??
account.connection_status)}
</Tag>
<div className="session-summary">
<span>
会话剩余{" "}
<strong>{duration(account.session.remaining_seconds)}</strong>
</span>
<span
title={
account.session.expires_at
? formatTime(account.session.expires_at, account.timezone)
: undefined
}
>
有效期 {duration(account.session.total_seconds)}
</span>
</div>
</section>
{(settingsOpen || !account.configured) && (
<section
className="connection-settings"
aria-label="WorldQuant 连接设置"
>
<h2>WorldQuant 连接</h2>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -156,62 +267,60 @@ export function AccountPage({
body: JSON.stringify({ email, password }),
});
setPassword("");
setSettingsOpen(true);
});
}}
>
<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 className="credentials-grid">
<label>
WorldQuant 邮箱
<Input
aria-label="WorldQuant 邮箱"
value={email}
onChange={setEmail}
autoComplete="off"
/>
</label>
<label>
WorldQuant 密码
<Input
aria-label="WorldQuant 密码"
mode="password"
value={password}
onChange={setPassword}
placeholder={
account.configured
? "已保存,输入新密码可更新"
: "输入平台密码"
}
autoComplete="new-password"
/>
</label>
</div>
<div className="inline-actions">
<Button
type="tertiary"
htmlType="submit"
disabled={!email || !password || Boolean(busy)}
disabled={!email || !password || blocked}
loading={busy === "save"}
>
保存凭据
</Button>
<Button
theme="solid"
disabled={!account.configured || Boolean(busy)}
loading={busy === "connect"}
disabled={!account.configured || blocked}
loading={busy === "connect" || connecting}
onClick={() =>
void action("connect", () => post("/account/connect"), true)
}
>
{account.connection_status === "connected"
? "重新连接"
: "连接 WorldQuant"}
{connected ? "重新连接" : "连接 WorldQuant"}
</Button>
<Button
type="tertiary"
theme="borderless"
disabled={
Boolean(busy) || account.connection_status === "disconnected"
blocked || account.connection_status === "disconnected"
}
onClick={() =>
void action("disconnect", () => post("/account/disconnect"))
@@ -219,43 +328,136 @@ export function AccountPage({
>
断开连接
</Button>
<span className="muted">凭据仅在服务端加密保存</span>
</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>
)}
<div className="account-grid">
<section className="account-section">
<h2>基本资料</h2>
<dl className="profile-grid">
{profileRows.map(([key, value]) => (
<div key={String(key)}>
<dt>{String(key)}</dt>
<div key={key}>
<dt>{key}</dt>
<dd>{displayValue(value)}</dd>
</div>
))}
</dl>
</section>
<section className="panel full-width">
<div className="panel-title">
<div>
<h2>工作空间偏好</h2>
<p>只影响你的本地研究界面</p>
<section className="account-section">
<div className="section-heading">
<h2>账户权限</h2>
<span className="muted">
{permissions ? `${permissions.length} 项` : "未提供"}
</span>
</div>
{permissions?.length ? (
<ul className="permission-list">
{permissions.map((permission) => (
<li key={permission} title={permission}>
<span>{permissionLabels[permission] ?? permission}</span>
<span className="permission-state">已开通</span>
</li>
))}
</ul>
) : (
<div className="inline-empty">
{permissions ? "平台未授予权限" : "连接后读取平台权限"}
</div>
)}
</section>
<section className="account-section full-width">
<div className="section-heading">
<h2>提交与用量</h2>
<span className="muted">
{usage ? `美东日期 ${usage.date}` : "尚未同步"}
</span>
</div>
<div className="account-counts">
{[
["待提交 Alpha", usage?.alphas.unsubmitted],
["已启用 Alpha", usage?.alphas.active],
["已停用 Alpha", usage?.alphas.decommissioned],
["累计提交", usage?.submissions?.total],
].map(([label, value]) => (
<div key={String(label)}>
<span>{label}</span>
<strong>{count(value)}</strong>
</div>
))}
</div>
<div className="usage-table-wrap">
<table className="usage-table">
<thead>
<tr>
<th>用量类型</th>
<th>今日</th>
<th>昨日</th>
<th>每日上限</th>
<th>剩余额度</th>
</tr>
</thead>
<tbody>
{(
[
["submissions", "Alpha 提交"],
["simulations", "回测"],
] as const
).map(([key, label]) => (
<tr key={key}>
<th scope="row">{label}</th>
<td>
{usage?.[key]?.today == null
? "尚无当日记录"
: count(usage[key]?.today)}
</td>
<td title={usage?.[key]?.yesterday_date ?? undefined}>
{count(usage?.[key]?.yesterday)}
</td>
<td>
{usage?.[key]?.limit == null
? "平台未返回"
: count(usage[key]?.limit)}
{key === "simulations" &&
usage?.simulations?.limit != null &&
"(本地设定)"}
</td>
<td>{count(usage?.[key]?.remaining)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="usage-note">
回测按本地每日额度计算剩余次数;活动统计可能延迟,当日次数未知时不估算余额。
</p>
{usage &&
Object.entries(usage.errors).map(([key, message]) => (
<p role="status" className="error-text" key={key}>
{
(
{
simulations: "回测用量",
submissions: "提交用量",
alphas: "Alpha 概览",
} as Record<string, string>
)[key]
}
:{message}
</p>
))}
</section>
<ModelSettingsPanel />
<section className="account-section full-width">
<div className="section-heading">
<h2>工作空间偏好</h2>
<span className="muted">
更新于 {formatTime(account.last_synced_at, account.timezone)}
</span>
</div>
<form
className="preferences-form"
onSubmit={(e) => {
e.preventDefault();
void action("preferences", () =>
+54 -52
View File
@@ -8,20 +8,13 @@ import {
Input,
Modal,
Popover,
Pagination,
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,
@@ -145,6 +138,16 @@ export function AlphaPage({
state: "",
});
const [localVersion, setLocalVersion] = useState(0);
const tablePanel = useRef<HTMLElement>(null);
const [compact, setCompact] = useState(
() => matchMedia("(max-width: 760px)").matches,
);
useEffect(() => {
const media = matchMedia("(max-width: 760px)");
const change = () => setCompact(media.matches);
media.addEventListener("change", change);
return () => media.removeEventListener("change", change);
}, []);
useEffect(() => {
if (account) {
setPageSize(account.page_size);
@@ -227,6 +230,7 @@ export function AlphaPage({
}, [query, version, localVersion]);
useEffect(() => {
setSelected([]);
tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 });
}, [query]);
function updateDraft(key: string, value: unknown) {
setDraft((previous) => ({
@@ -291,18 +295,16 @@ export function AlphaPage({
render: (_, row) => (
<button
className="alpha-link"
title={`${row!.id} · ${row!.alpha_type ?? ""}`}
onClick={() => {
setDetailId(row!.id);
onOverlay();
}}
>
<span>
{row!.research.favorite && <IconStar className="favorite-icon" />}
{row!.research.favorite && <Tag size="small">收藏</Tag>}
{row!.name || row!.id}
</span>
<small>
{row!.name ? row!.id : (row!.alpha_type ?? "未提供类型")}
</small>
</button>
),
},
@@ -323,7 +325,7 @@ export function AlphaPage({
render: (_, row) => (
<div className="stacked-cell">
<span>{row!.region ?? "—"}</span>
<small>{row!.universe ?? "—"}</small>
<span className="muted"> / {row!.universe ?? "—"}</span>
</div>
),
},
@@ -334,10 +336,11 @@ export function AlphaPage({
render: (_, row) => (
<div className="stacked-cell">
<span>{row!.status ?? "未提供"}</span>
<small>
{row!.stage ?? "—"}
{row!.hidden ? " · 已隐藏" : ""}
</small>
<span className="muted">
{" "}
· {row!.stage ?? "—"}
{row!.hidden ? " · 隐藏" : ""}
</span>
</div>
),
},
@@ -393,9 +396,9 @@ export function AlphaPage({
render: (_, row) => formatTime(row!.date_created, account?.timezone),
},
];
const columns = columnDefinitions.filter((column) =>
visibleColumns.includes(String(column.key)),
);
const columns = columnDefinitions
.filter((column) => visibleColumns.includes(String(column.key)))
.map((column) => (compact ? { ...column, fixed: undefined } : column));
const activeFilterCount = Object.values(filters).filter(Boolean).length;
const options = (key: string) =>
(Array.isArray(facets[key]) ? (facets[key] as string[]) : []).map(
@@ -408,17 +411,14 @@ export function AlphaPage({
<>
<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)}>
<Button type="tertiary" onClick={() => setImporting(true)}>
导入 Alpha ID
</Button>
<Button
theme="solid"
icon={<IconRefresh />}
loading={busy === "full_sync"}
disabled={!connected}
onClick={() => void newTask("full_sync")}
@@ -466,7 +466,7 @@ export function AlphaPage({
/>
)}
{error && <Banner type="danger" description={error} />}
<section className="library-panel">
<section className="library-panel" ref={tablePanel}>
<form
className="filter-panel"
onSubmit={(e) => {
@@ -477,7 +477,6 @@ export function AlphaPage({
<div className="primary-filters">
<Input
aria-label="搜索 Alpha"
prefix={<IconSearch />}
placeholder="搜索 ID、名称或表达式"
value={draft.q ?? ""}
onChange={(v) => updateDraft("q", v)}
@@ -510,7 +509,7 @@ export function AlphaPage({
<Button htmlType="submit" theme="solid">
查询
</Button>
<Button theme="borderless" onClick={clearFilters}>
<Button type="tertiary" theme="borderless" onClick={clearFilters}>
重置
</Button>
</div>
@@ -632,6 +631,7 @@ export function AlphaPage({
{selected.length > 0 && (
<>
<Button
type="tertiary"
size="small"
onClick={() => {
setBulkVersions(
@@ -648,6 +648,7 @@ export function AlphaPage({
批量编辑
</Button>
<Button
type="tertiary"
size="small"
disabled={!connected}
loading={busy === "alpha_refresh"}
@@ -690,7 +691,7 @@ export function AlphaPage({
}}
/>
<a href={`/api/v1/alphas/export?${query}`}>
<Button icon={<IconDownload />} size="small">
<Button type="tertiary" size="small">
导出
</Button>
</a>
@@ -722,11 +723,9 @@ export function AlphaPage({
</div>
}
>
<Button
aria-label="显示列设置"
icon={<IconSetting />}
size="small"
/>
<Button type="tertiary" aria-label="显示列设置" size="small">
列设置
</Button>
</Popover>
</div>
</div>
@@ -735,32 +734,21 @@ export function AlphaPage({
dataSource={data.items}
rowKey="id"
loading={loading}
size="middle"
className="alpha-table"
size="small"
scroll={{
y: "max(260px, calc(100vh - 670px))",
y: "100%",
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);
},
fixed: !compact,
}}
pagination={false}
empty={
<Empty
className="alpha-empty"
image={<div className="empty-alpha">α</div>}
title={
activeFilterCount
? "没有符合条件的 Alpha"
@@ -774,10 +762,24 @@ export function AlphaPage({
/>
}
/>
<footer className="table-pagination" aria-label="Alpha 分页">
<span className="muted">共 {data.total.toLocaleString()} 条</span>
<Pagination
size={compact ? "small" : "default"}
currentPage={page}
pageSize={pageSize}
total={data.total}
showSizeChanger
pageSizeOpts={[25, 50, 100]}
preventPageChangeOnPageSizeChange
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</footer>
</section>
<p className="page-footnote">
平台数据与本地研究记录独立保存 · 记录不因再次同步而覆盖
</p>
<Modal
title="导入 Alpha ID"
visible={importing && !overlaySuspended}
+570 -470
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -51,6 +51,28 @@ export type Account = {
theme: "light" | "dark";
timezone: string;
page_size: number;
session: {
authenticated: boolean;
expires_at: string | null;
remaining_seconds: number | null;
total_seconds: number | null;
};
};
export type DailyActivity = {
today: number | null;
yesterday: number | null;
yesterday_date: string | null;
total: number | null;
limit: number | null;
remaining: number | null;
};
export type AccountUsage = {
date: string;
timezone: string;
simulations?: DailyActivity;
submissions?: DailyActivity;
alphas: Record<string, number | null>;
errors: Record<string, string>;
};
export type Job = {
id: string;
+87
View File
@@ -173,3 +173,90 @@ test("collapse, navigation, reload, narrow viewport and cancellation preserve ex
401,
);
});
test("Lark workspace keeps pagination, account details and chat usable at every width", async ({
page,
}) => {
await login(page);
await configure(page);
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(25);
await expect(page.locator(".sidebar")).toHaveCSS(
"background-color",
"rgb(249, 249, 249)",
);
await expect(page.locator(".nav-item.active")).toHaveCSS(
"background-color",
"rgba(31, 35, 41, 0.05)",
);
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
for (const width of [1920, 1440, 1280, 850, 390]) {
await page.setViewportSize({ width, height: 900 });
const footer = page.locator('footer[aria-label="Alpha 分页"]');
await expect(footer).toBeVisible();
const before = (await footer.boundingBox())!;
expect(before.y + before.height).toBeLessThanOrEqual(900);
expect(before.y + before.height).toBeGreaterThan(864);
await page
.locator(".semi-table-body")
.evaluate((el) => el.scrollTo({ top: 1000 }));
expect((await footer.boundingBox())!.y).toBeCloseTo(before.y, 0);
await page.getByRole("button", { name: "打开研究助手" }).click();
await expect(chat).toBeVisible();
await expect(chat).toHaveCSS("background-color", "rgb(255, 255, 255)");
const box = (await chat.boundingBox())!;
expect(box.x + box.width).toBeCloseTo(width, 0);
if (width >= 1440) {
expect(
(await page.locator(".main-shell").boundingBox())!.width,
).toBeGreaterThan(600);
expect(
(await footer.boundingBox())!.x + (await footer.boundingBox())!.width,
).toBeLessThan(box.x);
} else {
await expect(page.locator(".main-shell")).toHaveAttribute("inert", "");
await expect(page.locator(".ai-mask")).toBeVisible();
}
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);
await page.screenshot({
path: `../output/playwright/lark-chat-${width}.png`,
});
await chat.getByRole("button", { name: "收起研究助手" }).click();
}
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole("button", { name: "个人信息", exact: true }).click();
await expect(page.getByRole("button", { name: "连接设置" })).toHaveAttribute(
"aria-expanded",
"false",
);
await expect(page.locator(".permission-list li")).toHaveCount(10);
await expect(page.getByRole("row", { name: /回测/ })).toContainText(
"10,000(本地设定)",
);
await page
.getByRole("textbox", { name: "模型标识", exact: true })
.fill("保留模型设置草稿");
await page.getByRole("button", { name: "打开研究助手" }).click();
const resize = chat.getByRole("separator", { name: "调整助手宽度" });
await resize.focus();
for (let i = 0; i < 11; i++) await resize.press("ArrowLeft");
expect((await chat.boundingBox())!.width).toBe(640);
await chat.getByRole("button", { name: "新会话", exact: true }).click();
const input = chat.getByRole("textbox", { name: "发送给研究助手" });
await input.fill("保留聊天草稿");
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("button", { name: "个人信息", exact: true }).click();
await expect(input).toHaveValue("保留聊天草稿");
await expect(
page.getByRole("textbox", { name: "模型标识", exact: true }),
).toHaveValue("保留模型设置草稿");
await chat.getByRole("button", { name: "收起研究助手" }).click();
await page.screenshot({
path: "../output/playwright/lark-account.png",
fullPage: true,
});
});
+11
View File
@@ -16,6 +16,12 @@ test("account → full sync → research → resync → PnL → filtered export
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await page.getByRole("button", { name: "个人信息", exact: true }).click();
const connectionSettings = page.getByRole("button", {
name: "连接设置",
exact: true,
});
if ((await connectionSettings.getAttribute("aria-expanded")) === "false")
await connectionSettings.click();
await page
.getByRole("textbox", { name: "WorldQuant 邮箱" })
.fill("test@example.com");
@@ -36,6 +42,11 @@ test("account → full sync → research → resync → PnL → filtered export
.toBe("connected");
await page.keyboard.press("Escape");
await expect(page.locator(".job-panel")).not.toBeVisible();
await expect(page.locator(".permission-list li")).toHaveCount(10);
await expect(page.locator(".session-summary")).toContainText("4 小时");
const simulation = page.getByRole("row", { name: /回测/ });
await expect(simulation).toContainText("10,000(本地设定)");
await expect(simulation).toContainText("9,997");
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("button", { name: "同步平台数据" }).click();
await expect