feat(dashboard): add Eastern-time activity overview and submission calendar

This commit is contained in:
yuxuanhui
2026-09-12 00:04:57 +08:00
parent 5d7c31810e
commit 9372c47580
9 changed files with 1203 additions and 449 deletions
@@ -0,0 +1,18 @@
# 实现首页活动看板
Type: task
Status: ready-for-agent
Implementation: completed
范围与验收见 ../spec.md。
## Comments
- 已核对现有平台 activities 接口及 Semi 2.103.0 组件文档。采用独立只读活动接口,保留缺失日期未知语义。
- 已实现美东秒级时钟、刷新验证任务反馈、今日提交、近 26 周热力日历、严格 > 1 的 Pyramid 搜索清单、含当日的 14 天回测图。新增 Lucide React 1.45.0,复用 Semi 2.103.0。
- 活动统计来自平台 submissions/simulations recordset,缺失日期保持 null;按美东日期独立读取,不以本地回测任务数代替平台活动次数。
- 验证:dashboard/worldquant 54 项通过;Ruff、Prettier、前端构建、git diff --check 通过;既有 sidebar Playwright 回归 1 项通过。
- Playwright CLI 隔离浏览器检查:真实测试服务接口读取、182 个日期格、14 天柱状图、验证任务完成、搜索及无匹配、键盘 Tooltip、独立接口错误/重试、全零/全缺失、390px 无整页横向溢出、正方形日期格、深色主题通过。截图与本轮检查脚本位于忽略目录 output/playwright/home-activity-*。
- 额外 API 回归 13 项通过、1 项失败:既有 test_export_formula_injection_and_detail_variants 的 PnL 断言缺少 series 字段,接口已有该字段,本次未改动 PnL 实现或断言。构建仍有 lottie-web eval 和大 bundle 提示。
- 没有真实平台账户验收、数据库迁移、部署或提交;隔离测试服务和浏览器已关闭。
+13
View File
@@ -19,3 +19,16 @@
页面按平台日期原样查询,不擅自转换为浏览器时区或推断统计边界。
浏览器合同仅保留上述字段并统一指标为 `value`,缺失指标保留 null,错误不暴露上游正文。
统计归属次数跨类别累计,不作为去重后的 Alpha 总数。
## 首页活动看板迭代(2026-09-11)
用户授权本地优化首页,使用 Semi Design 2.103.0 与 Lucide 图标。
- 顶部刷新验证按钮复用账户验证任务;完成后刷新看板,人工验证与错误状态可见。
- 当前美东时间按 America/New_York 每秒显示 yyyy-MM-dd HH:mm:ss,跨日自动刷新统计。
- 今日已提交 Alpha 按平台 submissions 活动统计,近 26 周提交热力日历按周列排列,提供日期和次数提示。
- Pyramid 仅显示乘数严格大于 1 的类别,降序排列并支持搜索。
- 回测图显示包含美东当天在内的连续 14 天,采用平台 simulations 活动数据。
- 平台缺失日期保留未知,明确零值与未知;独立接口错误不遮挡其他区块。
验证:活动响应结构、时区/夏令时跨日、缺日与零、鉴权及错误隔离;前端构建、已有导航回归及隔离浏览器验证桌面/移动端/深色主题。无需数据库迁移,不访问真实账户做写操作。
+74 -2
View File
@@ -1,8 +1,9 @@
"""Read-only Pyramid dashboard with an allowlisted, stable browser contract."""
"""Read-only platform dashboard with an allowlisted, stable browser contract."""
import math
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from typing import Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
@@ -12,6 +13,77 @@ from .security import require_auth
from .worldquant import WqError
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"], dependencies=[Depends(require_auth)])
EASTERN = ZoneInfo("America/New_York")
class ActivityDay(BaseModel):
date: date
count: int | None
class ActivitySnapshot(BaseModel):
items: list[ActivityDay]
today: date
timezone: str = "America/New_York"
fetched_at: datetime
def activity_snapshot(data, days: int, at: datetime) -> ActivitySnapshot:
"""Return platform calendar counts, keeping absent dates unknown.
BRAIN activity dates already denote Eastern calendar days. Never interpret
them as UTC instants or replace missing records with zero. Malformed or
duplicate rows raise WqError rather than displaying misleading totals.
"""
today = at.astimezone(EASTERN).date()
start = today - timedelta(days=days - 1)
recordset = data.get("records") if isinstance(data, dict) else None
schema = recordset.get("schema") if isinstance(recordset, dict) else None
properties = schema.get("properties") if isinstance(schema, dict) else None
records = recordset.get("records") if isinstance(recordset, dict) else None
if not isinstance(properties, list) or not isinstance(records, list):
raise WqError("平台活动数据格式无法识别", "invalid_response")
names = [prop.get("name") if isinstance(prop, dict) else prop for prop in properties]
if names.count("date") != 1 or names.count("value") != 1:
raise WqError("平台活动数据格式无法识别", "invalid_response")
date_index, value_index = names.index("date"), names.index("value")
counts = {}
for row in records:
if not isinstance(row, list) or len(row) <= max(date_index, value_index):
raise WqError("平台活动数据格式无法识别", "invalid_response")
raw_date, value = row[date_index], row[value_index]
try:
day = date.fromisoformat(raw_date)
if day.isoformat() != raw_date:
raise ValueError
except (TypeError, ValueError):
raise WqError("平台活动日期格式无法识别", "invalid_response") from None
if day in counts or (value is not None and (type(value) is not int or value < 0)):
raise WqError("平台活动次数格式无法识别", "invalid_response")
counts[day] = value
return ActivitySnapshot(
items=[ActivityDay(date=day, count=counts.get(day))
for offset in range(days) for day in [start + timedelta(days=offset)]],
today=today, fetched_at=at,
)
@router.get("/activity/{metric}", response_model=ActivitySnapshot)
async def activity(request: Request, metric: Literal["submissions", "simulations"]):
"""Read each activity independently using the bound platform session."""
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:
data = await runner.client.get(f"/users/self/activities/{metric}")
return activity_snapshot(data, 182 if metric == "submissions" else 14, datetime.now(timezone.utc))
except WqError as exc:
raise HTTPException(
409 if exc.code in ("disconnected", "verification_required", "authentication_failed") else 502,
str(exc),
) from None
class PyramidRow(BaseModel):
+90
View File
@@ -124,3 +124,93 @@ async def test_dashboard_auth_connection_dates_and_independent_errors(app, clien
client.cookies.clear()
for endpoint in ("pyramid-alphas", "pyramid-multipliers"):
assert (await client.get(f"{prefix}/{endpoint}")).status_code == 401
def activity_data(rows, properties=None):
return {"records": {"schema": {"properties": properties or ["date", "value"]}, "records": rows}}
def test_activity_calendar_preserves_missing_zero_and_column_order():
from app.dashboard import activity_snapshot
at = datetime.fromisoformat("2026-09-11T02:00:00+00:00")
data = activity_data([
[3, "2026-09-10", "private"], [0, "2026-09-09", "private"],
[99, "2026-09-11", "future"], [15, "2025-01-01", "old"],
], [{"name": "value"}, {"name": "date"}, {"name": "secret"}])
result = activity_snapshot(data, 14, at)
assert result.today.isoformat() == "2026-09-10"
assert len(result.items) == 14 and result.items[0].date.isoformat() == "2026-08-28"
assert [day.count for day in result.items[-3:]] == [None, 0, 3]
assert "private" not in result.model_dump_json()
assert result.timezone == "America/New_York"
assert len(activity_snapshot(data, 182, at).items) == 182
@pytest.mark.parametrize("timestamp,today", [
("2026-03-08T04:59:59+00:00", "2026-03-07"),
("2026-03-08T05:00:00+00:00", "2026-03-08"),
("2026-03-09T04:00:00+00:00", "2026-03-09"),
("2026-11-01T04:00:00+00:00", "2026-11-01"),
("2026-11-02T04:59:59+00:00", "2026-11-01"),
("2026-11-02T05:00:00+00:00", "2026-11-02"),
("2027-01-01T04:00:00+00:00", "2026-12-31"),
])
def test_activity_uses_eastern_calendar_across_dst_and_year(timestamp, today):
from app.dashboard import activity_snapshot
result = activity_snapshot(activity_data([]), 14, datetime.fromisoformat(timestamp))
assert result.today.isoformat() == today
assert all(day.count is None for day in result.items)
assert len({day.date for day in result.items}) == 14
assert (result.items[-1].date - result.items[0].date).days == 13
@pytest.mark.parametrize("data", [
{}, {"records": None}, activity_data([None]), activity_data([["2026-09-10"]]),
activity_data([["invalid", 1]]), activity_data([["20260910", 1]]),
activity_data([["2026-09-10", True]]), activity_data([["2026-09-10", -1]]),
activity_data([["2026-09-10", 1.5]]), activity_data([["2026-09-10", "3"]]),
activity_data([["2026-09-10", 1], ["2026-09-10", 2]]),
activity_data([], ["date", "date", "value"]), activity_data([], ["date"]),
])
def test_activity_rejects_malformed_or_ambiguous_counts(data):
from app.dashboard import activity_snapshot
with pytest.raises(WqError, match="格式无法识别"):
activity_snapshot(data, 14, datetime.fromisoformat("2026-09-11T12:00:00+00:00"))
async def test_activity_endpoint_auth_connection_and_independent_failures(app, client, logged_in, monkeypatch):
calls = []
async def read(path):
calls.append(path)
if path.endswith("simulations"):
raise WqError("无权访问该平台资源", "access_denied")
return activity_data([])
monkeypatch.setattr(app.state.runner.client, "get", read)
prefix = "/api/v1/dashboard/activity"
assert (await logged_in.get(f"{prefix}/submissions")).status_code == 409
assert not 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"
assert (await logged_in.get(f"{prefix}/arbitrary")).status_code == 422
assert not calls
response = await logged_in.get(f"{prefix}/submissions")
assert response.status_code == 200 and len(response.json()["items"]) == 182
assert all(day["count"] is None for day in response.json()["items"])
assert (await logged_in.get(f"{prefix}/simulations")).status_code == 502
assert calls == ["/users/self/activities/submissions", "/users/self/activities/simulations"]
assert (await logged_in.get("/api/v1/auth/me")).status_code == 200
async def waiting(path):
raise WqError("需要人工验证", "verification_required")
monkeypatch.setattr(app.state.runner.client, "get", waiting)
assert (await logged_in.get(f"{prefix}/submissions")).status_code == 409
client.cookies.clear()
assert (await client.get(f"{prefix}/submissions")).status_code == 401
assert (await client.get(f"{prefix}/simulations")).status_code == 401
+1
View File
@@ -16,6 +16,7 @@
"@douyinfe/semi-icons": "2.103.0",
"@douyinfe/semi-ui-19": "2.103.0",
"ai": "6.0.277",
"lucide-react": "1.45.0",
"markstream-react": "2.0.8",
"react": "19.2.8",
"react-dom": "19.2.8"
+12
View File
@@ -20,6 +20,9 @@ importers:
ai:
specifier: 6.0.277
version: 6.0.277(zod@4.5.4)
lucide-react:
specifier: 1.45.0
version: 1.45.0(react@19.2.8)
markstream-react:
specifier: 2.0.8
version: 2.0.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -945,6 +948,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.45.0:
resolution: {integrity: sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
markdown-extensions@2.0.0:
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
engines: {node: '>=16'}
@@ -2452,6 +2460,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@1.45.0(react@19.2.8):
dependencies:
react: 19.2.8
markdown-extensions@2.0.0: {}
markdown-it-container@4.0.0: {}
+2
View File
@@ -370,6 +370,8 @@ export default function App() {
>
{page === "home" && (
<HomePage
jobs={jobs}
onChange={actionDone}
account={account}
onAccount={() => changePage("account")}
/>
+563 -289
View File
@@ -1,7 +1,27 @@
import { useEffect, useState } from "react";
import { Banner, Button, Input, Spin, Table } from "@douyinfe/semi-ui-19";
import { api, formatTime } from "../api";
import type { Account } from "../types";
import { useEffect, useState, type ReactNode } from "react";
import {
Banner,
Button,
Card,
Input,
Spin,
Table,
Tag,
Toast,
Tooltip,
} from "@douyinfe/semi-ui-19";
import {
BarChart3,
CalendarDays,
Clock3,
Layers3,
RefreshCw,
Search,
Send,
ShieldCheck,
} from "lucide-react";
import { api, post } from "../api";
import type { Account, Job } from "../types";
import "./home.css";
type PyramidRow = {
@@ -11,126 +31,202 @@ type PyramidRow = {
category_name: string;
value: number | null;
};
type Snapshot = {
items: PyramidRow[];
type PyramidSnapshot = { items: PyramidRow[]; fetched_at: string };
type ActivityDay = { date: string; count: number | null };
type ActivitySnapshot = {
items: ActivityDay[];
today: string;
timezone: string;
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 easternFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
function easternTime(date: Date) {
const parts = Object.fromEntries(
easternFormatter
.formatToParts(date)
.map(({ type, value }) => [type, value]),
);
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
}
function useResource<T>(path: string, enabled: boolean, revision: string) {
const [result, setResult] = useState<{
key: string;
data?: T;
error?: string;
}>();
const [retry, setRetry] = useState(0);
const key = `${path}:${revision}:${enabled}:${retry}`;
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);
});
if (!enabled) {
setResult(undefined);
return;
}
return () => {
ignore = true;
controller.abort();
};
}, [path, enabled, version, identity, retry]);
const controller = new AbortController();
api<T>(path, { signal: controller.signal })
.then((data) => {
if (!controller.signal.aborted) setResult({ key, data });
})
.catch((error: Error) => {
if (!controller.signal.aborted)
setResult({ key, error: error.message });
});
return () => controller.abort();
}, [path, enabled, key]);
// Keyed results prevent another account or yesterday's totals flashing while a new read starts.
const current = enabled && result?.key === key ? result : undefined;
return {
data: enabled ? data : null,
error: enabled ? error : "",
loading: enabled && loading,
refresh: () => setRetry((value) => value + 1),
data: current?.data,
error: current?.error,
loading: enabled && !current,
refresh: () => setRetry((n) => n + 1),
};
}
type ResourceState = ReturnType<typeof useSnapshot>;
type ResourceState = { error?: string; loading: boolean; refresh: () => void };
export function HomePage({
account,
jobs,
onAccount,
onChange,
}: {
account: Account | null;
jobs: Job[];
onAccount: () => void;
onChange: () => void;
}) {
const [now, setNow] = useState(() => new Date());
const [version, setVersion] = useState(0);
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [period, setPeriod] = useState({ start: "", end: "" });
const [sending, setSending] = useState(false);
const [verificationId, setVerificationId] = useState<string | null>(null);
const [verificationError, setVerificationError] = useState("");
const [search, setSearch] = useState("");
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
const time = easternTime(now);
const today = time.slice(0, 10);
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(
account?.connection_status === "connected" && !!account.wq_user_id;
const revision = `${account?.wq_user_id ?? ""}:${today}:${version}`;
const submissions = useResource<ActivitySnapshot>(
"/dashboard/activity/submissions",
connected,
revision,
);
const simulations = useResource<ActivitySnapshot>(
"/dashboard/activity/simulations",
connected,
revision,
);
const multipliers = useResource<PyramidSnapshot>(
"/dashboard/pyramid-multipliers",
connected,
version,
identity,
revision,
);
const alphas = useSnapshot(
`/dashboard/pyramid-alphas?${params}`,
connected,
version,
identity,
const verification = jobs.find((job) => job.id === verificationId);
const authRunning = jobs.some(
(job) =>
["connect", "verify", "profile"].includes(job.kind) &&
["queued", "running"].includes(job.status),
);
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
const busy = sending || authRunning || !!verificationId;
useEffect(() => {
if (!verification || ["queued", "running"].includes(verification.status))
return;
setVerificationId(null);
if (verification.status === "completed") {
setVersion((n) => n + 1);
Toast.success("验证完成,正在更新看板");
} else {
setVerificationError(
verification.error || "验证尚未完成,请到个人信息页查看账户状态。",
);
}
}, [verification]);
async function refreshVerification() {
setSending(true);
setVerificationError("");
try {
const job = await post<Job>("/account/verify");
setVerificationId(job.id);
onChange();
} catch (error) {
setVerificationError((error as Error).message);
} finally {
setSending(false);
}
}
const eligible = (multipliers.data?.items ?? [])
.filter((row) => row.value !== null && row.value > 1)
.sort(
(a, b) =>
b.value! - a.value! ||
a.region.localeCompare(b.region) ||
a.delay - b.delay ||
a.category_id.localeCompare(b.category_id),
);
const query = search.trim().toLowerCase();
const rows = eligible.filter((row) =>
`${row.region} D${row.delay} ${row.category_id} ${row.category_name}`
.toLowerCase()
.includes(query),
);
const submittedToday =
submissions.data?.today === today
? submissions.data.items.find((day) => day.date === today)?.count
: 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;
const knownSimulations =
simulations.data?.items.filter((day) => day.count !== null) ?? [];
const simulationTotal = knownSimulations.reduce(
(sum, day) => sum + day.count!,
0,
);
const unknownMultipliers =
multipliers.data?.items.filter((row) => row.value === null).length ?? 0;
return (
<section className="home-page" aria-label="首页看板">
<header className="home-heading">
<div>
<h1>首页看板</h1>
<p>了解 Pyramid 的平台乘数与 Alpha 分布。</p>
<p>跟踪提交进展,发现研究机会。</p>
</div>
<div className="home-heading-actions">
<div className="home-clock">
<span>
<Clock3 size={14} aria-hidden="true" />
当前美东时间
</span>
<time dateTime={now.toISOString()}>{time}</time>
</div>
<Button
disabled={!connected || multipliers.loading || alphas.loading}
onClick={() => setVersion((value) => value + 1)}
theme="solid"
icon={<RefreshCw size={16} aria-hidden="true" />}
loading={busy}
disabled={
!account?.configured ||
busy ||
account.connection_status === "connecting"
}
onClick={() => void refreshVerification()}
>
刷新看板
刷新验证
</Button>
</div>
</header>
{verificationError && (
<Banner type="warning" description={verificationError} />
)}
{!account ? (
<div className="home-empty">
<Spin />
@@ -139,177 +235,130 @@ export function HomePage({
) : (
!connected && (
<div className="home-connect">
<ShieldCheck size={24} aria-hidden="true" />
<div>
<strong>连接 WorldQuant 后查看看板</strong>
<p>请到个人信息页连接账户;如需人工验证,请先完成验证。</p>
<p>
{account.connection_error ||
"请先配置账户并完成验证,以读取平台活动数据。"}
</p>
</div>
<Button theme="solid" onClick={onAccount}>
前往个人信息
</Button>
<Button onClick={onAccount}>前往个人信息</Button>
</div>
)
)}
{account?.verification_url && (
<Banner
type="warning"
description={
<span>
请先
<a
href={account.verification_url}
target="_blank"
rel="noreferrer"
>
打开 WorldQuant 验证页面
</a>
,完成后点击“刷新验证”。
</span>
}
/>
)}
<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>
))}
<Metric
label="今日已提交 Alpha"
icon={<Send size={18} aria-hidden="true" />}
value={submittedToday}
unit="个"
hint={`${today} · 美东自然日`}
loading={submissions.loading}
/>
<Metric
label="近 14 天回测"
icon={<BarChart3 size={18} aria-hidden="true" />}
value={knownSimulations.length ? simulationTotal : null}
unit="次"
hint={
knownSimulations.length === 14
? "平台回测活动累计"
: "已返回日期合计 · 缺失日期不计入"
}
loading={simulations.loading}
/>
<Metric
label="Pyramid 加成类别"
icon={<Layers3 size={18} aria-hidden="true" />}
value={multipliers.data ? eligible.length : null}
unit="类"
hint="当前乘数 > 1 的地区 / 延迟 / 类别"
loading={multipliers.loading}
/>
</div>
<Card
className="home-card home-calendar-card"
title={
<PanelTitle
icon={<CalendarDays size={18} aria-hidden="true" />}
title="Alpha 提交日历"
/>
}
headerExtraContent={<Tag>近 26 周</Tag>}
>
<ResourceContent
state={submissions}
connected={connected}
name="提交日历"
>
{submissions.data && <SubmissionCalendar data={submissions.data} />}
</ResourceContent>
</Card>
<div className="home-panels">
<PyramidPanel
<Card
className="home-card"
title={
<PanelTitle
icon={<BarChart3 size={18} aria-hidden="true" />}
title="回测次数"
/>
}
headerExtraContent={<Tag>近 14 天</Tag>}
>
<ResourceContent
state={simulations}
connected={connected}
name="回测次数"
>
{simulations.data && <SimulationChart data={simulations.data} />}
</ResourceContent>
</Card>
<Card
className="home-card home-pyramid-card"
title={
<PanelTitle
icon={<Layers3 size={18} aria-hidden="true" />}
title="Pyramid 乘数"
description="按乘数从高到低排列,日期筛选不影响当前乘数。"
metric="乘数"
/>
}
headerExtraContent={<Tag color="blue">乘数 &gt; 1</Tag>}
>
<ResourceContent
state={multipliers}
connected={connected}
timezone={account?.timezone}
/>
<PyramidPanel
title="Alpha 分布"
description="按归属数量从高到低排列;数量为 0 的类别也会保留。"
metric="Alpha 数量"
state={alphas}
connected={connected}
timezone={account?.timezone}
name="Pyramid 乘数"
>
<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 ? (
{multipliers.data && (
<>
<div className="home-table-tools">
<Input
aria-label={`搜索${title}`}
aria-label="搜索 Pyramid 类别"
prefix={<Search size={16} aria-hidden="true" />}
value={search}
onChange={setSearch}
placeholder="搜索地区、延迟或类别"
showClear
placeholder="搜索地区、延迟或类别"
/>
<span>
{rows.length} / {state.data.items.length} 类
</span>
<span>{rows.length} 类</span>
</div>
<div className="home-table-wrap">
<Table<PyramidRow>
@@ -320,73 +369,298 @@ function PyramidPanel({
pagination={false}
size="small"
sticky
style={{ minWidth: 440 }}
empty={
state.data.items.length
? "没有匹配的 Pyramid 类别。"
: "平台暂未返回 Pyramid 数据。"
eligible.length
? "没有匹配的 Pyramid 类别"
: "当前没有乘数大于 1 的类别"
}
columns={[
{
title: "地区",
key: "region",
width: 64,
render: (_, row) => row.region,
title: "数据类别",
dataIndex: "category_name",
render: (_, row) => (
<div className="home-category">
<strong>{row.category_name}</strong>
<small>{row.category_id}</small>
</div>
),
},
{ title: "地区", dataIndex: "region", width: 68 },
{
title: "延迟",
key: "delay",
width: 64,
render: (_, row) => <>D{row.delay}</>,
width: 58,
render: (_, row) => `D${row.delay}`,
},
{
title: "数据类别",
key: "category",
render: (_, row) => (
<>
<strong>{row.category_name}</strong>
<small>{row.category_id}</small>
</>
),
},
{
title: metric,
title: "乘数",
key: "value",
width: 110,
width: 80,
align: "right",
render: (_, row) => (
<>
<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>
</>
<Tag color="blue">
{row.value?.toLocaleString("zh-CN")}×
</Tag>
),
},
]}
/>
</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>
<p className="home-footnote">
按乘数从高到低排列
{unknownMultipliers
? ` · ${unknownMultipliers} 类乘数未知,未列入`
: ""}{" "}
· 更新于 {easternTime(new Date(multipliers.data.fetched_at))}{" "}
美东
</p>
</>
) : null}
)}
</ResourceContent>
</Card>
</div>
</section>
);
}
function PanelTitle({ icon, title }: { icon: ReactNode; title: string }) {
return (
<h2 className="home-panel-title">
{icon}
{title}
</h2>
);
}
function Metric({
label,
value,
unit,
hint,
icon,
loading,
}: {
label: string;
value: number | null | undefined;
unit: string;
hint: string;
icon: ReactNode;
loading: boolean;
}) {
return (
<Card className="home-metric" aria-label={label}>
<div className="home-metric-label">
<span>{label}</span>
<span className="home-metric-icon">{icon}</span>
</div>
<div className="home-metric-value">
{loading ? (
<Spin size="small" />
) : (
<>
<strong>
{value == null ? "—" : value.toLocaleString("zh-CN")}
</strong>
{value != null && <span>{unit}</span>}
</>
)}
</div>
<p>{hint}</p>
</Card>
);
}
function ResourceContent({
state,
connected,
name,
children,
}: {
state: ResourceState;
connected: boolean;
name: string;
children: ReactNode;
}) {
if (!connected)
return <div className="home-empty">连接账户后显示{name}。</div>;
if (state.error)
return (
<div className="home-panel-error">
<Banner type="warning" description={state.error} />
<Button onClick={state.refresh}>重试{name}</Button>
</div>
);
if (state.loading)
return (
<div className="home-empty" role="status">
<Spin />
<span>正在加载{name}…</span>
</div>
);
return children;
}
function level(count: number | null) {
return count === null ? "unknown" : String(Math.min(count, 4));
}
function SubmissionCalendar({ data }: { data: ActivitySnapshot }) {
const first = new Date(`${data.items[0].date}T00:00:00Z`);
const offset = first.getUTCDay();
const cells: (ActivityDay | null)[] = [
...Array<null>(offset).fill(null),
...data.items,
];
const weeks = Array.from(
{ length: Math.ceil(cells.length / 7) },
(_, index) => cells.slice(index * 7, index * 7 + 7),
);
const known = data.items.filter((day) => day.count !== null);
const total = known.reduce((sum, day) => sum + day.count!, 0);
const activeDays = known.filter((day) => day.count! > 0).length;
let lastMonth = "";
return (
<>
<div className="home-calendar-summary">
<span>
{known.length ? (
<>
<strong>{total.toLocaleString("zh-CN")}</strong> 次提交 ·{" "}
<strong>{activeDays}</strong> 个活跃日
</>
) : (
"暂无提交记录"
)}
</span>
<span>
{data.items[0].date} 至 {data.today}
</span>
</div>
<div className="home-calendar-scroll">
<div className="home-calendar">
<div className="home-weekdays" aria-hidden="true">
{["", "一", "", "三", "", "五", ""].map((day, i) => (
<span key={i}>{day}</span>
))}
</div>
<div className="home-calendar-weeks">
{weeks.map((week, index) => {
const month =
(
week.find((day) => day?.date.endsWith("-01")) ??
week.find(Boolean)
)?.date.slice(0, 7) ?? "";
const monthLabel =
month !== lastMonth ? `${Number(month.slice(5))}月` : "";
lastMonth = month;
return (
<div className="home-calendar-week" key={index}>
<span className="home-month">{monthLabel}</span>
{week.map((day, row) =>
day ? (
<Tooltip
key={day.date}
content={`${day.date} · ${day.count === null ? "暂无记录" : `提交 ${day.count} 个 Alpha`}`}
>
<button
type="button"
className={`home-heat-cell heat-${level(day.count)}`}
aria-label={`${day.date}:${day.count === null ? "暂无提交记录" : `提交 ${day.count} 个 Alpha`}`}
aria-current={
day.date === data.today ? "date" : undefined
}
/>
</Tooltip>
) : (
<span className="home-heat-spacer" key={`empty-${row}`} />
),
)}
</div>
);
})}
</div>
</div>
</div>
<div className="home-calendar-bottom">
<p>按平台提交活动统计 · 美东日期 · 数据可能延迟</p>
<div className="home-legend">
<i className="heat-unknown" />
暂无记录<span>少</span>
{[0, 1, 2, 3, 4].map((n) => (
<i
key={n}
className={`heat-${n}`}
title={n === 4 ? "4 次及以上" : `${n} 次`}
/>
))}
<span>多</span>
</div>
</div>
</>
);
}
function SimulationChart({ data }: { data: ActivitySnapshot }) {
const maximum = Math.max(1, ...data.items.map((day) => day.count ?? 0));
const magnitude = 10 ** Math.floor(Math.log10(maximum / 4));
const step = Math.max(
1,
[1, 2, 2.5, 5, 10].find((factor) => factor * magnitude >= maximum / 4)! *
magnitude,
);
const ceiling = step * 4;
return (
<>
<p className="home-chart-description">平台每日回测活动 · 美东日期</p>
<div className="home-chart-scroll">
<div
className="home-chart"
role="group"
aria-label="近 14 天回测次数柱状图"
>
<div className="home-y-axis" aria-hidden="true">
{[4, 3, 2, 1, 0].map((tick) => (
<span key={tick}>{(tick * step).toLocaleString("zh-CN")}</span>
))}
</div>
<div className="home-chart-plot">
<div className="home-chart-grid" aria-hidden="true">
{[0, 1, 2, 3, 4].map((tick) => (
<i key={tick} />
))}
</div>
<div className="home-chart-bars">
{data.items.map((day) => (
<div className="home-chart-column" key={day.date}>
<Tooltip
content={`${day.date} · ${day.count === null ? "暂无记录" : `回测 ${day.count.toLocaleString("zh-CN")} 次`}`}
>
<button
type="button"
className={`home-chart-bar ${day.count === null ? "is-unknown" : ""} ${day.date === data.today ? "is-today" : ""}`}
style={{
height: day.count
? `${(day.count / ceiling) * 100}%`
: 3,
}}
aria-label={`${day.date}:${day.count === null ? "暂无回测记录" : `回测 ${day.count} 次`}`}
>
<span>
{day.count === null
? "—"
: day.count.toLocaleString("zh-CN")}
</span>
</button>
</Tooltip>
<span
className={`home-x-label ${day.date === data.today ? "is-today" : ""}`}
>
{day.date.slice(5).replace("-", "/")}
</span>
</div>
))}
</div>
</div>
</div>
</div>
<p className="home-footnote">
包含今日;“—”表示平台暂无记录,0 表示当日无回测。
</p>
</>
);
}
+393 -121
View File
@@ -2,183 +2,455 @@
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 4px 24px;
padding: 12px 8px 24px;
}
.home-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
gap: 20px;
margin-bottom: 24px;
}
.home-heading p,
.home-panel header p,
.home-connect p {
.home-heading p {
color: var(--muted);
margin-top: 4px;
font-size: 13px;
}
.home-heading-actions {
display: flex;
align-items: center;
gap: 20px;
}
.home-clock {
display: flex;
flex-direction: column;
gap: 5px;
}
.home-clock > span {
display: flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-size: 12px;
}
.home-clock time {
font-size: 14px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.home-page > .semi-banner {
margin-bottom: 16px;
}
.home-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
margin-bottom: 20px;
}
.home-metric,
.home-panel {
border: 1px solid var(--line);
border-radius: 8px;
.home-metric.semi-card,
.home-card.semi-card {
background: var(--surface);
border-color: var(--line);
border-radius: 8px;
min-width: 0;
box-shadow: none;
}
.home-metric {
.home-metric .semi-card-body {
padding: 18px 20px;
}
.home-metric-label {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
}
.home-metric > span {
justify-content: space-between;
align-items: center;
gap: 10px;
color: var(--muted);
}
.home-metric > strong {
font-size: 30px;
.home-metric-icon {
display: flex;
padding: 8px;
border-radius: 8px;
color: var(--accent);
background: var(--semi-color-primary-light-default);
}
.home-metric-value {
display: flex;
align-items: baseline;
gap: 8px;
min-height: 53px;
padding-top: 4px;
}
.home-metric-value strong {
font-size: 34px;
line-height: 1.35;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.3;
}
.home-metric small,
.home-period small,
.home-panel footer {
.home-metric-value > span,
.home-metric p {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.home-card .semi-card-header {
padding: 16px 20px;
}
.home-card .semi-card-body {
padding: 20px;
}
.home-panel-title {
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 600;
}
.home-panel-title svg {
color: var(--muted);
flex-shrink: 0;
}
.home-calendar-card {
margin-bottom: 20px;
}
.home-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr));
grid-template-columns: minmax(0, 1.2fr) minmax(0, 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-connect > div {
flex: 1;
}
.home-connect p {
margin-top: 4px;
color: var(--muted);
}
.home-empty {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 48px 20px;
min-height: 180px;
padding: 24px;
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 td strong {
font-weight: 400;
overflow-wrap: anywhere;
}
.home-panel td small {
display: block;
margin-top: 3px;
color: var(--muted);
overflow-wrap: anywhere;
}
.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;
min-height: 180px;
}
.home-calendar-summary {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 18px;
color: var(--muted);
font-size: 12px;
}
.home-calendar-summary strong {
color: var(--ink);
font-size: 16px;
font-weight: 600;
}
.home-calendar-scroll {
overflow-x: auto;
padding: 3px 2px 5px;
}
.home-calendar {
display: flex;
gap: 10px;
min-width: 550px;
max-width: 840px;
margin: 0 auto;
}
.home-weekdays {
display: grid;
grid-template-rows: repeat(7, 1fr);
gap: 5px;
padding-top: 25px;
color: var(--muted);
font-size: 11px;
}
.home-weekdays span {
display: flex;
align-items: center;
}
.home-calendar-weeks {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(14px, 1fr);
gap: 5px;
flex: 1;
}
.home-calendar-week {
display: grid;
grid-template-rows: 20px repeat(7, 1fr);
gap: 5px;
}
.home-month {
color: var(--muted);
white-space: nowrap;
font-size: 11px;
}
.home-heat-cell,
.home-heat-spacer {
width: 100%;
aspect-ratio: 1;
border-radius: 3px;
}
.home-heat-cell {
border: 1px solid rgb(0 0 0 / 4%);
padding: 0;
}
.home-heat-cell:hover {
outline: 1px solid var(--muted);
outline-offset: 1px;
}
.home-heat-cell[aria-current="date"] {
outline: 1px solid var(--accent);
outline-offset: 1px;
}
.heat-unknown {
background: repeating-linear-gradient(
135deg,
var(--surface),
var(--surface) 3px,
var(--semi-color-fill-0) 3px,
var(--semi-color-fill-0) 5px
);
border: 1px solid var(--line);
}
.heat-0 {
background: var(--semi-color-fill-0);
}
.heat-1 {
background: #c2e7b6;
}
.heat-2 {
background: #83cb7a;
}
.heat-3 {
background: #4da663;
}
.heat-4 {
background: #287c47;
}
body[theme-mode="dark"] .heat-1 {
background: #244f32;
}
body[theme-mode="dark"] .heat-2 {
background: #347644;
}
body[theme-mode="dark"] .heat-3 {
background: #45a05a;
}
body[theme-mode="dark"] .heat-4 {
background: #73c888;
}
.home-calendar-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: 14px;
font-size: 11px;
color: var(--muted);
}
.home-legend {
display: flex;
align-items: center;
gap: 4px;
}
.home-legend i {
display: inline-block;
width: 11px;
height: 11px;
border-radius: 2px;
}
.home-legend > span {
margin: 0 4px;
}
.home-table-tools {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.home-table-tools > span {
color: var(--muted);
font-size: 12px;
flex-shrink: 0;
}
.home-table-wrap {
max-height: 300px;
overflow: auto;
}
.home-category {
overflow-wrap: anywhere;
}
.home-category strong {
font-weight: 400;
}
.home-category small {
display: block;
margin-top: 3px;
color: var(--muted);
}
.home-footnote {
color: var(--muted);
font-size: 11px;
margin-top: 16px;
}
.home-chart-description {
font-size: 12px;
color: var(--muted);
margin-bottom: 30px;
}
.home-chart-scroll {
overflow-x: auto;
padding: 0 2px 5px;
}
.home-chart {
display: flex;
min-width: 470px;
height: 260px;
padding-top: 20px;
padding-bottom: 28px;
gap: 10px;
}
.home-y-axis {
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 30px;
color: var(--muted);
font-size: 10px;
position: relative;
top: -6px;
}
.home-chart-plot {
flex: 1;
position: relative;
min-width: 0;
}
.home-chart-grid {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.home-chart-grid i {
border-top: 1px dashed var(--line);
}
.home-chart-grid i:last-child {
border-top-style: solid;
}
.home-chart-bars {
position: relative;
display: grid;
grid-template-columns: repeat(14, minmax(0, 1fr));
gap: 8px;
height: 100%;
}
.home-chart-column {
display: flex;
align-items: center;
justify-content: flex-end;
flex-direction: column;
min-width: 0;
height: 100%;
position: relative;
}
.home-chart-bar {
width: 70%;
max-width: 26px;
background: var(--accent);
opacity: 0.7;
border: 0;
border-radius: 3px 3px 0 0;
padding: 0;
position: relative;
flex-shrink: 0;
}
.home-chart-bar.is-today,
.home-chart-bar:hover,
.home-chart-bar:focus-visible {
opacity: 1;
}
.home-chart-bar.is-unknown {
background: var(--line);
}
.home-chart-bar > span {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
color: var(--ink);
font-size: 10px;
white-space: nowrap;
}
.home-x-label {
position: absolute;
top: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
font-size: 10px;
color: var(--muted);
}
.home-x-label.is-today {
color: var(--accent);
font-weight: 600;
}
@media (max-width: 1100px) {
.home-panels {
grid-template-columns: minmax(0, 1fr);
}
}
@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-page {
padding: 8px 0 20px;
}
.home-heading {
align-items: flex-start;
flex-direction: column;
gap: 16px;
margin-bottom: 20px;
}
.home-heading-actions {
width: 100%;
justify-content: space-between;
gap: 8px;
}
.home-metrics {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.home-metric .semi-card-body {
padding: 14px 16px;
}
.home-metric-value strong {
font-size: 30px;
}
.home-card .semi-card-header,
.home-card .semi-card-body {
padding: 16px;
}
.home-connect {
flex-wrap: wrap;
}
}