diff --git a/.dockerignore b/.dockerignore index 9fe7967..8d6154f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,3 +17,4 @@ backend/tests frontend/tests **/*.tsbuildinfo backups +account.json diff --git a/README.md b/README.md index 0da8542..32c0319 100644 --- a/README.md +++ b/README.md @@ -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 同时编辑出现冲突时不会覆盖新版本;复制需要保留的草稿后载入最新记录再编辑。任务进度沿用业务轮询;停止聊天不会取消已创建的同步任务。 diff --git a/backend/app/account_data.py b/backend/app/account_data.py new file mode 100644 index 0000000..e07e2e8 --- /dev/null +++ b/backend/app/account_data.py @@ -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, + } diff --git a/backend/app/jobs.py b/backend/app/jobs.py index 6f6ccbe..fa369b5 100644 --- a/backend/app/jobs.py +++ b/backend/app/jobs.py @@ -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( diff --git a/backend/app/main.py b/backend/app/main.py index 0bbd8c1..c71ebd2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 471f9f9..a206ef6 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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): diff --git a/backend/app/worldquant.py b/backend/app/worldquant.py index e0a63ec..e5aea35 100644 --- a/backend/app/worldquant.py +++ b/backend/app/worldquant.py @@ -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!" diff --git a/backend/tests/browser_server.py b/backend/tests/browser_server.py index 942a2fa..15eb448 100644 --- a/backend/tests/browser_server.py +++ b/backend/tests/browser_server.py @@ -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 diff --git a/backend/tests/test_jobs.py b/backend/tests/test_jobs.py index a429471..c460275 100644 --- a/backend/tests/test_jobs.py +++ b/backend/tests/test_jobs.py @@ -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: diff --git a/backend/tests/test_worldquant.py b/backend/tests/test_worldquant.py index 57acd69..04bfb83 100644 --- a/backend/tests/test_worldquant.py +++ b/backend/tests/test_worldquant.py @@ -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 diff --git a/docs/ai-chatbot-plan.md b/docs/ai-chatbot-plan.md index eb73d84..be36527 100644 --- a/docs/ai-chatbot-plan.md +++ b/docs/ai-chatbot-plan.md @@ -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 布局,聊天最大宽度、表格滚动、底部分页、窄屏遮罩、键盘操作和跨页草稿。截图均使用合成数据。 diff --git a/docs/project-plan.md b/docs/project-plan.md index 1505f77..f839b9b 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -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。 diff --git a/docs/verification.md b/docs/verification.md index 1fa7835..704b4f1 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -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 或收费模型服务。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 48f5fe5..e12edb8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { /> ) : (