fix: restore polished account experience and align AI with Lark design
This commit is contained in:
+34
-57
@@ -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>
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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
@@ -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
@@ -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", () =>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user