From 9372c475807fb1c8cc32ee3c8d780621bbabd4b5 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sat, 12 Sep 2026 00:04:57 +0800 Subject: [PATCH] feat(dashboard): add Eastern-time activity overview and submission calendar --- .../issues/02-activity-dashboard.md | 18 + .scratch/home-dashboard/spec.md | 13 + backend/app/dashboard.py | 76 +- backend/tests/test_dashboard.py | 90 ++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 12 + frontend/src/App.tsx | 2 + frontend/src/pages/HomePage.tsx | 926 ++++++++++++------ frontend/src/pages/home.css | 514 +++++++--- 9 files changed, 1203 insertions(+), 449 deletions(-) create mode 100644 .scratch/home-dashboard/issues/02-activity-dashboard.md diff --git a/.scratch/home-dashboard/issues/02-activity-dashboard.md b/.scratch/home-dashboard/issues/02-activity-dashboard.md new file mode 100644 index 0000000..297522f --- /dev/null +++ b/.scratch/home-dashboard/issues/02-activity-dashboard.md @@ -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 提示。 +- 没有真实平台账户验收、数据库迁移、部署或提交;隔离测试服务和浏览器已关闭。 diff --git a/.scratch/home-dashboard/spec.md b/.scratch/home-dashboard/spec.md index 3eb1a34..fad51e6 100644 --- a/.scratch/home-dashboard/spec.md +++ b/.scratch/home-dashboard/spec.md @@ -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 活动数据。 +- 平台缺失日期保留未知,明确零值与未知;独立接口错误不遮挡其他区块。 + +验证:活动响应结构、时区/夏令时跨日、缺日与零、鉴权及错误隔离;前端构建、已有导航回归及隔离浏览器验证桌面/移动端/深色主题。无需数据库迁移,不访问真实账户做写操作。 diff --git a/backend/app/dashboard.py b/backend/app/dashboard.py index f3fa83e..8d8b509 100644 --- a/backend/app/dashboard.py +++ b/backend/app/dashboard.py @@ -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): diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index b3efd06..cc08b0d 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 8aa07d0..9258240 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 48edf7e..9eafa3f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 892f473..2de49b2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -370,6 +370,8 @@ export default function App() { > {page === "home" && ( changePage("account")} /> diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index eae76c2..1822bc6 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -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(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(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(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(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; +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(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( + "/dashboard/activity/submissions", + connected, + revision, + ); + const simulations = useResource( + "/dashboard/activity/simulations", + connected, + revision, + ); + const multipliers = useResource( "/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 - : 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 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("/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 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 (

首页看板

-

了解 Pyramid 的平台乘数与 Alpha 分布。

+

跟踪提交进展,发现研究机会。

+
+
+
+ + + +
+
-
+ {verificationError && ( + + )} {!account ? (
@@ -139,254 +235,432 @@ export function HomePage({ ) : ( !connected && (
+
) )} -
- {metrics.map(([label, value, hint]) => ( -
- {label} - - {typeof value === "number" - ? value.toLocaleString("zh-CN") - : (value ?? "—")} - - {hint} -
- ))} -
-
- + 请先 + + 打开 WorldQuant 验证页面 + + ,完成后点击“刷新验证”。 + + } /> - +
+
); } -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)); +function PanelTitle({ icon, title }: { icon: ReactNode; title: string }) { return ( -
-
-

{title}

-

{description}

-
- {children} - {state.error ? ( -
- - -
- ) : state.loading ? ( -
- - 正在加载{title}… -
- ) : !connected ? ( -
连接账户后显示{title}。
- ) : state.data ? ( - <> -
- - - {rows.length} / {state.data.items.length} 类 - -
-
- - dataSource={rows} - rowKey={(row) => - row ? `${row.region}:${row.delay}:${row.category_id}` : "" - } - pagination={false} - size="small" - sticky - style={{ minWidth: 440 }} - empty={ - state.data.items.length - ? "没有匹配的 Pyramid 类别。" - : "平台暂未返回 Pyramid 数据。" - } - columns={[ - { - title: "地区", - key: "region", - width: 64, - render: (_, row) => row.region, - }, - { - title: "延迟", - key: "delay", - width: 64, - render: (_, row) => <>D{row.delay}, - }, - { - title: "数据类别", - key: "category", - render: (_, row) => ( - <> - {row.category_name} - {row.category_id} - - ), - }, - { - title: metric, - key: "value", - width: 110, - align: "right", - render: (_, row) => ( - <> -
- {row.value === null - ? "—" - : `${row.value.toLocaleString("zh-CN")}${metric === "乘数" ? "×" : ""}`} -
- - - ), - }, - ]} - /> -
-
- {metric === "Alpha 数量" && ( - - 查询范围: - {state.data.start_date || state.data.end_date - ? `${state.data.start_date ?? "不限开始"} 至 ${state.data.end_date ?? "不限结束"}` - : "平台默认"} - - )} - 更新于 {formatTime(state.data.fetched_at, timezone)} -
- - ) : null} -
+

+ {icon} + {title} +

+ ); +} +function Metric({ + label, + value, + unit, + hint, + icon, + loading, +}: { + label: string; + value: number | null | undefined; + unit: string; + hint: string; + icon: ReactNode; + loading: boolean; +}) { + return ( + +
+ {label} + {icon} +
+
+ {loading ? ( + + ) : ( + <> + + {value == null ? "—" : value.toLocaleString("zh-CN")} + + {value != null && {unit}} + + )} +
+

{hint}

+
+ ); +} +function ResourceContent({ + state, + connected, + name, + children, +}: { + state: ResourceState; + connected: boolean; + name: string; + children: ReactNode; +}) { + if (!connected) + return
连接账户后显示{name}。
; + if (state.error) + return ( +
+ + +
+ ); + if (state.loading) + return ( +
+ + 正在加载{name}… +
+ ); + 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(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 ( + <> +
+ + {known.length ? ( + <> + {total.toLocaleString("zh-CN")} 次提交 ·{" "} + {activeDays} 个活跃日 + + ) : ( + "暂无提交记录" + )} + + + {data.items[0].date} 至 {data.today} + +
+
+
+ +
+ {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 ( +
+ {monthLabel} + {week.map((day, row) => + day ? ( + +
+ ); + })} +
+
+
+
+

按平台提交活动统计 · 美东日期 · 数据可能延迟

+
+ + 暂无记录少 + {[0, 1, 2, 3, 4].map((n) => ( + + ))} + 多 +
+
+ + ); +} +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 ( + <> +

平台每日回测活动 · 美东日期

+
+
+ +
+ +
+ {data.items.map((day) => ( +
+ + + + + {day.date.slice(5).replace("-", "/")} + +
+ ))} +
+
+
+
+

+ 包含今日;“—”表示平台暂无记录,0 表示当日无回测。 +

+ ); } diff --git a/frontend/src/pages/home.css b/frontend/src/pages/home.css index 271e6bd..c1fd6e8 100644 --- a/frontend/src/pages/home.css +++ b/frontend/src/pages/home.css @@ -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; } }