feat(dashboard): add Eastern-time activity overview and submission calendar
This commit is contained in:
@@ -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 提示。
|
||||||
|
- 没有真实平台账户验收、数据库迁移、部署或提交;隔离测试服务和浏览器已关闭。
|
||||||
@@ -19,3 +19,16 @@
|
|||||||
页面按平台日期原样查询,不擅自转换为浏览器时区或推断统计边界。
|
页面按平台日期原样查询,不擅自转换为浏览器时区或推断统计边界。
|
||||||
浏览器合同仅保留上述字段并统一指标为 `value`,缺失指标保留 null,错误不暴露上游正文。
|
浏览器合同仅保留上述字段并统一指标为 `value`,缺失指标保留 null,错误不暴露上游正文。
|
||||||
统计归属次数跨类别累计,不作为去重后的 Alpha 总数。
|
统计归属次数跨类别累计,不作为去重后的 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 活动数据。
|
||||||
|
- 平台缺失日期保留未知,明确零值与未知;独立接口错误不遮挡其他区块。
|
||||||
|
|
||||||
|
验证:活动响应结构、时区/夏令时跨日、缺日与零、鉴权及错误隔离;前端构建、已有导航回归及隔离浏览器验证桌面/移动端/深色主题。无需数据库迁移,不访问真实账户做写操作。
|
||||||
|
|||||||
@@ -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
|
import math
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -12,6 +13,77 @@ from .security import require_auth
|
|||||||
from .worldquant import WqError
|
from .worldquant import WqError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"], dependencies=[Depends(require_auth)])
|
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):
|
class PyramidRow(BaseModel):
|
||||||
|
|||||||
@@ -124,3 +124,93 @@ async def test_dashboard_auth_connection_dates_and_independent_errors(app, clien
|
|||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
for endpoint in ("pyramid-alphas", "pyramid-multipliers"):
|
for endpoint in ("pyramid-alphas", "pyramid-multipliers"):
|
||||||
assert (await client.get(f"{prefix}/{endpoint}")).status_code == 401
|
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
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@douyinfe/semi-icons": "2.103.0",
|
"@douyinfe/semi-icons": "2.103.0",
|
||||||
"@douyinfe/semi-ui-19": "2.103.0",
|
"@douyinfe/semi-ui-19": "2.103.0",
|
||||||
"ai": "6.0.277",
|
"ai": "6.0.277",
|
||||||
|
"lucide-react": "1.45.0",
|
||||||
"markstream-react": "2.0.8",
|
"markstream-react": "2.0.8",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
|
|||||||
Generated
+12
@@ -20,6 +20,9 @@ importers:
|
|||||||
ai:
|
ai:
|
||||||
specifier: 6.0.277
|
specifier: 6.0.277
|
||||||
version: 6.0.277(zod@4.5.4)
|
version: 6.0.277(zod@4.5.4)
|
||||||
|
lucide-react:
|
||||||
|
specifier: 1.45.0
|
||||||
|
version: 1.45.0(react@19.2.8)
|
||||||
markstream-react:
|
markstream-react:
|
||||||
specifier: 2.0.8
|
specifier: 2.0.8
|
||||||
version: 2.0.8(react-dom@19.2.8(react@19.2.8))(react@19.2.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:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
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:
|
markdown-extensions@2.0.0:
|
||||||
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
|
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -2452,6 +2460,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
lucide-react@1.45.0(react@19.2.8):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
|
||||||
markdown-extensions@2.0.0: {}
|
markdown-extensions@2.0.0: {}
|
||||||
|
|
||||||
markdown-it-container@4.0.0: {}
|
markdown-it-container@4.0.0: {}
|
||||||
|
|||||||
@@ -370,6 +370,8 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
{page === "home" && (
|
{page === "home" && (
|
||||||
<HomePage
|
<HomePage
|
||||||
|
jobs={jobs}
|
||||||
|
onChange={actionDone}
|
||||||
account={account}
|
account={account}
|
||||||
onAccount={() => changePage("account")}
|
onAccount={() => changePage("account")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+600
-326
@@ -1,7 +1,27 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { Banner, Button, Input, Spin, Table } from "@douyinfe/semi-ui-19";
|
import {
|
||||||
import { api, formatTime } from "../api";
|
Banner,
|
||||||
import type { Account } from "../types";
|
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";
|
import "./home.css";
|
||||||
|
|
||||||
type PyramidRow = {
|
type PyramidRow = {
|
||||||
@@ -11,126 +31,202 @@ type PyramidRow = {
|
|||||||
category_name: string;
|
category_name: string;
|
||||||
value: number | null;
|
value: number | null;
|
||||||
};
|
};
|
||||||
type Snapshot = {
|
type PyramidSnapshot = { items: PyramidRow[]; fetched_at: string };
|
||||||
items: PyramidRow[];
|
type ActivityDay = { date: string; count: number | null };
|
||||||
|
type ActivitySnapshot = {
|
||||||
|
items: ActivityDay[];
|
||||||
|
today: string;
|
||||||
|
timezone: string;
|
||||||
fetched_at: string;
|
fetched_at: string;
|
||||||
start_date: string | null;
|
|
||||||
end_date: string | null;
|
|
||||||
};
|
};
|
||||||
|
const easternFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||||
function useSnapshot(
|
timeZone: "America/New_York",
|
||||||
path: string,
|
year: "numeric",
|
||||||
enabled: boolean,
|
month: "2-digit",
|
||||||
version: number,
|
day: "2-digit",
|
||||||
identity: string,
|
hour: "2-digit",
|
||||||
) {
|
minute: "2-digit",
|
||||||
const [data, setData] = useState<Snapshot | null>(null);
|
second: "2-digit",
|
||||||
const [error, setError] = useState("");
|
hourCycle: "h23",
|
||||||
const [loading, setLoading] = useState(false);
|
});
|
||||||
|
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 [retry, setRetry] = useState(0);
|
||||||
|
const key = `${path}:${revision}:${enabled}:${retry}`;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
if (!enabled) {
|
||||||
const controller = new AbortController();
|
setResult(undefined);
|
||||||
setData(null);
|
return;
|
||||||
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 () => {
|
const controller = new AbortController();
|
||||||
ignore = true;
|
api<T>(path, { signal: controller.signal })
|
||||||
controller.abort();
|
.then((data) => {
|
||||||
};
|
if (!controller.signal.aborted) setResult({ key, data });
|
||||||
}, [path, enabled, version, identity, retry]);
|
})
|
||||||
|
.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 {
|
return {
|
||||||
data: enabled ? data : null,
|
data: current?.data,
|
||||||
error: enabled ? error : "",
|
error: current?.error,
|
||||||
loading: enabled && loading,
|
loading: enabled && !current,
|
||||||
refresh: () => setRetry((value) => value + 1),
|
refresh: () => setRetry((n) => n + 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
type ResourceState = ReturnType<typeof useSnapshot>;
|
type ResourceState = { error?: string; loading: boolean; refresh: () => void };
|
||||||
|
|
||||||
export function HomePage({
|
export function HomePage({
|
||||||
account,
|
account,
|
||||||
|
jobs,
|
||||||
onAccount,
|
onAccount,
|
||||||
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
account: Account | null;
|
account: Account | null;
|
||||||
|
jobs: Job[];
|
||||||
onAccount: () => void;
|
onAccount: () => void;
|
||||||
|
onChange: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
const [version, setVersion] = useState(0);
|
const [version, setVersion] = useState(0);
|
||||||
const [start, setStart] = useState("");
|
const [sending, setSending] = useState(false);
|
||||||
const [end, setEnd] = useState("");
|
const [verificationId, setVerificationId] = useState<string | null>(null);
|
||||||
const [period, setPeriod] = useState({ start: "", end: "" });
|
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 =
|
const connected =
|
||||||
account?.connection_status === "connected" && !!account?.wq_user_id;
|
account?.connection_status === "connected" && !!account.wq_user_id;
|
||||||
const identity = account?.wq_user_id ?? "";
|
const revision = `${account?.wq_user_id ?? ""}:${today}:${version}`;
|
||||||
const params = new URLSearchParams();
|
const submissions = useResource<ActivitySnapshot>(
|
||||||
if (period.start) params.set("start_date", period.start);
|
"/dashboard/activity/submissions",
|
||||||
if (period.end) params.set("end_date", period.end);
|
connected,
|
||||||
const multipliers = useSnapshot(
|
revision,
|
||||||
|
);
|
||||||
|
const simulations = useResource<ActivitySnapshot>(
|
||||||
|
"/dashboard/activity/simulations",
|
||||||
|
connected,
|
||||||
|
revision,
|
||||||
|
);
|
||||||
|
const multipliers = useResource<PyramidSnapshot>(
|
||||||
"/dashboard/pyramid-multipliers",
|
"/dashboard/pyramid-multipliers",
|
||||||
connected,
|
connected,
|
||||||
version,
|
revision,
|
||||||
identity,
|
|
||||||
);
|
);
|
||||||
const alphas = useSnapshot(
|
const verification = jobs.find((job) => job.id === verificationId);
|
||||||
`/dashboard/pyramid-alphas?${params}`,
|
const authRunning = jobs.some(
|
||||||
connected,
|
(job) =>
|
||||||
version,
|
["connect", "verify", "profile"].includes(job.kind) &&
|
||||||
identity,
|
["queued", "running"].includes(job.status),
|
||||||
);
|
);
|
||||||
const multiplierValues =
|
const busy = sending || authRunning || !!verificationId;
|
||||||
multipliers.data?.items.flatMap((item) =>
|
useEffect(() => {
|
||||||
item.value === null ? [] : [item.value],
|
if (!verification || ["queued", "running"].includes(verification.status))
|
||||||
) ?? [];
|
return;
|
||||||
const counts = alphas.data?.items;
|
setVerificationId(null);
|
||||||
const completeCounts = counts && counts.every((item) => item.value !== null);
|
if (verification.status === "completed") {
|
||||||
const coverage = completeCounts
|
setVersion((n) => n + 1);
|
||||||
? counts.filter((item) => item.value! > 0).length
|
Toast.success("验证完成,正在更新看板");
|
||||||
: null;
|
} else {
|
||||||
const total = completeCounts
|
setVerificationError(
|
||||||
? counts.reduce((sum, item) => sum + item.value!, 0)
|
verification.error || "验证尚未完成,请到个人信息页查看账户状态。",
|
||||||
: null;
|
);
|
||||||
const invalidDates = !!(start && end && start > end);
|
}
|
||||||
const dirtyDates = start !== period.start || end !== period.end;
|
}, [verification]);
|
||||||
const metrics = [
|
async function refreshVerification() {
|
||||||
["Pyramid 类别", multipliers.data?.items.length, "当前乘数列表中的类别"],
|
setSending(true);
|
||||||
[
|
setVerificationError("");
|
||||||
"最高乘数",
|
try {
|
||||||
multiplierValues.length &&
|
const job = await post<Job>("/account/verify");
|
||||||
multiplierValues.length === multipliers.data?.items.length
|
setVerificationId(job.id);
|
||||||
? `${Math.max(...multiplierValues).toLocaleString("zh-CN")}×`
|
onChange();
|
||||||
: null,
|
} catch (error) {
|
||||||
"平台当前鼓励程度",
|
setVerificationError((error as Error).message);
|
||||||
],
|
} finally {
|
||||||
["已覆盖类别", coverage, "所选范围内 Alpha 数量大于 0"],
|
setSending(false);
|
||||||
["Alpha 归属次数", total, "跨类别累计,同一 Alpha 可能重复计入"],
|
}
|
||||||
] as const;
|
}
|
||||||
|
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 (
|
return (
|
||||||
<section className="home-page" aria-label="首页看板">
|
<section className="home-page" aria-label="首页看板">
|
||||||
<header className="home-heading">
|
<header className="home-heading">
|
||||||
<div>
|
<div>
|
||||||
<h1>首页看板</h1>
|
<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
|
||||||
|
theme="solid"
|
||||||
|
icon={<RefreshCw size={16} aria-hidden="true" />}
|
||||||
|
loading={busy}
|
||||||
|
disabled={
|
||||||
|
!account?.configured ||
|
||||||
|
busy ||
|
||||||
|
account.connection_status === "connecting"
|
||||||
|
}
|
||||||
|
onClick={() => void refreshVerification()}
|
||||||
|
>
|
||||||
|
刷新验证
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
disabled={!connected || multipliers.loading || alphas.loading}
|
|
||||||
onClick={() => setVersion((value) => value + 1)}
|
|
||||||
>
|
|
||||||
刷新看板
|
|
||||||
</Button>
|
|
||||||
</header>
|
</header>
|
||||||
|
{verificationError && (
|
||||||
|
<Banner type="warning" description={verificationError} />
|
||||||
|
)}
|
||||||
{!account ? (
|
{!account ? (
|
||||||
<div className="home-empty">
|
<div className="home-empty">
|
||||||
<Spin />
|
<Spin />
|
||||||
@@ -139,254 +235,432 @@ export function HomePage({
|
|||||||
) : (
|
) : (
|
||||||
!connected && (
|
!connected && (
|
||||||
<div className="home-connect">
|
<div className="home-connect">
|
||||||
|
<ShieldCheck size={24} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<strong>连接 WorldQuant 后查看看板</strong>
|
<strong>连接 WorldQuant 后查看看板</strong>
|
||||||
<p>请到个人信息页连接账户;如需人工验证,请先完成验证。</p>
|
<p>
|
||||||
|
{account.connection_error ||
|
||||||
|
"请先配置账户并完成验证,以读取平台活动数据。"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button theme="solid" onClick={onAccount}>
|
<Button onClick={onAccount}>前往个人信息</Button>
|
||||||
前往个人信息
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
<div className="home-metrics">
|
{account?.verification_url && (
|
||||||
{metrics.map(([label, value, hint]) => (
|
<Banner
|
||||||
<article className="home-metric" key={label} aria-label={label}>
|
type="warning"
|
||||||
<span>{label}</span>
|
description={
|
||||||
<strong>
|
<span>
|
||||||
{typeof value === "number"
|
请先
|
||||||
? value.toLocaleString("zh-CN")
|
<a
|
||||||
: (value ?? "—")}
|
href={account.verification_url}
|
||||||
</strong>
|
target="_blank"
|
||||||
<small>{hint}</small>
|
rel="noreferrer"
|
||||||
</article>
|
>
|
||||||
))}
|
打开 WorldQuant 验证页面
|
||||||
</div>
|
</a>
|
||||||
<div className="home-panels">
|
,完成后点击“刷新验证”。
|
||||||
<PyramidPanel
|
</span>
|
||||||
title="Pyramid 乘数"
|
}
|
||||||
description="按乘数从高到低排列,日期筛选不影响当前乘数。"
|
|
||||||
metric="乘数"
|
|
||||||
state={multipliers}
|
|
||||||
connected={connected}
|
|
||||||
timezone={account?.timezone}
|
|
||||||
/>
|
/>
|
||||||
<PyramidPanel
|
)}
|
||||||
title="Alpha 分布"
|
<div className="home-metrics">
|
||||||
description="按归属数量从高到低排列;数量为 0 的类别也会保留。"
|
<Metric
|
||||||
metric="Alpha 数量"
|
label="今日已提交 Alpha"
|
||||||
state={alphas}
|
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}
|
connected={connected}
|
||||||
timezone={account?.timezone}
|
name="提交日历"
|
||||||
>
|
>
|
||||||
<form
|
{submissions.data && <SubmissionCalendar data={submissions.data} />}
|
||||||
className="home-period"
|
</ResourceContent>
|
||||||
onSubmit={(event) => {
|
</Card>
|
||||||
event.preventDefault();
|
<div className="home-panels">
|
||||||
if (invalidDates) return;
|
<Card
|
||||||
if (dirtyDates) setPeriod({ start, end });
|
className="home-card"
|
||||||
else alphas.refresh();
|
title={
|
||||||
}}
|
<PanelTitle
|
||||||
|
icon={<BarChart3 size={18} aria-hidden="true" />}
|
||||||
|
title="回测次数"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
headerExtraContent={<Tag>近 14 天</Tag>}
|
||||||
|
>
|
||||||
|
<ResourceContent
|
||||||
|
state={simulations}
|
||||||
|
connected={connected}
|
||||||
|
name="回测次数"
|
||||||
>
|
>
|
||||||
<div className="home-date-fields">
|
{simulations.data && <SimulationChart data={simulations.data} />}
|
||||||
<label>
|
</ResourceContent>
|
||||||
开始日期
|
</Card>
|
||||||
<input
|
<Card
|
||||||
aria-label="分布开始日期"
|
className="home-card home-pyramid-card"
|
||||||
type="date"
|
title={
|
||||||
value={start}
|
<PanelTitle
|
||||||
max={end || "9999-12-31"}
|
icon={<Layers3 size={18} aria-hidden="true" />}
|
||||||
onChange={(event) => setStart(event.target.value)}
|
title="Pyramid 乘数"
|
||||||
/>
|
/>
|
||||||
</label>
|
}
|
||||||
<label>
|
headerExtraContent={<Tag color="blue">乘数 > 1</Tag>}
|
||||||
结束日期
|
>
|
||||||
<input
|
<ResourceContent
|
||||||
aria-label="分布结束日期"
|
state={multipliers}
|
||||||
type="date"
|
connected={connected}
|
||||||
value={end}
|
name="Pyramid 乘数"
|
||||||
min={start || undefined}
|
>
|
||||||
max="9999-12-31"
|
{multipliers.data && (
|
||||||
onChange={(event) => setEnd(event.target.value)}
|
<>
|
||||||
/>
|
<div className="home-table-tools">
|
||||||
</label>
|
<Input
|
||||||
</div>
|
aria-label="搜索 Pyramid 类别"
|
||||||
<div className="inline-actions">
|
prefix={<Search size={16} aria-hidden="true" />}
|
||||||
<Button
|
value={search}
|
||||||
htmlType="submit"
|
onChange={setSearch}
|
||||||
disabled={!connected || invalidDates || alphas.loading}
|
showClear
|
||||||
>
|
placeholder="搜索地区、延迟或类别"
|
||||||
应用日期
|
/>
|
||||||
</Button>
|
<span>{rows.length} 类</span>
|
||||||
<Button
|
</div>
|
||||||
disabled={!start && !end && !period.start && !period.end}
|
<div className="home-table-wrap">
|
||||||
onClick={() => {
|
<Table<PyramidRow>
|
||||||
setStart("");
|
dataSource={rows}
|
||||||
setEnd("");
|
rowKey={(row) =>
|
||||||
setPeriod({ start: "", end: "" });
|
row ? `${row.region}:${row.delay}:${row.category_id}` : ""
|
||||||
}}
|
}
|
||||||
>
|
pagination={false}
|
||||||
恢复默认
|
size="small"
|
||||||
</Button>
|
sticky
|
||||||
<small>按平台日期查询;留空使用平台默认范围。</small>
|
empty={
|
||||||
</div>
|
eligible.length
|
||||||
{invalidDates && <p role="alert">开始日期不能晚于结束日期。</p>}
|
? "没有匹配的 Pyramid 类别"
|
||||||
{dirtyDates && !invalidDates && (
|
: "当前没有乘数大于 1 的类别"
|
||||||
<p className="muted">日期已修改,点击“应用日期”更新分布。</p>
|
}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
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: 58,
|
||||||
|
render: (_, row) => `D${row.delay}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "乘数",
|
||||||
|
key: "value",
|
||||||
|
width: 80,
|
||||||
|
align: "right",
|
||||||
|
render: (_, row) => (
|
||||||
|
<Tag color="blue">
|
||||||
|
{row.value?.toLocaleString("zh-CN")}×
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="home-footnote">
|
||||||
|
按乘数从高到低排列
|
||||||
|
{unknownMultipliers
|
||||||
|
? ` · ${unknownMultipliers} 类乘数未知,未列入`
|
||||||
|
: ""}{" "}
|
||||||
|
· 更新于 {easternTime(new Date(multipliers.data.fetched_at))}{" "}
|
||||||
|
美东
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</form>
|
</ResourceContent>
|
||||||
</PyramidPanel>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PyramidPanel({
|
function PanelTitle({ icon, title }: { icon: ReactNode; title: string }) {
|
||||||
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 (
|
return (
|
||||||
<section
|
<h2 className="home-panel-title">
|
||||||
className="home-panel"
|
{icon}
|
||||||
aria-label={title}
|
{title}
|
||||||
aria-busy={state.loading}
|
</h2>
|
||||||
>
|
);
|
||||||
<header>
|
}
|
||||||
<h2>{title}</h2>
|
function Metric({
|
||||||
<p>{description}</p>
|
label,
|
||||||
</header>
|
value,
|
||||||
{children}
|
unit,
|
||||||
{state.error ? (
|
hint,
|
||||||
<div className="home-panel-error">
|
icon,
|
||||||
<Banner type="warning" description={state.error} />
|
loading,
|
||||||
<Button onClick={state.refresh}>重试{title}</Button>
|
}: {
|
||||||
</div>
|
label: string;
|
||||||
) : state.loading ? (
|
value: number | null | undefined;
|
||||||
<div className="home-empty" role="status">
|
unit: string;
|
||||||
<Spin />
|
hint: string;
|
||||||
<span>正在加载{title}…</span>
|
icon: ReactNode;
|
||||||
</div>
|
loading: boolean;
|
||||||
) : !connected ? (
|
}) {
|
||||||
<div className="home-empty">连接账户后显示{title}。</div>
|
return (
|
||||||
) : state.data ? (
|
<Card className="home-metric" aria-label={label}>
|
||||||
<>
|
<div className="home-metric-label">
|
||||||
<div className="home-table-tools">
|
<span>{label}</span>
|
||||||
<Input
|
<span className="home-metric-icon">{icon}</span>
|
||||||
aria-label={`搜索${title}`}
|
</div>
|
||||||
value={search}
|
<div className="home-metric-value">
|
||||||
onChange={setSearch}
|
{loading ? (
|
||||||
placeholder="搜索地区、延迟或类别"
|
<Spin size="small" />
|
||||||
showClear
|
) : (
|
||||||
/>
|
<>
|
||||||
<span>
|
<strong>
|
||||||
{rows.length} / {state.data.items.length} 类
|
{value == null ? "—" : value.toLocaleString("zh-CN")}
|
||||||
</span>
|
</strong>
|
||||||
</div>
|
{value != null && <span>{unit}</span>}
|
||||||
<div className="home-table-wrap">
|
</>
|
||||||
<Table<PyramidRow>
|
)}
|
||||||
dataSource={rows}
|
</div>
|
||||||
rowKey={(row) =>
|
<p>{hint}</p>
|
||||||
row ? `${row.region}:${row.delay}:${row.category_id}` : ""
|
</Card>
|
||||||
}
|
);
|
||||||
pagination={false}
|
}
|
||||||
size="small"
|
function ResourceContent({
|
||||||
sticky
|
state,
|
||||||
style={{ minWidth: 440 }}
|
connected,
|
||||||
empty={
|
name,
|
||||||
state.data.items.length
|
children,
|
||||||
? "没有匹配的 Pyramid 类别。"
|
}: {
|
||||||
: "平台暂未返回 Pyramid 数据。"
|
state: ResourceState;
|
||||||
}
|
connected: boolean;
|
||||||
columns={[
|
name: string;
|
||||||
{
|
children: ReactNode;
|
||||||
title: "地区",
|
}) {
|
||||||
key: "region",
|
if (!connected)
|
||||||
width: 64,
|
return <div className="home-empty">连接账户后显示{name}。</div>;
|
||||||
render: (_, row) => row.region,
|
if (state.error)
|
||||||
},
|
return (
|
||||||
{
|
<div className="home-panel-error">
|
||||||
title: "延迟",
|
<Banner type="warning" description={state.error} />
|
||||||
key: "delay",
|
<Button onClick={state.refresh}>重试{name}</Button>
|
||||||
width: 64,
|
</div>
|
||||||
render: (_, row) => <>D{row.delay}</>,
|
);
|
||||||
},
|
if (state.loading)
|
||||||
{
|
return (
|
||||||
title: "数据类别",
|
<div className="home-empty" role="status">
|
||||||
key: "category",
|
<Spin />
|
||||||
render: (_, row) => (
|
<span>正在加载{name}…</span>
|
||||||
<>
|
</div>
|
||||||
<strong>{row.category_name}</strong>
|
);
|
||||||
<small>{row.category_id}</small>
|
return children;
|
||||||
</>
|
}
|
||||||
),
|
function level(count: number | null) {
|
||||||
},
|
return count === null ? "unknown" : String(Math.min(count, 4));
|
||||||
{
|
}
|
||||||
title: metric,
|
function SubmissionCalendar({ data }: { data: ActivitySnapshot }) {
|
||||||
key: "value",
|
const first = new Date(`${data.items[0].date}T00:00:00Z`);
|
||||||
width: 110,
|
const offset = first.getUTCDay();
|
||||||
align: "right",
|
const cells: (ActivityDay | null)[] = [
|
||||||
render: (_, row) => (
|
...Array<null>(offset).fill(null),
|
||||||
<>
|
...data.items,
|
||||||
<div className="home-value">
|
];
|
||||||
{row.value === null
|
const weeks = Array.from(
|
||||||
? "—"
|
{ length: Math.ceil(cells.length / 7) },
|
||||||
: `${row.value.toLocaleString("zh-CN")}${metric === "乘数" ? "×" : ""}`}
|
(_, index) => cells.slice(index * 7, index * 7 + 7),
|
||||||
</div>
|
);
|
||||||
<div className="home-bar" aria-hidden="true">
|
const known = data.items.filter((day) => day.count !== null);
|
||||||
<i
|
const total = known.reduce((sum, day) => sum + day.count!, 0);
|
||||||
style={{
|
const activeDays = known.filter((day) => day.count! > 0).length;
|
||||||
width: `${((row.value ?? 0) / maximum) * 100}%`,
|
let lastMonth = "";
|
||||||
}}
|
return (
|
||||||
/>
|
<>
|
||||||
</div>
|
<div className="home-calendar-summary">
|
||||||
</>
|
<span>
|
||||||
),
|
{known.length ? (
|
||||||
},
|
<>
|
||||||
]}
|
<strong>{total.toLocaleString("zh-CN")}</strong> 次提交 ·{" "}
|
||||||
/>
|
<strong>{activeDays}</strong> 个活跃日
|
||||||
</div>
|
</>
|
||||||
<footer>
|
) : (
|
||||||
{metric === "Alpha 数量" && (
|
"暂无提交记录"
|
||||||
<span>
|
)}
|
||||||
查询范围:
|
</span>
|
||||||
{state.data.start_date || state.data.end_date
|
<span>
|
||||||
? `${state.data.start_date ?? "不限开始"} 至 ${state.data.end_date ?? "不限结束"}`
|
{data.items[0].date} 至 {data.today}
|
||||||
: "平台默认"}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
)}
|
<div className="home-calendar-scroll">
|
||||||
<span>更新于 {formatTime(state.data.fetched_at, timezone)}</span>
|
<div className="home-calendar">
|
||||||
</footer>
|
<div className="home-weekdays" aria-hidden="true">
|
||||||
</>
|
{["", "一", "", "三", "", "五", ""].map((day, i) => (
|
||||||
) : null}
|
<span key={i}>{day}</span>
|
||||||
</section>
|
))}
|
||||||
|
</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
@@ -2,183 +2,455 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 8px 4px 24px;
|
padding: 12px 8px 24px;
|
||||||
}
|
}
|
||||||
.home-heading {
|
.home-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
.home-heading p,
|
.home-heading p {
|
||||||
.home-panel header p,
|
|
||||||
.home-connect p {
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-top: 4px;
|
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 {
|
.home-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
.home-metric,
|
.home-metric.semi-card,
|
||||||
.home-panel {
|
.home-card.semi-card {
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
border-color: var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.home-metric {
|
.home-metric .semi-card-body {
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.home-metric-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
padding: 20px;
|
gap: 10px;
|
||||||
}
|
|
||||||
.home-metric > span {
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.home-metric > strong {
|
.home-metric-icon {
|
||||||
font-size: 30px;
|
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-weight: 600;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
line-height: 1.3;
|
|
||||||
}
|
}
|
||||||
.home-metric small,
|
.home-metric-value > span,
|
||||||
.home-period small,
|
.home-metric p {
|
||||||
.home-panel footer {
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
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 {
|
.home-panels {
|
||||||
display: grid;
|
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;
|
gap: 20px;
|
||||||
align-items: start;
|
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 {
|
.home-connect {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
background: var(--semi-color-primary-light-default);
|
background: var(--semi-color-primary-light-default);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
.home-connect > div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.home-connect p {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
.home-empty {
|
.home-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 48px 20px;
|
min-height: 180px;
|
||||||
|
padding: 24px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-align: center;
|
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 {
|
.home-panel-error {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: start;
|
justify-items: start;
|
||||||
gap: 16px;
|
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) {
|
@media (max-width: 600px) {
|
||||||
.home-metrics {
|
.home-page {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
padding: 8px 0 20px;
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.home-metric {
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
.home-metric > strong {
|
|
||||||
font-size: 26px;
|
|
||||||
}
|
}
|
||||||
.home-heading {
|
.home-heading {
|
||||||
align-items: flex-start;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user