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
+1
View File
@@ -17,3 +17,4 @@ backend/tests
frontend/tests
**/*.tsbuildinfo
backups
account.json
+5 -1
View File
@@ -27,6 +27,10 @@ docker compose ps
平台未返回的资料与指标保留为空。数值筛选采用平台原始单位,例如 Turnover `0.15` 表示 15%。日期筛选边界为 UTC;时间显示采用个人页的时区偏好。
个人信息页展示平台权限、会话有效期、提交与模拟用量;已配置账户的连接表单默认收起,通过“连接设置”展开。回测每日 `10,000` 次是本地设定的展示额度,按美东日期的活动次数计算剩余次数,并非平台返回的配额。当日记录缺失时显示未知,不把剩余次数估算为满额。点击“刷新资料”更新这些快照。
工作空间和 AI 交互统一采用紧凑的 Lark 样式。Alpha 列表只滚动表体,分页保持在可用区域底部;个人信息页独立滚动。
## AI 研究助手
1. 在“个人信息 → 大模型服务”填写 Base URL、API Key、模型标识,明确选择 Chat Completions 或 Responses。
@@ -35,7 +39,7 @@ docker compose ps
4. 全部通过后勾选“启用研究助手”并保存。更换地址、模型、协议或密钥后必须重新测试;更换地址必须重填密钥。
5. 点击右下角“AI 研究助手”或顶部“AI 助手”,新建会话开始使用。可以询问当前 Alpha、筛选换手率不超过 15% 的记录、查看缓存 PnL,或提出研究记录修改和同步任务操作。
聊天默认收起,展开宽度为 420px,左边缘可拖动或用左右方向键调整到 360–640px。宽屏聊天与详情并排;窄屏打开聊天时暂时隐藏详情和任务面板,收起后恢复。页面切换保留当前聊天、筛选和研究草稿,草稿内容不会自动发送给模型。
聊天默认收起,展开宽度为 420px,左边缘可拖动或用左右方向键调整到 360–640px。宽屏聊天与详情并排;窄屏打开聊天时暂时隐藏详情和任务面板,并通过遮罩隔离背景操作,收起后恢复。点击遮罩、收起按钮或按 Esc 可收起聊天。页面切换保留当前聊天、筛选和研究草稿,草稿内容不会自动发送给模型。
本地研究修改、批量标签/状态、创建/取消/重试同步任务均先显示预览。只有点击“确认执行”才会写入;文字中的同意不能替代按钮。预览固定目标及版本,批量最多 100 条。页面与 AI 同时编辑出现冲突时不会覆盖新版本;复制需要保留的草稿后载入最新记录再编辑。任务进度沿用业务轮询;停止聊天不会取消已创建的同步任务。
+65
View File
@@ -0,0 +1,65 @@
"""Account snapshots with an explicit local daily simulation allowance."""
from datetime import datetime
from zoneinfo import ZoneInfo
from .alphas import number
LOCAL_DAILY_SIMULATION_LIMIT = 10_000
def daily_activity(raw, today, daily_limit=None):
"""Read recordset columns by schema; missing dates are not reported as zero usage."""
recordset = raw.get("records", {})
if not isinstance(recordset, dict):
raise ValueError("Invalid activity recordset")
schema = recordset.get("schema", {})
if not isinstance(schema, dict):
raise ValueError("Invalid activity schema")
properties, rows = schema.get("properties", []), recordset.get("records", [])
if not isinstance(properties, list) or not isinstance(rows, list):
raise ValueError("Invalid activity records")
names = [p.get("name") if isinstance(p, dict) else p for p in properties]
count = None
if "date" in names and "value" in names:
for row in rows:
if isinstance(row, list) and len(row) >= len(names) and row[names.index("date")] == today:
count = number(row[names.index("value")])
result = {
"today": count,
"limit": daily_limit,
"remaining": max(0, daily_limit - count) if daily_limit is not None and count is not None else None,
}
for key in ("yesterday", "total"):
period = raw.get(key)
result[key] = number(period.get("value")) if isinstance(period, dict) else None
if key == "yesterday":
result["yesterday_date"] = period.get("end") if isinstance(period, dict) else None
# The allowance is a user-chosen local budget, not a platform quota.
# Missing activity must not imply that the whole daily budget is available.
return result
def usage_snapshot(data, errors, at=None):
today = (at or datetime.now(ZoneInfo("America/New_York"))).date().isoformat()
activities, errors = {}, dict(errors)
for key in ("simulations", "submissions"):
if key in data:
try:
activities[key] = daily_activity(
data[key],
today,
daily_limit=LOCAL_DAILY_SIMULATION_LIMIT if key == "simulations" else None,
)
except ValueError:
errors[key] = "平台用量数据格式无法识别"
return {
"date": today,
"timezone": "America/New_York",
**activities,
"alphas": {
key: number(data.get("alphas", {}).get(key))
for key in ("unsubmitted", "active", "decommissioned")
},
"errors": errors,
}
+15
View File
@@ -159,12 +159,16 @@ class Runner:
async def refresh_profile(self):
raw = await self.client.profile()
# Confirm identity before fetching or storing additional account data.
async with self.sessions() as db:
account = await db.get(Account, 1)
user_id = raw.get("id")
if not user_id or (account.wq_user_id and account.wq_user_id != str(user_id)):
self.client.disconnect()
raise WqError("平台账户身份不匹配,请核对凭据", "identity_mismatch")
usage = await self.client.account_usage()
async with self.sessions() as db:
account = await db.get(Account, 1)
account.wq_user_id = str(user_id)
# An allowlist avoids persisting unknown personal/security fields.
account.profile = sanitize(
@@ -177,6 +181,14 @@ class Runner:
"email",
"firstName",
"lastName",
"fullName",
"level",
"geniusLevel",
"verified",
"approved",
"dateCreated",
"dateVerified",
"dateApproved",
"role",
"roles",
"permissions",
@@ -185,6 +197,9 @@ class Runner:
if k in raw
}
)
if self.client.permissions is not None:
account.profile = {**account.profile, "permissions": self.client.permissions}
account.profile = {**account.profile, "usage": usage}
account.connection_status, account.connection_error = "connected", None
account.verification_url, account.last_synced_at = None, now()
await db.execute(
+9 -5
View File
@@ -45,7 +45,7 @@ from .schemas import (
from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password
def account_output(account):
def account_output(account, client):
keys = (
"email",
"wq_user_id",
@@ -59,7 +59,11 @@ def account_output(account):
"timezone",
"page_size",
)
return {**{k: getattr(account, k) for k in keys}, "configured": bool(account.password_encrypted)}
return {
**{k: getattr(account, k) for k in keys},
"configured": bool(account.password_encrypted),
"session": client.session_info(),
}
def csv_cell(value):
@@ -189,7 +193,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
@api.get("/account", response_model=AccountOutput, tags=["account"])
async def get_account():
async with sessions() as db:
return account_output(await db.get(Account, 1))
return account_output(await db.get(Account, 1), runner.client)
@api.put("/account/credentials", response_model=AccountOutput, tags=["account"])
async def credentials(body: CredentialsInput):
@@ -203,7 +207,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
account.email = body.email
account.password_encrypted = cipher(settings).encrypt(body.password.encode()).decode()
await db.commit()
return account_output(account)
return account_output(account, runner.client)
@api.patch("/account/preferences", response_model=AccountOutput, tags=["account"])
async def preferences(body: PreferencesInput):
@@ -212,7 +216,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
for key, value in body.model_dump().items():
setattr(account, key, value)
await db.commit()
return account_output(account)
return account_output(account, runner.client)
async def account_job(kind):
async with sessions() as db:
+8
View File
@@ -232,6 +232,13 @@ class JobOutput(BaseModel):
updated_at: datetime
class PlatformSessionOutput(BaseModel):
authenticated: bool
expires_at: datetime | None
remaining_seconds: int | None
total_seconds: float | None
class AccountOutput(BaseModel):
email: str | None
configured: bool
@@ -245,6 +252,7 @@ class AccountOutput(BaseModel):
theme: Literal["light", "dark"]
timezone: str
page_size: int
session: PlatformSessionOutput
class SessionOutput(BaseModel):
+71 -3
View File
@@ -7,7 +7,7 @@ contain credentials, cookies, or temporary authentication links.
import asyncio
import math
import random
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from typing import Awaitable, Callable
from urllib.parse import urljoin, urlparse
@@ -41,6 +41,9 @@ class WqClient:
self.auth_generation = 0
self.credentials: tuple[str, str] | None = None
self.verification_url: str | None = None
self.permissions: list[str] | None = None
self.session_expires_at: datetime | None = None
self.session_duration: float | None = None
self.sleep = sleep
self.on_retry: Callable[[float], Awaitable[None]] | None = None
@@ -51,8 +54,47 @@ class WqClient:
self.authenticated = False
self.credentials = None
self.verification_url = None
self.permissions = None
self.session_expires_at = None
self.session_duration = None
self.client.cookies.clear()
def capture_session(self, response: httpx.Response):
"""Keep only permissions and expiry metadata; never retain the authentication body."""
self.permissions, self.session_expires_at, self.session_duration = None, None, None
try:
data = response.json()
except ValueError:
return
if not isinstance(data, dict):
return
permissions = data.get("permissions")
if isinstance(permissions, list) and all(isinstance(p, str) for p in permissions):
self.permissions = list(dict.fromkeys(permissions))
token = data.get("token")
expiry = token.get("expiry") if isinstance(token, dict) else None
if (
isinstance(expiry, (int, float))
and not isinstance(expiry, bool)
and math.isfinite(expiry)
and 0 <= expiry <= 31536000
):
self.session_duration = expiry
self.session_expires_at = datetime.now(timezone.utc) + timedelta(seconds=expiry)
def session_info(self):
remaining = (
max(0, int((self.session_expires_at - datetime.now(timezone.utc)).total_seconds()))
if self.session_expires_at
else None
)
return {
"authenticated": self.authenticated and remaining != 0,
"expires_at": self.session_expires_at,
"remaining_seconds": remaining,
"total_seconds": self.session_duration,
}
def safe_verification_url(self, response: httpx.Response) -> str:
url = urljoin(str(response.url), response.headers.get("Location", ""))
base = urlparse(self.settings.wq_base_url)
@@ -111,6 +153,7 @@ class WqClient:
raise VerificationRequired(self.verification_url)
self.credentials = (email, password)
self.authenticated = False
self.permissions, self.session_expires_at, self.session_duration = None, None, None
if force:
self.client.cookies.clear()
self.verification_url = None
@@ -126,6 +169,7 @@ class WqClient:
if response.status_code not in (200, 201):
raise WqError(f"平台认证返回异常状态 {response.status_code}", "authentication_failed")
self.authenticated = True
self.capture_session(response)
self.auth_generation += 1
self.verification_url = None
@@ -139,6 +183,7 @@ class WqClient:
response = await self._request("POST", self.verification_url)
if response.status_code in (200, 201):
self.authenticated = True
self.capture_session(response)
self.auth_generation += 1
self.verification_url = None
elif response.status_code in (401, 403, 202):
@@ -146,7 +191,7 @@ class WqClient:
else:
raise WqError("验证会话已失效,请重新连接", "authentication_failed")
async def get(self, path: str, params=None):
async def get(self, path: str, params=None, headers=None):
if not self.credentials:
raise WqError("请先连接 WorldQuant", "disconnected")
if not self.authenticated:
@@ -154,7 +199,7 @@ class WqClient:
refreshed = False
for attempt in range(self.settings.retry_attempts):
generation = self.auth_generation
response = await self._request("GET", path, params=params)
response = await self._request("GET", path, params=params, headers=headers)
if response.status_code == 401 and not refreshed:
await self.authenticate(*self.credentials, stale_generation=generation)
refreshed = True
@@ -189,6 +234,29 @@ class WqClient:
async def profile(self):
return await self.get("/users/self")
async def account_usage(self):
"""Read independent account resources; unavailable sections do not hide the profile."""
from .account_data import usage_snapshot
resources = {
"simulations": "/users/self/activities/simulations",
"submissions": "/users/self/activities/submissions",
"alphas": "/users/self/alphas/summary",
}
data, errors = {}, {}
for key, path in resources.items():
try:
data[key] = await self.get(
path, headers={"Accept": "application/json;version=4.0"} if key == "alphas" else None
)
except VerificationRequired:
raise
except WqError as exc:
if exc.code in ("authentication_failed", "disconnected"):
raise
errors[key] = str(exc)
return usage_snapshot(data, errors)
async def alphas(self, submission, hidden, offset, before):
# Cover every platform stage; submitted records are not assumed to be OS only.
status_key = "status" if submission == "UNSUBMITTED" else "status!"
+46 -2
View File
@@ -83,13 +83,57 @@ def create_test_app():
path = request.url.path
if path == "/authentication" and request.method == "POST":
return httpx.Response(
201, json={"user": {"id": "TEST_USER"}}, headers={"Set-Cookie": "mock=only; Path=/"}
201,
json={
"user": {"id": "TEST_USER"},
"token": {"expiry": 14400},
"permissions": [
"CONSULTANT",
"SUPER_ALPHA",
"MULTI_SIMULATION",
"PROD_ALPHAS",
"BRAIN_LABS",
"BRAIN_LABS_JUPYTER_LAB",
"REFERRAL",
"VISUALIZATION",
"WORKDAY",
"BEFORE_AND_AFTER_PERFORMANCE_V2",
],
},
headers={"Set-Cookie": "mock=only; Path=/"},
)
if request.method != "GET":
raise AssertionError("Browser acceptance attempted an upstream mutation")
if path == "/users/self":
return httpx.Response(
200, json={"id": "TEST_USER", "name": "模拟研究员", "email": "test@example.com"}
200,
json={
"id": "TEST_USER",
"fullName": "模拟研究员",
"email": "test@example.com",
"level": "CONSULTANT",
"geniusLevel": "GOLD",
"verified": True,
"approved": True,
"dateCreated": "2025-01-01T00:00:00Z",
},
)
if path == "/users/self/alphas/summary":
return httpx.Response(200, json={"unsubmitted": 413, "active": 207, "decommissioned": 0})
if path in ("/users/self/activities/simulations", "/users/self/activities/submissions"):
from zoneinfo import ZoneInfo
today = datetime.now(ZoneInfo("America/New_York")).date()
return httpx.Response(
200,
json={
"yesterday": {"end": (today - timedelta(days=1)).isoformat(), "value": 2},
"total": {"value": 207},
"records": {
"schema": {"properties": [{"name": "date"}, {"name": "value"}]},
"records": [[today.isoformat(), 3]],
},
},
)
if path == "/users/self/alphas":
unsubmitted = "status" in request.url.params
+4
View File
@@ -19,6 +19,7 @@ class FakePlatform:
self.fail_id = True
self.require_verification = False
self.block = None
self.permissions = ["CONSULTANT", "SUPER_ALPHA"]
async def authenticate(self, *args, **kwargs):
if self.require_verification:
@@ -32,6 +33,9 @@ class FakePlatform:
async def profile(self):
return {"id": "user1", "email": "test@example.com", "password": "never-save", "unknown": 1}
async def account_usage(self):
return {"date": "2026-09-07", "alphas": {"active": 99}, "errors": {}}
async def alphas(self, submission, hidden, offset, before):
self.page_calls.append((submission, hidden, offset, before))
if self.block:
+81
View File
@@ -35,6 +35,87 @@ async def test_cookie_auth_expiry_and_read_only_boundary(settings):
await client.close()
async def test_auth_metadata_usage_and_local_simulation_allowance(settings):
from datetime import datetime
from zoneinfo import ZoneInfo
today = datetime.now(ZoneInfo("America/New_York")).date().isoformat()
calls = []
def handler(request):
calls.append((request.method, request.url.path))
if request.url.path == "/authentication":
return httpx.Response(
201,
json={
"permissions": ["CONSULTANT", "SUPER_ALPHA", "CONSULTANT"],
"token": {"expiry": 14400, "value": "private-token"},
"password": "private-password",
},
)
if request.url.path.endswith("/simulations"):
return httpx.Response(
200,
json={
"records": {
"schema": {"properties": [{"name": "value"}, {"name": "date"}]},
"records": [[0, today]],
},
"yesterday": {"value": 40},
"total": {"value": 900},
},
)
if request.url.path.endswith("/submissions"):
return httpx.Response(403)
assert request.headers["Accept"] == "application/json;version=4.0"
return httpx.Response(200, json={"active": 99, "unsubmitted": 100, "decommissioned": 0})
client = WqClient(settings, transport=httpx.MockTransport(handler))
await client.authenticate("test@example.com", "secret")
assert client.permissions == ["CONSULTANT", "SUPER_ALPHA"]
info = client.session_info()
assert info["authenticated"] and 14395 < info["remaining_seconds"] <= 14400
assert "private" not in str(info)
usage = await client.account_usage()
assert usage["simulations"]["today"] == 0
assert usage["simulations"]["limit"] == 10_000 and usage["simulations"]["remaining"] == 10_000
assert usage["alphas"]["active"] == 99
assert "submissions" in usage["errors"] and "submissions" not in usage
assert all(method == "GET" or path == "/authentication" for method, path in calls)
client.disconnect()
assert client.permissions is None and client.session_info()["remaining_seconds"] is None
assert not client.session_info()["authenticated"]
await client.close()
def test_activity_missing_today_is_unknown_not_zero():
from app.account_data import daily_activity
data = {
"records": {
"schema": {"properties": [{"name": "date"}, {"name": "value"}]},
"records": [["2026-09-06", 2]],
}
}
assert daily_activity(data, "2026-09-07")["today"] is None
assert daily_activity({}, "2026-09-07")["limit"] is None
local_budget = daily_activity(data, "2026-09-07", daily_limit=10_000)
assert local_budget["limit"] == 10_000 and local_budget["remaining"] is None
@pytest.mark.parametrize(
"recordset", [None, {"records": None}, {"schema": None}, {"schema": {"properties": None}}]
)
def test_malformed_optional_activity_does_not_discard_other_sections(recordset):
from app.account_data import usage_snapshot
result = usage_snapshot(
{"simulations": {"records": recordset}, "submissions": {}, "alphas": {"active": 99}}, {}
)
assert "simulations" in result["errors"] and "simulations" not in result
assert result["submissions"]["today"] is None and result["alphas"]["active"] == 99
async def test_persona_verification_uses_same_cookie_and_safe_location(settings):
complete = False
+13
View File
@@ -132,3 +132,16 @@ AI 工具只使用明确的业务接口,不接触 ORM、任意 SQL、任意 HT
- 失败、取消或中断的轮次使用服务端保存的用户消息与工具审计事实补足完整历史,不重放未配对的模型调用,也不增加摘要模型请求。
模型设置变更会使现有待确认轮次失效,需要停止该轮并重新预览。自动化仅验证固定模拟行为与协议;真实模型对自然语言指代和工具选择的效果,需要配置供应商后另行联调。
## 7. 界面基线与本次恢复
个人信息与登录体验以会话“打磨个人信息模块与登录体验”的最终快照 `f781203` 为基线,保留账户权限、会话有效期、提交/模拟用量、本地每日 10,000 次模拟额度和固定底部分页。AI 功能沿用此基线,不另建视觉主题。
- `scope_sketch`:紧凑研究工作空间,个人信息设置、全局聊天、查询结果、修改确认;主要流程为查询、预览、确认和查看业务结果。
- `lark_style_recipe`:白色主区域,侧栏直接使用 `#f9f9f9`、选中项 `#1f23290d`,主操作使用 `#1456f0`;4px 间距基准,按用户的紧凑偏好以 8/12/16px 排布。普通卡片无阴影,边框轻,控件圆角 4–6px、消息与结果容器 8px。
- `emphasis_budget`:页面标题 600,区域标题和选中导航 500,正文、指标、提示、按钮和链接 400。颜色用于操作、焦点和真实状态。
- `layout_signature_usage` / `top_nav_policy`:保留左侧主导航与 56px 工作区顶栏;业务页面独立滚动。页面挂载容器保持可收缩的 flex 高度链,列表仅表体滚动,分页保持底部。
- `right_rail_policy`:聊天默认 420px、可调整 360–640px;桌面端预留空间,按剩余业务宽度调整账户表单;窄屏显示遮罩并隔离背景焦点,640px 以下占满屏幕。收起保留会话、生成任务与草稿,Esc 关闭当前面板。
- `ud_control_coverage`:保留 Semi 的 Button、Input、Select、Tag、Banner、TextArea 与 Pagination 语义及交互状态,统一颜色、字重和间距;自定义部分限布局、业务结果和前后差异。
- `icon_plan` / `media_decision`:本次新增 AI 区域采用文字操作,无自绘图标或插画;侧栏与登录恢复原会话的简洁文字入口。辅助聊天空间优先用于结果和编辑,没有营销 Hero 或装饰媒体。
- `verification_plan`:账户能力与 AI 回归;390/850/1280/1440/1920px 布局,聊天最大宽度、表格滚动、底部分页、窄屏遮罩、键盘操作和跨页草稿。截图均使用合成数据。
+3
View File
@@ -6,6 +6,7 @@
- 首期:个人信息与会话、Alpha 列表与详情、本地研究记录、可靠同步、Docker 部署。
- 当前扩展:全局右侧 AI 研究助手,自定义模型服务,通过查询工具及用户确认操作业务;具体范围见 [AI Chatbot 开发计划](ai-chatbot-plan.md)。
- 界面统一遵循 Lark Design Prototype,保留紧凑个人信息与登录体验;白色工作区、浅色导航、蓝色主操作及 4px 间距基准同样适用于 AI 面板、设置、结果与确认。
- Python + React + TypeScript + Semi Design,前后端分别位于 backend/ 和 frontend/,独立依赖与测试。
- PostgreSQL 存储数据,FastAPI 提供 OpenAPI 契约,HTTPX 统一异步调用 WorldQuant。
- React 19 使用 @douyinfe/semi-ui-19。Caddy 提供静态资源、API 代理与公网 HTTPS。
@@ -21,12 +22,14 @@
在个人页配置平台邮箱及密码,连接、重新连接、人工验证、断开与刷新资料。
平台凭据加密存储,解密密钥独立配置,不在前端持久化,也不进入日志或响应。
展示平台 ID、昵称、邮箱、身份与权限(以返回字段为准)、连接状态、最后同步时间。
展示平台会话剩余时长、提交与模拟活动、Alpha 数量概览;模拟每日 10,000 次为本地展示额度,按美东日期计算,缺失当日活动时不估算余额。已配置的连接表单默认收起。
提供本地显示名称、主题、时区和分页偏好,默认中文、浅色、Asia/Shanghai、25 条/页。
缺失资料显示“未提供”,不得虚构数据。
### Alpha 管理
- 主界面为侧栏、筛选区、可排序分页表格、列显隐、多选、详情抽屉;任务进度在独立面板查看。
- 列表以完整 flex 高度链填充可用区域,仅表体滚动,分页固定在底部;聊天展开后按剩余空间布局,保留编辑草稿。
- 全量同步已提交/未提交、隐藏/可见 Alpha;指定 ID 导入及选中刷新;仅手动触发。
- 筛选 ID/名称/表达式、地区、Universe、类型、语言、平台状态、隐藏、日期、核心指标、本地标签/状态/收藏。
- 展示 REGULAR/SUPER、FASTEXPR/PYTHON、Selection/Combo、完整 settings、IS/OS、已有 checks。
+15 -1
View File
@@ -1,6 +1,6 @@
# 项目与 AI 助手验收记录
日期:2026-09-07。验证范围为本地实现、模拟 WorldQuant 上游、实际 PostgreSQL/Docker。未访问真实 WorldQuant 账户,没有调用平台回测、检查、属性修改或提交接口。
日期:2026-09-07。下列首版 AI 验收使用本地实现、模拟 WorldQuant 上游及实际 PostgreSQL/Docker,没有访问真实 WorldQuant 账户或调用平台回测、检查、属性修改、提交接口。本次账户与样式恢复的验证单列于文末。
## 自动化与运行实测
@@ -78,3 +78,17 @@ AI SDK UI `6.0.277` / `@ai-sdk/react 3.0.280`、Pydantic AI slim `1.97.0` 均锁
## 已知架构边界
首期只有一个任务执行进程。长 Retry-After 会让后续任务排队,任务面板显示下次尝试时间并支持取消;不会为了缩短等待而提前请求平台。offset 分页遇到平台记录移动时可能遗漏,后续全量同步校正,单次未见记录不自动删除。数据库保存原始业务快照,后续研究、回测和 MCP 能力尚未实现。
## 账户基线恢复与 Lark 统一验收
恢复来源为“打磨个人信息模块与登录体验”的最终工作区快照 `f781203`。该快照此前未进入 `main` 提交历史,AI 开发所用基线未包含它;合并检查遗漏了这组未提交改动。本次按共同基线合并账户代码及样式,保留现有 AI 业务模块、版本校验、确认、会话与执行能力。
- 恢复账户权限、认证会话有效期、提交/模拟活动、Alpha 数量概览;模拟每日本地额度为 10,000,当日活动未知时余额也保持未知。恢复连接设置默认收起、紧凑登录与底部分页。
- 聊天、模型配置、查询卡片和确认差异统一采用 Lark 的白色工作区、浅色导航、蓝色主操作及 4px 间距基准;确认差异明确标示“修改前/修改后”。模型配置草稿跨页保留。
- `uv run ruff check app tests`、`uv run pytest -q`:74 项通过;恢复的权限、认证元数据、用量及缺失活动契约测试包含在内。
- `pnpm build`:类型检查与生产构建通过。首次检查发现主检出目录尚未安装 AI 锁定依赖,经 `pnpm install --frozen-lockfile` 补齐,锁文件未变更。原有 lottie-web 构建提示仍存在。
- Playwright 原有 4 项完整回归通过,新增的多尺寸布局与草稿测试通过。新增测试首次使用了错误的 footer landmark 定位;修正定位后单独重跑通过,未因此改变页面行为。
- 实测 390/850/1280/1440/1920px:无页面横向溢出,表体滚动不移动分页;桌面聊天预留空间,窄屏遮罩与背景 inert 生效,手机聊天占满屏幕;640px 聊天宽度和跨页表单/聊天草稿通过。旧有确认后刷新、详情草稿冲突、收起后继续生成、恢复、停止和退出流程均通过。
- 已查看截图:`output/playwright/ai-approval.png`、`account.png`、`lark-chat-390.png`、`lark-chat-1440.png`。全部为合成账户与 Alpha,未读取真实凭据。
旧会话曾记录真实账户的只读认证、个人资料、10 项权限、14,400 秒会话和活动用量联调;这是旧快照的历史记录,并非本轮重新验证。本轮没有迁移或存储结构变更,没有重新运行 Docker/备份验收,也未重新部署正式实例、访问真实 WorldQuant 或收费模型服务。
+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>Alpha 研究</div>
</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>
+42 -12
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,6 +590,13 @@ function BusinessCard({
</p>
)}
{pending && (
<div className="ai-approval">
<p className="muted">
{call.preview.targets?.length
? `将修改 ${call.preview.targets.length} 条研究记录。`
: "将执行以上任务操作。"}
确认后执行。
</p>
<div className="inline-actions">
<Button
theme="solid"
@@ -578,12 +606,14 @@ function BusinessCard({
确认执行
</Button>
<Button
type="tertiary"
disabled={disabled}
onClick={() => void onDecision(call.id, false)}
>
拒绝
</Button>
</div>
</div>
)}
{typeof result.error === "string" && (
<p className="error-text">{result.error}</p>
+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;
}
+278 -76
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>
</div>
<Tag
color={account.connection_status === "connected" ? "green" : "grey"}
size="large"
<div className="inline-actions">
<Button
type="tertiary"
disabled={!account.configured || blocked}
loading={busy === "profile"}
aria-label="刷新个人资料"
onClick={() =>
void action("profile", () => post("/account/refresh"))
}
>
{connectionLabels[account.connection_status] ??
account.connection_status}
</Tag>
刷新资料
</Button>
<Button
type="tertiary"
aria-expanded={settingsOpen || !account.configured}
onClick={() => setSettingsOpen(!settingsOpen)}
>
连接设置
</Button>
</div>
</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>
<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>
<p>连接你的个人 BRAIN 账户</p>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -156,16 +267,17 @@ export function AccountPage({
body: JSON.stringify({ email, password }),
});
setPassword("");
setSettingsOpen(true);
});
}}
>
<div className="credentials-grid">
<label>
WorldQuant 邮箱
<Input
aria-label="WorldQuant 邮箱"
value={email}
onChange={setEmail}
placeholder="name@example.com"
autoComplete="off"
/>
</label>
@@ -178,40 +290,37 @@ export function AccountPage({
onChange={setPassword}
placeholder={
account.configured
? "已保存 · 输入新密码可更新配置"
: "输入你的平台密码"
? "已保存,输入新密码可更新"
: "输入平台密码"
}
autoComplete="new-password"
/>
</label>
<div className="credential-note">
<IconShield />
密码加密存储,仅用于服务端连接平台。
</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