feat: add Pyramid home dashboard

This commit is contained in:
yuxuanhui
2026-09-09 18:20:08 +08:00
parent be76b89e9d
commit e57b1f7a2e
17 changed files with 950 additions and 34 deletions
@@ -0,0 +1,16 @@
# 首页 Pyramid 看板
Type: task
Status: ready-for-agent
按 ../spec.md 接入两项只读资源并实现独立首页,完成本地自动化与页面验证。
## Comments
- 用户已授权新增首页模块,首期范围为两个 Pyramid 接口。
- 实现完成:默认首页、导航/助手页面上下文、两项独立只读资源、摘要卡片、搜索、日期筛选、刷新/重试及连接状态。
- 验证完成:后端全量 254 项通过;日期格式修正后相关 14 项再次通过;ruff 全量通过。
- 前端构建通过,现有浏览器回归 22 项全部通过。构建仍有依赖 lottie-web 的 eval 提示。
- Playwright CLI 在独立临时数据库和模拟平台中检查新首页:未连接、正确摘要、搜索无匹配、日期应用/无效范围/恢复默认、单项失败不影响另一项、重试、空数据、缺失值、迟到响应不覆盖新结果、断开后清空数据均通过。
- 已检查 1440px 浅色/深色与 390px 窄屏截图,未发生横向页面溢出。截图位于忽略目录 output/playwright/home-dashboard-*.png。
- 真实平台只读核实了两项响应结构与日期参数的接受格式;没有验证日期范围的服务端统计边界。未部署、未提交。
+21
View File
@@ -0,0 +1,21 @@
# 首页 Pyramid 看板
新增首页导航并作为无 hash 时的默认页面;保留已有页面深链接。
首期仅展示 cnhk 已封装的 Pyramid multipliers 与 Pyramid alphas 数据。
复用后端 WorldQuant 会话及认证重试,浏览器仅访问受系统登录保护的本地接口。
两个资源独立加载、独立报错;分布支持可选起止日期,乘数不受日期筛选影响。
不以缺失值推断零,不自行推断点亮规则;响应结构必须以参考代码或样例为依据。
提供加载、无数据、未连接、失败、刷新状态,支持现有浅色/深色与窄屏布局。
不新增数据库存储、模型工具、外部写入或部署。
验证:上游路径与日期转发、仅 404 回退、错误隔离、登录保护、数据解析;
前端构建及浏览器导航/筛选/刷新/异常与响应式检查。真实接口可用性单独说明。
## 接口核实(2026-09-09)
两个真实接口均只读请求成功。响应的 `pyramids` 列表包含 `category.id/name`、
`region`、`delay`,两类指标分别为 `multiplier` 和 `alphaCount`。
分布接口实测接受 `startDate/endDate=YYYY-MM-DD`,包含时间的 ISO datetime 返回 400。
页面按平台日期原样查询,不擅自转换为浏览器时区或推断统计边界。
浏览器合同仅保留上述字段并统一指标为 `value`,缺失指标保留 null,错误不暴露上游正文。
统计归属次数跨类别累计,不作为去重后的 Alpha 总数。
+1
View File
@@ -37,6 +37,7 @@ class ModelSettingsInput(Contract):
class PageContext(Contract):
page: Literal[
"home",
"alphas",
"account",
"datasets",
+101
View File
@@ -0,0 +1,101 @@
"""Read-only Pyramid dashboard with an allowlisted, stable browser contract."""
import math
from datetime import date, datetime, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from .models import Account
from .security import require_auth
from .worldquant import WqError
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"], dependencies=[Depends(require_auth)])
class PyramidRow(BaseModel):
region: str
delay: int
category_id: str
category_name: str
value: float | int | None
class PyramidSnapshot(BaseModel):
items: list[PyramidRow]
fetched_at: datetime
start_date: date | None = None
end_date: date | None = None
def pyramid_rows(data, metric: Literal["multiplier", "alphaCount"]):
"""Normalize the observed BRAIN schema; absent metrics remain unknown.
Return only category, region, delay and the requested metric. Invalid
identities or nonnumeric metrics raise WqError rather than inventing counts
or silently dropping rows. Unknown upstream fields never reach the browser.
"""
if not isinstance(data, dict) or not isinstance(data.get("pyramids"), list):
raise WqError("Pyramid 数据格式无法识别", "invalid_response")
rows = []
for item in data["pyramids"]:
if not isinstance(item, dict):
raise WqError("Pyramid 数据格式无法识别", "invalid_response")
category = item.get("category")
region, delay, value = item.get("region"), item.get("delay"), item.get(metric)
if (
not isinstance(category, dict)
or not all(isinstance(category.get(key), str) and category[key] for key in ("id", "name"))
or not isinstance(region, str) or not region
or type(delay) is not int or delay < 0
or (value is not None and (
type(value) not in (int, float) or not math.isfinite(value) or value < 0
or (metric == "alphaCount" and type(value) is not int)
))
):
raise WqError("Pyramid 数据格式无法识别", "invalid_response")
rows.append(PyramidRow(
region=region, delay=delay, category_id=category["id"],
category_name=category["name"], value=value,
))
return rows
async def read_pyramids(request, metric, start_date=None, end_date=None):
"""Read via the existing session; platform errors must not expire system login."""
runner = request.app.state.runner
async with request.app.state.sessions() as db:
account = await db.get(Account, 1)
if runner.disconnecting or account.connection_status != "connected" or not account.wq_user_id:
raise HTTPException(409, "请先在个人信息页连接并确认 WorldQuant 账户")
try:
if metric == "multiplier":
data = await runner.client.get_pyramid_multipliers()
else:
# BRAIN accepts calendar dates here and rejects ISO datetime values.
data = await runner.client.get_pyramid_alphas(
start_date.isoformat() if start_date else None,
end_date.isoformat() if end_date else None,
)
return PyramidSnapshot(
items=pyramid_rows(data, metric), fetched_at=datetime.now(timezone.utc),
start_date=start_date, end_date=end_date,
)
except WqError as exc:
raise HTTPException(
409 if exc.code in ("disconnected", "verification_required", "authentication_failed") else 502,
str(exc),
) from None
@router.get("/pyramid-multipliers", response_model=PyramidSnapshot)
async def multipliers(request: Request):
return await read_pyramids(request, "multiplier")
@router.get("/pyramid-alphas", response_model=PyramidSnapshot)
async def alphas(request: Request, start_date: date | None = None, end_date: date | None = None):
if start_date and end_date and start_date > end_date:
raise HTTPException(422, "开始日期不能晚于结束日期")
return await read_pyramids(request, "alphaCount", start_date, end_date)
+2
View File
@@ -22,6 +22,7 @@ from .business import Business, notify_job
from .catalog.research_routes import router as research_catalog_router
from .catalog.routes import router as catalog_router
from .config import Settings
from .dashboard import router as dashboard_router
from .db import create_database
from .jobs import AUTH_KINDS, Runner, create_job
from .mcp_api.token_routes import router as mcp_token_router
@@ -471,6 +472,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
if mcp_runtime:
app.mount("/api/v1/mcp", mcp_runtime.app)
app.include_router(mcp_token_router)
app.include_router(dashboard_router)
app.include_router(backtest_router)
app.include_router(api)
app.include_router(catalog_router)
+27
View File
@@ -312,6 +312,33 @@ class WqClient:
async def profile(self):
return await self.get("/users/self")
async def get_pyramid_multipliers(self):
"""Return current Pyramid multipliers using the shared authenticated session."""
return await self.get("/users/self/activities/pyramid-multipliers")
async def get_pyramid_alphas(self, start_date=None, end_date=None):
"""Return distribution for optional ISO dates; only missing routes trigger fallback.
Paths mirror the cnhk adapter. Access denial, authentication and network
failures must retain their meaning instead of trying unrelated routes.
"""
params = {}
if start_date is not None:
params["startDate"] = start_date
if end_date is not None:
params["endDate"] = end_date
for path in (
"/users/self/activities/pyramid-alphas",
"/users/self/pyramid/alphas",
"/activities/pyramid-alphas",
):
try:
return await self.get(path, params=params)
except WqError as exc:
if exc.code != "not_found":
raise
raise WqError("当前账户的 Pyramid 分布接口不可用", "not_found")
async def account_usage(self):
"""Read independent account resources; unavailable sections do not hide the profile."""
from .account_data import usage_snapshot
+15
View File
@@ -142,6 +142,21 @@ def create_test_app():
)
if path == "/users/self/alphas/summary":
return httpx.Response(200, json={"unsubmitted": 413, "active": 207, "decommissioned": 0})
if path in ("/users/self/activities/pyramid-multipliers", "/users/self/activities/pyramid-alphas"):
metric = "multiplier" if path.endswith("multipliers") else "alphaCount"
categories = [
("analyst", "Analyst", "USA", 1, 1.8, 12),
("fundamental", "Fundamental", "EUR", 1, 1.6, 7),
("model", "Model", "GLB", 1, 1.5, 5),
("sentiment", "Sentiment", "CHN", 0, 1.4, 3),
("news", "News", "ASI", 1, 1.2, 0),
("pv", "Price Volume", "USA", 0, 1.0, 0),
]
return httpx.Response(200, json={"pyramids": [
{"category": {"id": cid, "name": name}, "region": region, "delay": delay,
metric: multiplier if metric == "multiplier" else min(count, 2) if request.url.query else count}
for cid, name, region, delay, multiplier, count in categories
]})
if path in ("/users/self/activities/simulations", "/users/self/activities/submissions"):
from zoneinfo import ZoneInfo
+126
View File
@@ -0,0 +1,126 @@
from datetime import datetime
import httpx
import pytest
from app.dashboard import pyramid_rows
from app.models import Account
from app.worldquant import WqClient, WqError
def pyramid(**extra):
return {"category": {"id": "analyst", "name": "Analyst"}, "region": "USA", "delay": 1, **extra}
async def test_pyramid_read_paths_and_404_fallback(settings):
calls = []
def upstream(request):
calls.append(request)
assert request.method == "GET"
if request.url.path.endswith("pyramid-multipliers"):
return httpx.Response(200, json={"pyramids": [pyramid(multiplier=1.3)]})
if request.url.path != "/activities/pyramid-alphas":
return httpx.Response(404)
return httpx.Response(200, json={"pyramids": [pyramid(alphaCount=0)]})
client = WqClient(settings, transport=httpx.MockTransport(upstream))
client.credentials, client.authenticated = ("test@example.com", "test-only"), True
try:
assert (await client.get_pyramid_multipliers())["pyramids"][0]["multiplier"] == 1.3
data = await client.get_pyramid_alphas("2026-07-01", "2026-07-31")
assert data["pyramids"][0]["alphaCount"] == 0
assert [r.url.path for r in calls] == [
"/users/self/activities/pyramid-multipliers", "/users/self/activities/pyramid-alphas",
"/users/self/pyramid/alphas", "/activities/pyramid-alphas",
]
for request in calls[1:]:
assert dict(request.url.params) == {
"startDate": "2026-07-01", "endDate": "2026-07-31",
}
finally:
await client.close()
@pytest.mark.parametrize("status,code,expected_calls", [(403, "access_denied", 1), (404, "not_found", 3)])
async def test_pyramid_errors_preserve_access_boundary(settings, status, code, expected_calls):
calls = []
def upstream(request):
calls.append(request)
return httpx.Response(status, json={"detail": "private-upstream-body"})
client = WqClient(settings, transport=httpx.MockTransport(upstream))
client.credentials, client.authenticated = ("test@example.com", "test-only"), True
try:
with pytest.raises(WqError) as error:
await client.get_pyramid_alphas()
assert error.value.code == code
assert "private-upstream-body" not in str(error.value)
assert len(calls) == expected_calls
assert all(not request.url.query for request in calls)
finally:
await client.close()
def test_pyramid_normalization_preserves_zero_unknown_and_allowlist():
rows = pyramid_rows({"pyramids": [pyramid(alphaCount=0, token="private"), pyramid()]}, "alphaCount")
assert rows[0].value == 0 and rows[1].value is None
assert "private" not in str(rows)
assert pyramid_rows({"pyramids": []}, "multiplier") == []
@pytest.mark.parametrize("data", [
{}, {"pyramids": {}}, {"pyramids": [None]},
{"pyramids": [pyramid(alphaCount="3")]},
{"pyramids": [pyramid(alphaCount=True)]},
{"pyramids": [pyramid(alphaCount=-1)]},
{"pyramids": [pyramid(alphaCount=2.5)]},
{"pyramids": [pyramid(alphaCount=float("nan"))]},
{"pyramids": [pyramid(category=None)]},
])
def test_pyramid_unknown_shapes_are_not_empty_success(data):
with pytest.raises(WqError, match="格式无法识别"):
pyramid_rows(data, "alphaCount")
async def test_dashboard_auth_connection_dates_and_independent_errors(app, client, logged_in, monkeypatch):
calls = []
async def read_alphas(start_date=None, end_date=None):
calls.append((start_date, end_date))
return {"pyramids": [pyramid(alphaCount=7)], "token": "private"}
async def read_multipliers():
raise WqError("无权访问该平台资源", "access_denied")
monkeypatch.setattr(app.state.runner.client, "get_pyramid_alphas", read_alphas)
monkeypatch.setattr(app.state.runner.client, "get_pyramid_multipliers", read_multipliers)
prefix = "/api/v1/dashboard"
assert (await logged_in.get(f"{prefix}/pyramid-alphas")).status_code == 409
assert calls == []
async with app.state.sessions.begin() as db:
account = await db.get(Account, 1)
account.connection_status, account.wq_user_id = "connected", "TEST_USER"
for query in ("start_date=bad", "start_date=2026-08-02&end_date=2026-08-01"):
assert (await logged_in.get(f"{prefix}/pyramid-alphas?{query}")).status_code == 422
assert calls == []
response = await logged_in.get(f"{prefix}/pyramid-alphas?start_date=2026-07-01&end_date=2026-07-31")
assert response.status_code == 200
assert calls == [("2026-07-01", "2026-07-31")]
result = response.json()
assert result["items"][0] == {
"region": "USA", "delay": 1, "category_id": "analyst", "category_name": "Analyst", "value": 7,
}
assert result["start_date"] == "2026-07-01" and result["end_date"] == "2026-07-31"
assert datetime.fromisoformat(result["fetched_at"]).tzinfo is not None
assert "private" not in response.text
assert (await logged_in.get(f"{prefix}/pyramid-multipliers")).status_code == 502
assert (await logged_in.get("/api/v1/auth/me")).status_code == 200
await logged_in.get(f"{prefix}/pyramid-alphas")
assert calls[-1] == (None, None)
await logged_in.get(f"{prefix}/pyramid-alphas?end_date=2026-07-31")
assert calls[-1] == (None, "2026-07-31")
client.cookies.clear()
for endpoint in ("pyramid-alphas", "pyramid-multipliers"):
assert (await client.get(f"{prefix}/{endpoint}")).status_code == 401
+8
View File
@@ -25,6 +25,7 @@ import { MCPKeysPage } from "./pages/MCPKeysPage";
import { AccountPage } from "./pages/AccountPage";
import { DatasetPage } from "./pages/DatasetPage";
import { AlphaPage } from "./pages/AlphaPage";
import { HomePage } from "./pages/HomePage";
import { JobPanel } from "./components/JobPanel";
import { BacktestPage } from "./backtests/BacktestPage";
import { WorkspacePreferences } from "./components/WorkspacePreferences";
@@ -319,6 +320,12 @@ export default function App() {
<main
className={`page-content ${page !== "account" ? "bounded-page" : "account-page"}`}
>
{page === "home" && (
<HomePage
account={account}
onAccount={() => changePage("account")}
/>
)}
{page === "mcp-keys" && <MCPKeysPage />}
{pollError && (
<Banner
@@ -479,6 +486,7 @@ export default function App() {
onClose={() => setChatOpen(false)}
context={
{
home: { page: "home" as const },
quantflow:
researchContext.page === "quantflow"
? researchContext
+1
View File
@@ -12,6 +12,7 @@ export type ModelSettings = {
};
export type PageContext = {
page:
| "home"
| "alphas"
| "account"
| "datasets"
+2 -3
View File
@@ -5,6 +5,7 @@ const contextLabels: Record<
PageContext["page"],
(context: PageContext) => string
> = {
home: () => "上下文:首页看板",
alphas: (context) =>
`上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`,
operators: () => "上下文:算子库",
@@ -30,9 +31,7 @@ export function pageFromHash(hash: string): WorkspacePage {
const value = hash.replace(/^#/, "").split("?")[0];
const page = value.startsWith("quantflow/") ? "quantflow" : value;
if (page === "mcp-keys") return page;
return Object.hasOwn(contextLabels, page)
? (page as WorkspacePage)
: "alphas";
return Object.hasOwn(contextLabels, page) ? (page as WorkspacePage) : "home";
}
type Destination = {
+34 -28
View File
@@ -16,6 +16,7 @@ import type { Account } from "../types";
import type { WorkspacePage } from "../ai/workspace";
const navigation = [
{ id: "home", label: "首页看板", icon: IconGridView, group: "概览" },
{ id: "datasets", label: "数据目录", icon: IconList, group: "数据与素材" },
{ id: "operators", label: "算子库", icon: IconList, group: "数据与素材" },
{
@@ -173,35 +174,40 @@ export function AppSidebar({
</div>
)}
<nav className="sidebar-nav" aria-label="主导航">
{["数据与素材", "研究实验", "研究编排", "研究成果", "系统管理"].map(
(group) => (
<div key={group}>
{!collapsed && (
<div className="sidebar-group-heading">{group}</div>
)}
{navigation
.filter((item) => item.group === group)
.map(({ id, label, icon: Icon }) => (
<Tooltip
key={id}
content={label}
position="right"
visible={collapsed ? undefined : false}
{[
"概览",
"数据与素材",
"研究实验",
"研究编排",
"研究成果",
"系统管理",
].map((group) => (
<div key={group}>
{!collapsed && (
<div className="sidebar-group-heading">{group}</div>
)}
{navigation
.filter((item) => item.group === group)
.map(({ id, label, icon: Icon }) => (
<Tooltip
key={id}
content={label}
position="right"
visible={collapsed ? undefined : false}
>
<button
className={`sidebar-nav-item ${id === page ? "active" : ""}`}
aria-label={label}
aria-current={id === page ? "page" : undefined}
onClick={() => navigate(id)}
>
<button
className={`sidebar-nav-item ${id === page ? "active" : ""}`}
aria-label={label}
aria-current={id === page ? "page" : undefined}
onClick={() => navigate(id)}
>
<Icon style={{ fontSize: 17 }} />
{!collapsed && <span>{label}</span>}
</button>
</Tooltip>
))}
</div>
),
)}
<Icon style={{ fontSize: 17 }} />
{!collapsed && <span>{label}</span>}
</button>
</Tooltip>
))}
</div>
))}
</nav>
<div className="sidebar-footer">
{!collapsed && (
+376
View File
@@ -0,0 +1,376 @@
import { useEffect, useState } from "react";
import { Banner, Button, Input, Spin } from "@douyinfe/semi-ui-19";
import { api, formatTime } from "../api";
import type { Account } from "../types";
import "./home.css";
type PyramidRow = {
region: string;
delay: number;
category_id: string;
category_name: string;
value: number | null;
};
type Snapshot = {
items: PyramidRow[];
fetched_at: string;
start_date: string | null;
end_date: string | null;
};
function useSnapshot(
path: string,
enabled: boolean,
version: number,
identity: string,
) {
const [data, setData] = useState<Snapshot | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [retry, setRetry] = useState(0);
useEffect(() => {
let ignore = false;
const controller = new AbortController();
setData(null);
setError("");
setLoading(enabled);
if (enabled) {
api<Snapshot>(path, { signal: controller.signal })
.then((next) => {
if (!ignore) setData(next);
})
.catch((e: Error) => {
if (!ignore) setError(e.message);
})
.finally(() => {
if (!ignore) setLoading(false);
});
}
return () => {
ignore = true;
controller.abort();
};
}, [path, enabled, version, identity, retry]);
return {
data: enabled ? data : null,
error: enabled ? error : "",
loading: enabled && loading,
refresh: () => setRetry((value) => value + 1),
};
}
type ResourceState = ReturnType<typeof useSnapshot>;
export function HomePage({
account,
onAccount,
}: {
account: Account | null;
onAccount: () => void;
}) {
const [version, setVersion] = useState(0);
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [period, setPeriod] = useState({ start: "", end: "" });
const connected =
account?.connection_status === "connected" && !!account?.wq_user_id;
const identity = account?.wq_user_id ?? "";
const params = new URLSearchParams();
if (period.start) params.set("start_date", period.start);
if (period.end) params.set("end_date", period.end);
const multipliers = useSnapshot(
"/dashboard/pyramid-multipliers",
connected,
version,
identity,
);
const alphas = useSnapshot(
`/dashboard/pyramid-alphas?${params}`,
connected,
version,
identity,
);
const multiplierValues =
multipliers.data?.items.flatMap((item) =>
item.value === null ? [] : [item.value],
) ?? [];
const counts = alphas.data?.items;
const completeCounts = counts && counts.every((item) => item.value !== null);
const coverage = completeCounts
? counts.filter((item) => item.value! > 0).length
: null;
const total = completeCounts
? counts.reduce((sum, item) => sum + item.value!, 0)
: null;
const invalidDates = !!(start && end && start > end);
const dirtyDates = start !== period.start || end !== period.end;
const metrics = [
["Pyramid 类别", multipliers.data?.items.length, "当前乘数列表中的类别"],
[
"最高乘数",
multiplierValues.length &&
multiplierValues.length === multipliers.data?.items.length
? `${Math.max(...multiplierValues).toLocaleString("zh-CN")}×`
: null,
"平台当前鼓励程度",
],
["已覆盖类别", coverage, "所选范围内 Alpha 数量大于 0"],
["Alpha 归属次数", total, "跨类别累计,同一 Alpha 可能重复计入"],
] as const;
return (
<section className="home-page" aria-label="首页看板">
<header className="home-heading">
<div>
<h1>首页看板</h1>
<p>了解 Pyramid 的平台乘数与 Alpha 分布。</p>
</div>
<Button
disabled={!connected || multipliers.loading || alphas.loading}
onClick={() => setVersion((value) => value + 1)}
>
刷新看板
</Button>
</header>
{!account ? (
<div className="home-empty">
<Spin />
<span>正在读取账户状态…</span>
</div>
) : (
!connected && (
<div className="home-connect">
<div>
<strong>连接 WorldQuant 后查看看板</strong>
<p>请到个人信息页连接账户;如需人工验证,请先完成验证。</p>
</div>
<Button theme="solid" onClick={onAccount}>
前往个人信息
</Button>
</div>
)
)}
<div className="home-metrics">
{metrics.map(([label, value, hint]) => (
<article className="home-metric" key={label} aria-label={label}>
<span>{label}</span>
<strong>
{typeof value === "number"
? value.toLocaleString("zh-CN")
: (value ?? "—")}
</strong>
<small>{hint}</small>
</article>
))}
</div>
<div className="home-panels">
<PyramidPanel
title="Pyramid 乘数"
description="按乘数从高到低排列,日期筛选不影响当前乘数。"
metric="乘数"
state={multipliers}
connected={connected}
timezone={account?.timezone}
/>
<PyramidPanel
title="Alpha 分布"
description="按归属数量从高到低排列;数量为 0 的类别也会保留。"
metric="Alpha 数量"
state={alphas}
connected={connected}
timezone={account?.timezone}
>
<form
className="home-period"
onSubmit={(event) => {
event.preventDefault();
if (invalidDates) return;
if (dirtyDates) setPeriod({ start, end });
else alphas.refresh();
}}
>
<div className="home-date-fields">
<label>
开始日期
<input
aria-label="分布开始日期"
type="date"
value={start}
max={end || "9999-12-31"}
onChange={(event) => setStart(event.target.value)}
/>
</label>
<label>
结束日期
<input
aria-label="分布结束日期"
type="date"
value={end}
min={start || undefined}
max="9999-12-31"
onChange={(event) => setEnd(event.target.value)}
/>
</label>
</div>
<div className="inline-actions">
<Button
htmlType="submit"
disabled={!connected || invalidDates || alphas.loading}
>
应用日期
</Button>
<Button
disabled={!start && !end && !period.start && !period.end}
onClick={() => {
setStart("");
setEnd("");
setPeriod({ start: "", end: "" });
}}
>
恢复默认
</Button>
<small>按平台日期查询;留空使用平台默认范围。</small>
</div>
{invalidDates && <p role="alert">开始日期不能晚于结束日期。</p>}
{dirtyDates && !invalidDates && (
<p className="muted">日期已修改,点击“应用日期”更新分布。</p>
)}
</form>
</PyramidPanel>
</div>
</section>
);
}
function PyramidPanel({
title,
description,
metric,
state,
connected,
timezone,
children,
}: {
title: string;
description: string;
metric: "乘数" | "Alpha 数量";
state: ResourceState;
connected: boolean;
timezone?: string;
children?: React.ReactNode;
}) {
const [search, setSearch] = useState("");
const query = search.trim().toLowerCase();
const rows = (state.data?.items ?? [])
.filter((item) =>
`${item.region} D${item.delay} ${item.category_id} ${item.category_name}`
.toLowerCase()
.includes(query),
)
.sort(
(a, b) =>
(b.value ?? -1) - (a.value ?? -1) ||
a.region.localeCompare(b.region) ||
a.delay - b.delay ||
a.category_id.localeCompare(b.category_id),
);
const maximum = Math.max(1, ...rows.map((row) => row.value ?? 0));
return (
<section
className="home-panel"
aria-label={title}
aria-busy={state.loading}
>
<header>
<h2>{title}</h2>
<p>{description}</p>
</header>
{children}
{state.error ? (
<div className="home-panel-error">
<Banner type="warning" description={state.error} />
<Button onClick={state.refresh}>重试{title}</Button>
</div>
) : state.loading ? (
<div className="home-empty" role="status">
<Spin />
<span>正在加载{title}…</span>
</div>
) : !connected ? (
<div className="home-empty">连接账户后显示{title}。</div>
) : state.data ? (
<>
<div className="home-table-tools">
<Input
aria-label={`搜索${title}`}
value={search}
onChange={setSearch}
placeholder="搜索地区、延迟或类别"
showClear
/>
<span>
{rows.length} / {state.data.items.length} 类
</span>
</div>
<div className="home-table-wrap">
<table>
<thead>
<tr>
<th>地区</th>
<th>延迟</th>
<th>数据类别</th>
<th>{metric}</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr
key={`${row.region}:${row.delay}:${row.category_id}:${index}`}
>
<td>{row.region}</td>
<td>D{row.delay}</td>
<td>
<strong>{row.category_name}</strong>
<small>{row.category_id}</small>
</td>
<td>
<div className="home-value">
{row.value === null
? "—"
: `${row.value.toLocaleString("zh-CN")}${metric === "乘数" ? "×" : ""}`}
</div>
<div className="home-bar" aria-hidden="true">
<i
style={{
width: `${((row.value ?? 0) / maximum) * 100}%`,
}}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
{!rows.length && (
<div className="home-empty">
{state.data.items.length
? "没有匹配的 Pyramid 类别。"
: "平台暂未返回 Pyramid 数据。"}
</div>
)}
</div>
<footer>
{metric === "Alpha 数量" && (
<span>
查询范围:
{state.data.start_date || state.data.end_date
? `${state.data.start_date ?? "不限开始"} 至 ${state.data.end_date ?? "不限结束"}`
: "平台默认"}
</span>
)}
<span>更新于 {formatTime(state.data.fetched_at, timezone)}</span>
</footer>
</>
) : null}
</section>
);
}
+217
View File
@@ -0,0 +1,217 @@
.home-page {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 4px 24px;
}
.home-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.home-heading p,
.home-panel header p,
.home-connect p {
color: var(--muted);
margin-top: 4px;
}
.home-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.home-metric,
.home-panel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
min-width: 0;
}
.home-metric {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
}
.home-metric > span {
color: var(--muted);
}
.home-metric > strong {
font-size: 30px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.3;
}
.home-metric small,
.home-period small,
.home-panel footer {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.home-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr));
gap: 20px;
align-items: start;
}
.home-panel {
overflow: hidden;
}
.home-panel header {
padding: 20px;
border-bottom: 1px solid var(--line);
}
.home-panel header p {
font-size: 12px;
}
.home-connect {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
background: var(--semi-color-primary-light-default);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.home-empty {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 48px 20px;
color: var(--muted);
text-align: center;
}
.home-period {
padding: 16px 20px;
background: var(--canvas);
border-bottom: 1px solid var(--line);
}
.home-date-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.home-date-fields input {
width: 100%;
min-width: 0;
height: 32px;
padding: 4px 8px;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 4px;
}
body[theme-mode="dark"] .home-date-fields input {
color-scheme: dark;
}
.home-table-tools {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
}
.home-table-tools > span {
flex-shrink: 0;
color: var(--muted);
font-size: 12px;
}
.home-table-wrap {
max-height: 420px;
overflow: auto;
}
.home-panel table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
text-align: left;
font-size: 13px;
}
.home-panel th {
position: sticky;
top: 0;
z-index: 1;
background: var(--canvas);
font-weight: 500;
color: var(--muted);
white-space: nowrap;
}
.home-panel td,
.home-panel th {
padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.home-panel td:first-child,
.home-panel td:nth-child(2) {
white-space: nowrap;
}
.home-panel td strong {
font-weight: 400;
overflow-wrap: anywhere;
}
.home-panel td small {
display: block;
margin-top: 3px;
color: var(--muted);
overflow-wrap: anywhere;
}
.home-panel td:last-child {
min-width: 96px;
text-align: right;
}
.home-value {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.home-bar {
height: 3px;
background: var(--canvas);
border-radius: 2px;
margin-top: 6px;
}
.home-bar i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--accent);
opacity: 0.7;
}
.home-panel footer {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--line);
}
.home-panel-error {
display: grid;
justify-items: start;
gap: 16px;
padding: 20px;
}
@media (max-width: 600px) {
.home-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.home-metric {
padding: 14px;
}
.home-metric > strong {
font-size: 26px;
}
.home-heading {
align-items: flex-start;
}
.home-panel td,
.home-panel th {
padding: 10px;
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ test("submission tabs, day selection, local correlation and reload", async ({
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await page.goto("/#alphas");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await expect(
+1 -1
View File
@@ -19,7 +19,7 @@ test("sidebar geometry, recent navigation, user menu and compact tools", async (
await page.getByRole("button", { name: "配置", exact: true }).click();
await expect(page.locator("#model-settings")).toBeInViewport();
await expect(
nav.getByRole("button", { name: "Alpha 管理", exact: true }),
nav.getByRole("button", { name: "首页看板", exact: true }),
).toHaveAttribute("aria-current", "page");
await page.locator(".settings-drawer .semi-sidesheet-close").click();
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
+1 -1
View File
@@ -175,7 +175,7 @@ test("account → scoped sync → research → resync → PnL → filtered expor
test("batch tags, column visibility, server pagination and saved preferences", async ({
page,
}) => {
await page.goto("/");
await page.goto("/#alphas");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await expect(