Compare commits
2 Commits
5d7c31810e
...
16653086c4
| Author | SHA1 | Date | |
|---|---|---|---|
| 16653086c4 | |||
| 9372c47580 |
@@ -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,错误不暴露上游正文。
|
||||
统计归属次数跨类别累计,不作为去重后的 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
|
||||
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):
|
||||
|
||||
@@ -20,6 +20,11 @@ class TokenInput(BaseModel):
|
||||
scopes: list[str] = Field(default_factory=lambda: ["research:read"], max_length=len(SCOPES))
|
||||
|
||||
|
||||
class TokenPermissionsInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
scopes: list[str] = Field(max_length=len(SCOPES))
|
||||
|
||||
|
||||
def token_output(row, account):
|
||||
"""Return public metadata only, including whether the current binding is usable."""
|
||||
def timestamp(value):
|
||||
@@ -69,6 +74,26 @@ async def issue_token(body: TokenInput, request: Request):
|
||||
return {**result, "token": secret}
|
||||
|
||||
|
||||
@router.patch("/{token_id}")
|
||||
async def update_token_permissions(token_id: str, body: TokenPermissionsInput, request: Request):
|
||||
"""Update owned active-token scopes without rotating or revealing its secret."""
|
||||
scopes = set(body.scopes)
|
||||
if not scopes <= SCOPES or "research:read" not in scopes:
|
||||
raise HTTPException(422, "权限无效;所有令牌必须包含 research:read")
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
row = await db.scalar(select(MCPToken).where(
|
||||
MCPToken.id == token_id, MCPToken.admin_id == 1, MCPToken.account_id == 1,
|
||||
).with_for_update())
|
||||
if not row:
|
||||
raise HTTPException(404, "MCP Key 不存在")
|
||||
account = await db.get(Account, 1)
|
||||
if token_output(row, account)["status"] != "active":
|
||||
raise HTTPException(409, "仅有效的 MCP Key 可以编辑权限")
|
||||
row.scopes = sorted(scopes)
|
||||
result = token_output(row, account)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{token_id}/revoke")
|
||||
async def revoke_token(token_id: str, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,3 +76,37 @@ async def test_token_browser_security_and_validation(app, logged_in):
|
||||
row = await db.scalar(select(MCPToken))
|
||||
row.expires_at = now() - timedelta(days=1)
|
||||
assert (await client.get("/api/v1/mcp-tokens")).json()["items"][0]["status"] == "expired"
|
||||
|
||||
|
||||
async def test_edit_token_permissions(app, logged_in):
|
||||
async with app.state.sessions.begin() as db:
|
||||
(await db.get(Account, 1)).wq_user_id = "synthetic-user"
|
||||
token = (await logged_in.post("/api/v1/mcp-tokens", json={"name": "editable"})).json()
|
||||
path = f'/api/v1/mcp-tokens/{token["id"]}'
|
||||
for scopes in [["research:read", "research:write", "backtests:execute"], ["research:read"]]:
|
||||
response = await logged_in.patch(path, json={"scopes": scopes})
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["scopes"] == sorted(scopes)
|
||||
assert result["expires_at"] == token["expires_at"]
|
||||
assert result["name"] == token["name"]
|
||||
assert "token" not in result and "token_hash" not in result
|
||||
async with app.state.sessions() as db:
|
||||
assert (await authenticate(db, token["token"])).scopes == frozenset(scopes)
|
||||
for body in [{"scopes": []}, {"scopes": ["admin", "research:read"]}, {"scopes": ["research:write"]}, {"scopes": ["research:read"], "admin_id": 2}, {}]:
|
||||
assert (await logged_in.patch(path, json=body)).status_code == 422
|
||||
body = {"scopes": ["research:read", "research:write"]}
|
||||
assert (await logged_in.patch(path, json=body, headers={"X-WQ-Request": ""})).status_code == 403
|
||||
assert (await logged_in.patch(path, json=body, headers={"Origin": "https://evil.test"})).status_code == 403
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as outsider:
|
||||
assert (await outsider.patch(path, json=body, headers={"Authorization": f'Bearer {token["token"]}', "X-WQ-Request": "1"})).status_code == 401
|
||||
assert (await logged_in.patch("/api/v1/mcp-tokens/missing", json=body)).status_code == 404
|
||||
async with app.state.sessions.begin() as db:
|
||||
(await db.get(Account, 1)).wq_user_id = "changed-user"
|
||||
assert (await logged_in.patch(path, json=body)).status_code == 409
|
||||
async with app.state.sessions.begin() as db:
|
||||
(await db.get(Account, 1)).wq_user_id = "synthetic-user"
|
||||
(await db.get(MCPToken, token["id"])).expires_at = now() - timedelta(days=1)
|
||||
assert (await logged_in.patch(path, json=body)).status_code == 409
|
||||
await logged_in.post(path + "/revoke")
|
||||
assert (await logged_in.patch(path, json=body)).status_code == 409
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+12
@@ -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: {}
|
||||
|
||||
@@ -370,6 +370,8 @@ export default function App() {
|
||||
>
|
||||
{page === "home" && (
|
||||
<HomePage
|
||||
jobs={jobs}
|
||||
onChange={actionDone}
|
||||
account={account}
|
||||
onAccount={() => changePage("account")}
|
||||
/>
|
||||
|
||||
+564
-290
@@ -1,7 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Banner, Button, Input, Spin, Table } from "@douyinfe/semi-ui-19";
|
||||
import { api, formatTime } from "../api";
|
||||
import type { Account } from "../types";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Toast,
|
||||
Tooltip,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
BarChart3,
|
||||
CalendarDays,
|
||||
Clock3,
|
||||
Layers3,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { api, post } from "../api";
|
||||
import type { Account, Job } from "../types";
|
||||
import "./home.css";
|
||||
|
||||
type PyramidRow = {
|
||||
@@ -11,126 +31,202 @@ type PyramidRow = {
|
||||
category_name: string;
|
||||
value: number | null;
|
||||
};
|
||||
type Snapshot = {
|
||||
items: PyramidRow[];
|
||||
type PyramidSnapshot = { items: PyramidRow[]; fetched_at: string };
|
||||
type ActivityDay = { date: string; count: number | null };
|
||||
type ActivitySnapshot = {
|
||||
items: ActivityDay[];
|
||||
today: string;
|
||||
timezone: string;
|
||||
fetched_at: string;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
};
|
||||
|
||||
function useSnapshot(
|
||||
path: string,
|
||||
enabled: boolean,
|
||||
version: number,
|
||||
identity: string,
|
||||
) {
|
||||
const [data, setData] = useState<Snapshot | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [retry, setRetry] = useState(0);
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
const controller = new AbortController();
|
||||
setData(null);
|
||||
setError("");
|
||||
setLoading(enabled);
|
||||
if (enabled) {
|
||||
api<Snapshot>(path, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (!ignore) setData(next);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!ignore) setError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ignore) setLoading(false);
|
||||
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}`;
|
||||
}
|
||||
return () => {
|
||||
ignore = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [path, enabled, version, identity, retry]);
|
||||
function useResource<T>(path: string, enabled: boolean, revision: string) {
|
||||
const [result, setResult] = useState<{
|
||||
key: string;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}>();
|
||||
const [retry, setRetry] = useState(0);
|
||||
const key = `${path}:${revision}:${enabled}:${retry}`;
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setResult(undefined);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
api<T>(path, { signal: controller.signal })
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) setResult({ key, data });
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (!controller.signal.aborted)
|
||||
setResult({ key, error: error.message });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [path, enabled, key]);
|
||||
// Keyed results prevent another account or yesterday's totals flashing while a new read starts.
|
||||
const current = enabled && result?.key === key ? result : undefined;
|
||||
return {
|
||||
data: enabled ? data : null,
|
||||
error: enabled ? error : "",
|
||||
loading: enabled && loading,
|
||||
refresh: () => setRetry((value) => value + 1),
|
||||
data: current?.data,
|
||||
error: current?.error,
|
||||
loading: enabled && !current,
|
||||
refresh: () => setRetry((n) => n + 1),
|
||||
};
|
||||
}
|
||||
type ResourceState = ReturnType<typeof useSnapshot>;
|
||||
type ResourceState = { error?: string; loading: boolean; refresh: () => void };
|
||||
|
||||
export function HomePage({
|
||||
account,
|
||||
jobs,
|
||||
onAccount,
|
||||
onChange,
|
||||
}: {
|
||||
account: Account | null;
|
||||
jobs: Job[];
|
||||
onAccount: () => void;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [version, setVersion] = useState(0);
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
const [period, setPeriod] = useState({ start: "", end: "" });
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verificationId, setVerificationId] = useState<string | null>(null);
|
||||
const [verificationError, setVerificationError] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const time = easternTime(now);
|
||||
const today = time.slice(0, 10);
|
||||
const connected =
|
||||
account?.connection_status === "connected" && !!account?.wq_user_id;
|
||||
const identity = account?.wq_user_id ?? "";
|
||||
const params = new URLSearchParams();
|
||||
if (period.start) params.set("start_date", period.start);
|
||||
if (period.end) params.set("end_date", period.end);
|
||||
const multipliers = useSnapshot(
|
||||
account?.connection_status === "connected" && !!account.wq_user_id;
|
||||
const revision = `${account?.wq_user_id ?? ""}:${today}:${version}`;
|
||||
const submissions = useResource<ActivitySnapshot>(
|
||||
"/dashboard/activity/submissions",
|
||||
connected,
|
||||
revision,
|
||||
);
|
||||
const simulations = useResource<ActivitySnapshot>(
|
||||
"/dashboard/activity/simulations",
|
||||
connected,
|
||||
revision,
|
||||
);
|
||||
const multipliers = useResource<PyramidSnapshot>(
|
||||
"/dashboard/pyramid-multipliers",
|
||||
connected,
|
||||
version,
|
||||
identity,
|
||||
revision,
|
||||
);
|
||||
const alphas = useSnapshot(
|
||||
`/dashboard/pyramid-alphas?${params}`,
|
||||
connected,
|
||||
version,
|
||||
identity,
|
||||
const verification = jobs.find((job) => job.id === verificationId);
|
||||
const authRunning = jobs.some(
|
||||
(job) =>
|
||||
["connect", "verify", "profile"].includes(job.kind) &&
|
||||
["queued", "running"].includes(job.status),
|
||||
);
|
||||
const multiplierValues =
|
||||
multipliers.data?.items.flatMap((item) =>
|
||||
item.value === null ? [] : [item.value],
|
||||
) ?? [];
|
||||
const counts = alphas.data?.items;
|
||||
const completeCounts = counts && counts.every((item) => item.value !== null);
|
||||
const coverage = completeCounts
|
||||
? counts.filter((item) => item.value! > 0).length
|
||||
const busy = sending || authRunning || !!verificationId;
|
||||
useEffect(() => {
|
||||
if (!verification || ["queued", "running"].includes(verification.status))
|
||||
return;
|
||||
setVerificationId(null);
|
||||
if (verification.status === "completed") {
|
||||
setVersion((n) => n + 1);
|
||||
Toast.success("验证完成,正在更新看板");
|
||||
} else {
|
||||
setVerificationError(
|
||||
verification.error || "验证尚未完成,请到个人信息页查看账户状态。",
|
||||
);
|
||||
}
|
||||
}, [verification]);
|
||||
async function refreshVerification() {
|
||||
setSending(true);
|
||||
setVerificationError("");
|
||||
try {
|
||||
const job = await post<Job>("/account/verify");
|
||||
setVerificationId(job.id);
|
||||
onChange();
|
||||
} catch (error) {
|
||||
setVerificationError((error as Error).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
const eligible = (multipliers.data?.items ?? [])
|
||||
.filter((row) => row.value !== null && row.value > 1)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.value! - a.value! ||
|
||||
a.region.localeCompare(b.region) ||
|
||||
a.delay - b.delay ||
|
||||
a.category_id.localeCompare(b.category_id),
|
||||
);
|
||||
const query = search.trim().toLowerCase();
|
||||
const rows = eligible.filter((row) =>
|
||||
`${row.region} D${row.delay} ${row.category_id} ${row.category_name}`
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
);
|
||||
const submittedToday =
|
||||
submissions.data?.today === today
|
||||
? submissions.data.items.find((day) => day.date === today)?.count
|
||||
: null;
|
||||
const total = completeCounts
|
||||
? counts.reduce((sum, item) => sum + item.value!, 0)
|
||||
: null;
|
||||
const invalidDates = !!(start && end && start > end);
|
||||
const dirtyDates = start !== period.start || end !== period.end;
|
||||
const metrics = [
|
||||
["Pyramid 类别", multipliers.data?.items.length, "当前乘数列表中的类别"],
|
||||
[
|
||||
"最高乘数",
|
||||
multiplierValues.length &&
|
||||
multiplierValues.length === multipliers.data?.items.length
|
||||
? `${Math.max(...multiplierValues).toLocaleString("zh-CN")}×`
|
||||
: null,
|
||||
"平台当前鼓励程度",
|
||||
],
|
||||
["已覆盖类别", coverage, "所选范围内 Alpha 数量大于 0"],
|
||||
["Alpha 归属次数", total, "跨类别累计,同一 Alpha 可能重复计入"],
|
||||
] as const;
|
||||
const knownSimulations =
|
||||
simulations.data?.items.filter((day) => day.count !== null) ?? [];
|
||||
const simulationTotal = knownSimulations.reduce(
|
||||
(sum, day) => sum + day.count!,
|
||||
0,
|
||||
);
|
||||
const unknownMultipliers =
|
||||
multipliers.data?.items.filter((row) => row.value === null).length ?? 0;
|
||||
|
||||
return (
|
||||
<section className="home-page" aria-label="首页看板">
|
||||
<header className="home-heading">
|
||||
<div>
|
||||
<h1>首页看板</h1>
|
||||
<p>了解 Pyramid 的平台乘数与 Alpha 分布。</p>
|
||||
<p>跟踪提交进展,发现研究机会。</p>
|
||||
</div>
|
||||
<div className="home-heading-actions">
|
||||
<div className="home-clock">
|
||||
<span>
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
当前美东时间
|
||||
</span>
|
||||
<time dateTime={now.toISOString()}>{time}</time>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!connected || multipliers.loading || alphas.loading}
|
||||
onClick={() => setVersion((value) => value + 1)}
|
||||
theme="solid"
|
||||
icon={<RefreshCw size={16} aria-hidden="true" />}
|
||||
loading={busy}
|
||||
disabled={
|
||||
!account?.configured ||
|
||||
busy ||
|
||||
account.connection_status === "connecting"
|
||||
}
|
||||
onClick={() => void refreshVerification()}
|
||||
>
|
||||
刷新看板
|
||||
刷新验证
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
{verificationError && (
|
||||
<Banner type="warning" description={verificationError} />
|
||||
)}
|
||||
{!account ? (
|
||||
<div className="home-empty">
|
||||
<Spin />
|
||||
@@ -139,177 +235,130 @@ export function HomePage({
|
||||
) : (
|
||||
!connected && (
|
||||
<div className="home-connect">
|
||||
<ShieldCheck size={24} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>连接 WorldQuant 后查看看板</strong>
|
||||
<p>请到个人信息页连接账户;如需人工验证,请先完成验证。</p>
|
||||
<p>
|
||||
{account.connection_error ||
|
||||
"请先配置账户并完成验证,以读取平台活动数据。"}
|
||||
</p>
|
||||
</div>
|
||||
<Button theme="solid" onClick={onAccount}>
|
||||
前往个人信息
|
||||
</Button>
|
||||
<Button onClick={onAccount}>前往个人信息</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{account?.verification_url && (
|
||||
<Banner
|
||||
type="warning"
|
||||
description={
|
||||
<span>
|
||||
请先
|
||||
<a
|
||||
href={account.verification_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
打开 WorldQuant 验证页面
|
||||
</a>
|
||||
,完成后点击“刷新验证”。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="home-metrics">
|
||||
{metrics.map(([label, value, hint]) => (
|
||||
<article className="home-metric" key={label} aria-label={label}>
|
||||
<span>{label}</span>
|
||||
<strong>
|
||||
{typeof value === "number"
|
||||
? value.toLocaleString("zh-CN")
|
||||
: (value ?? "—")}
|
||||
</strong>
|
||||
<small>{hint}</small>
|
||||
</article>
|
||||
))}
|
||||
<Metric
|
||||
label="今日已提交 Alpha"
|
||||
icon={<Send size={18} aria-hidden="true" />}
|
||||
value={submittedToday}
|
||||
unit="个"
|
||||
hint={`${today} · 美东自然日`}
|
||||
loading={submissions.loading}
|
||||
/>
|
||||
<Metric
|
||||
label="近 14 天回测"
|
||||
icon={<BarChart3 size={18} aria-hidden="true" />}
|
||||
value={knownSimulations.length ? simulationTotal : null}
|
||||
unit="次"
|
||||
hint={
|
||||
knownSimulations.length === 14
|
||||
? "平台回测活动累计"
|
||||
: "已返回日期合计 · 缺失日期不计入"
|
||||
}
|
||||
loading={simulations.loading}
|
||||
/>
|
||||
<Metric
|
||||
label="Pyramid 加成类别"
|
||||
icon={<Layers3 size={18} aria-hidden="true" />}
|
||||
value={multipliers.data ? eligible.length : null}
|
||||
unit="类"
|
||||
hint="当前乘数 > 1 的地区 / 延迟 / 类别"
|
||||
loading={multipliers.loading}
|
||||
/>
|
||||
</div>
|
||||
<Card
|
||||
className="home-card home-calendar-card"
|
||||
title={
|
||||
<PanelTitle
|
||||
icon={<CalendarDays size={18} aria-hidden="true" />}
|
||||
title="Alpha 提交日历"
|
||||
/>
|
||||
}
|
||||
headerExtraContent={<Tag>近 26 周</Tag>}
|
||||
>
|
||||
<ResourceContent
|
||||
state={submissions}
|
||||
connected={connected}
|
||||
name="提交日历"
|
||||
>
|
||||
{submissions.data && <SubmissionCalendar data={submissions.data} />}
|
||||
</ResourceContent>
|
||||
</Card>
|
||||
<div className="home-panels">
|
||||
<PyramidPanel
|
||||
<Card
|
||||
className="home-card"
|
||||
title={
|
||||
<PanelTitle
|
||||
icon={<BarChart3 size={18} aria-hidden="true" />}
|
||||
title="回测次数"
|
||||
/>
|
||||
}
|
||||
headerExtraContent={<Tag>近 14 天</Tag>}
|
||||
>
|
||||
<ResourceContent
|
||||
state={simulations}
|
||||
connected={connected}
|
||||
name="回测次数"
|
||||
>
|
||||
{simulations.data && <SimulationChart data={simulations.data} />}
|
||||
</ResourceContent>
|
||||
</Card>
|
||||
<Card
|
||||
className="home-card home-pyramid-card"
|
||||
title={
|
||||
<PanelTitle
|
||||
icon={<Layers3 size={18} aria-hidden="true" />}
|
||||
title="Pyramid 乘数"
|
||||
description="按乘数从高到低排列,日期筛选不影响当前乘数。"
|
||||
metric="乘数"
|
||||
/>
|
||||
}
|
||||
headerExtraContent={<Tag color="blue">乘数 > 1</Tag>}
|
||||
>
|
||||
<ResourceContent
|
||||
state={multipliers}
|
||||
connected={connected}
|
||||
timezone={account?.timezone}
|
||||
/>
|
||||
<PyramidPanel
|
||||
title="Alpha 分布"
|
||||
description="按归属数量从高到低排列;数量为 0 的类别也会保留。"
|
||||
metric="Alpha 数量"
|
||||
state={alphas}
|
||||
connected={connected}
|
||||
timezone={account?.timezone}
|
||||
name="Pyramid 乘数"
|
||||
>
|
||||
<form
|
||||
className="home-period"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (invalidDates) return;
|
||||
if (dirtyDates) setPeriod({ start, end });
|
||||
else alphas.refresh();
|
||||
}}
|
||||
>
|
||||
<div className="home-date-fields">
|
||||
<label>
|
||||
开始日期
|
||||
<input
|
||||
aria-label="分布开始日期"
|
||||
type="date"
|
||||
value={start}
|
||||
max={end || "9999-12-31"}
|
||||
onChange={(event) => setStart(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
结束日期
|
||||
<input
|
||||
aria-label="分布结束日期"
|
||||
type="date"
|
||||
value={end}
|
||||
min={start || undefined}
|
||||
max="9999-12-31"
|
||||
onChange={(event) => setEnd(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<Button
|
||||
htmlType="submit"
|
||||
disabled={!connected || invalidDates || alphas.loading}
|
||||
>
|
||||
应用日期
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!start && !end && !period.start && !period.end}
|
||||
onClick={() => {
|
||||
setStart("");
|
||||
setEnd("");
|
||||
setPeriod({ start: "", end: "" });
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
<small>按平台日期查询;留空使用平台默认范围。</small>
|
||||
</div>
|
||||
{invalidDates && <p role="alert">开始日期不能晚于结束日期。</p>}
|
||||
{dirtyDates && !invalidDates && (
|
||||
<p className="muted">日期已修改,点击“应用日期”更新分布。</p>
|
||||
)}
|
||||
</form>
|
||||
</PyramidPanel>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PyramidPanel({
|
||||
title,
|
||||
description,
|
||||
metric,
|
||||
state,
|
||||
connected,
|
||||
timezone,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
metric: "乘数" | "Alpha 数量";
|
||||
state: ResourceState;
|
||||
connected: boolean;
|
||||
timezone?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const query = search.trim().toLowerCase();
|
||||
const rows = (state.data?.items ?? [])
|
||||
.filter((item) =>
|
||||
`${item.region} D${item.delay} ${item.category_id} ${item.category_name}`
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.value ?? -1) - (a.value ?? -1) ||
|
||||
a.region.localeCompare(b.region) ||
|
||||
a.delay - b.delay ||
|
||||
a.category_id.localeCompare(b.category_id),
|
||||
);
|
||||
const maximum = Math.max(1, ...rows.map((row) => row.value ?? 0));
|
||||
return (
|
||||
<section
|
||||
className="home-panel"
|
||||
aria-label={title}
|
||||
aria-busy={state.loading}
|
||||
>
|
||||
<header>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
{children}
|
||||
{state.error ? (
|
||||
<div className="home-panel-error">
|
||||
<Banner type="warning" description={state.error} />
|
||||
<Button onClick={state.refresh}>重试{title}</Button>
|
||||
</div>
|
||||
) : state.loading ? (
|
||||
<div className="home-empty" role="status">
|
||||
<Spin />
|
||||
<span>正在加载{title}…</span>
|
||||
</div>
|
||||
) : !connected ? (
|
||||
<div className="home-empty">连接账户后显示{title}。</div>
|
||||
) : state.data ? (
|
||||
{multipliers.data && (
|
||||
<>
|
||||
<div className="home-table-tools">
|
||||
<Input
|
||||
aria-label={`搜索${title}`}
|
||||
aria-label="搜索 Pyramid 类别"
|
||||
prefix={<Search size={16} aria-hidden="true" />}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="搜索地区、延迟或类别"
|
||||
showClear
|
||||
placeholder="搜索地区、延迟或类别"
|
||||
/>
|
||||
<span>
|
||||
{rows.length} / {state.data.items.length} 类
|
||||
</span>
|
||||
<span>{rows.length} 类</span>
|
||||
</div>
|
||||
<div className="home-table-wrap">
|
||||
<Table<PyramidRow>
|
||||
@@ -320,73 +369,298 @@ function PyramidPanel({
|
||||
pagination={false}
|
||||
size="small"
|
||||
sticky
|
||||
style={{ minWidth: 440 }}
|
||||
empty={
|
||||
state.data.items.length
|
||||
? "没有匹配的 Pyramid 类别。"
|
||||
: "平台暂未返回 Pyramid 数据。"
|
||||
eligible.length
|
||||
? "没有匹配的 Pyramid 类别"
|
||||
: "当前没有乘数大于 1 的类别"
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
title: "地区",
|
||||
key: "region",
|
||||
width: 64,
|
||||
render: (_, row) => row.region,
|
||||
title: "数据类别",
|
||||
dataIndex: "category_name",
|
||||
render: (_, row) => (
|
||||
<div className="home-category">
|
||||
<strong>{row.category_name}</strong>
|
||||
<small>{row.category_id}</small>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: "地区", dataIndex: "region", width: 68 },
|
||||
{
|
||||
title: "延迟",
|
||||
key: "delay",
|
||||
width: 64,
|
||||
render: (_, row) => <>D{row.delay}</>,
|
||||
width: 58,
|
||||
render: (_, row) => `D${row.delay}`,
|
||||
},
|
||||
{
|
||||
title: "数据类别",
|
||||
key: "category",
|
||||
render: (_, row) => (
|
||||
<>
|
||||
<strong>{row.category_name}</strong>
|
||||
<small>{row.category_id}</small>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: metric,
|
||||
title: "乘数",
|
||||
key: "value",
|
||||
width: 110,
|
||||
width: 80,
|
||||
align: "right",
|
||||
render: (_, row) => (
|
||||
<>
|
||||
<div className="home-value">
|
||||
{row.value === null
|
||||
? "—"
|
||||
: `${row.value.toLocaleString("zh-CN")}${metric === "乘数" ? "×" : ""}`}
|
||||
</div>
|
||||
<div className="home-bar" aria-hidden="true">
|
||||
<i
|
||||
style={{
|
||||
width: `${((row.value ?? 0) / maximum) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<Tag color="blue">
|
||||
{row.value?.toLocaleString("zh-CN")}×
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<footer>
|
||||
{metric === "Alpha 数量" && (
|
||||
<span>
|
||||
查询范围:
|
||||
{state.data.start_date || state.data.end_date
|
||||
? `${state.data.start_date ?? "不限开始"} 至 ${state.data.end_date ?? "不限结束"}`
|
||||
: "平台默认"}
|
||||
</span>
|
||||
)}
|
||||
<span>更新于 {formatTime(state.data.fetched_at, timezone)}</span>
|
||||
</footer>
|
||||
<p className="home-footnote">
|
||||
按乘数从高到低排列
|
||||
{unknownMultipliers
|
||||
? ` · ${unknownMultipliers} 类乘数未知,未列入`
|
||||
: ""}{" "}
|
||||
· 更新于 {easternTime(new Date(multipliers.data.fetched_at))}{" "}
|
||||
美东
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</ResourceContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelTitle({ icon, title }: { icon: ReactNode; title: string }) {
|
||||
return (
|
||||
<h2 className="home-panel-title">
|
||||
{icon}
|
||||
{title}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
hint,
|
||||
icon,
|
||||
loading,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null | undefined;
|
||||
unit: string;
|
||||
hint: string;
|
||||
icon: ReactNode;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="home-metric" aria-label={label}>
|
||||
<div className="home-metric-label">
|
||||
<span>{label}</span>
|
||||
<span className="home-metric-icon">{icon}</span>
|
||||
</div>
|
||||
<div className="home-metric-value">
|
||||
{loading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<>
|
||||
<strong>
|
||||
{value == null ? "—" : value.toLocaleString("zh-CN")}
|
||||
</strong>
|
||||
{value != null && <span>{unit}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p>{hint}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
function ResourceContent({
|
||||
state,
|
||||
connected,
|
||||
name,
|
||||
children,
|
||||
}: {
|
||||
state: ResourceState;
|
||||
connected: boolean;
|
||||
name: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!connected)
|
||||
return <div className="home-empty">连接账户后显示{name}。</div>;
|
||||
if (state.error)
|
||||
return (
|
||||
<div className="home-panel-error">
|
||||
<Banner type="warning" description={state.error} />
|
||||
<Button onClick={state.refresh}>重试{name}</Button>
|
||||
</div>
|
||||
);
|
||||
if (state.loading)
|
||||
return (
|
||||
<div className="home-empty" role="status">
|
||||
<Spin />
|
||||
<span>正在加载{name}…</span>
|
||||
</div>
|
||||
);
|
||||
return children;
|
||||
}
|
||||
function level(count: number | null) {
|
||||
return count === null ? "unknown" : String(Math.min(count, 4));
|
||||
}
|
||||
function SubmissionCalendar({ data }: { data: ActivitySnapshot }) {
|
||||
const first = new Date(`${data.items[0].date}T00:00:00Z`);
|
||||
const offset = first.getUTCDay();
|
||||
const cells: (ActivityDay | null)[] = [
|
||||
...Array<null>(offset).fill(null),
|
||||
...data.items,
|
||||
];
|
||||
const weeks = Array.from(
|
||||
{ length: Math.ceil(cells.length / 7) },
|
||||
(_, index) => cells.slice(index * 7, index * 7 + 7),
|
||||
);
|
||||
const known = data.items.filter((day) => day.count !== null);
|
||||
const total = known.reduce((sum, day) => sum + day.count!, 0);
|
||||
const activeDays = known.filter((day) => day.count! > 0).length;
|
||||
let lastMonth = "";
|
||||
return (
|
||||
<>
|
||||
<div className="home-calendar-summary">
|
||||
<span>
|
||||
{known.length ? (
|
||||
<>
|
||||
<strong>{total.toLocaleString("zh-CN")}</strong> 次提交 ·{" "}
|
||||
<strong>{activeDays}</strong> 个活跃日
|
||||
</>
|
||||
) : (
|
||||
"暂无提交记录"
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{data.items[0].date} 至 {data.today}
|
||||
</span>
|
||||
</div>
|
||||
<div className="home-calendar-scroll">
|
||||
<div className="home-calendar">
|
||||
<div className="home-weekdays" aria-hidden="true">
|
||||
{["", "一", "", "三", "", "五", ""].map((day, i) => (
|
||||
<span key={i}>{day}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-calendar-weeks">
|
||||
{weeks.map((week, index) => {
|
||||
const month =
|
||||
(
|
||||
week.find((day) => day?.date.endsWith("-01")) ??
|
||||
week.find(Boolean)
|
||||
)?.date.slice(0, 7) ?? "";
|
||||
const monthLabel =
|
||||
month !== lastMonth ? `${Number(month.slice(5))}月` : "";
|
||||
lastMonth = month;
|
||||
return (
|
||||
<div className="home-calendar-week" key={index}>
|
||||
<span className="home-month">{monthLabel}</span>
|
||||
{week.map((day, row) =>
|
||||
day ? (
|
||||
<Tooltip
|
||||
key={day.date}
|
||||
content={`${day.date} · ${day.count === null ? "暂无记录" : `提交 ${day.count} 个 Alpha`}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`home-heat-cell heat-${level(day.count)}`}
|
||||
aria-label={`${day.date}:${day.count === null ? "暂无提交记录" : `提交 ${day.count} 个 Alpha`}`}
|
||||
aria-current={
|
||||
day.date === data.today ? "date" : undefined
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="home-heat-spacer" key={`empty-${row}`} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-calendar-bottom">
|
||||
<p>按平台提交活动统计 · 美东日期 · 数据可能延迟</p>
|
||||
<div className="home-legend">
|
||||
<i className="heat-unknown" />
|
||||
暂无记录<span>少</span>
|
||||
{[0, 1, 2, 3, 4].map((n) => (
|
||||
<i
|
||||
key={n}
|
||||
className={`heat-${n}`}
|
||||
title={n === 4 ? "4 次及以上" : `${n} 次`}
|
||||
/>
|
||||
))}
|
||||
<span>多</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function SimulationChart({ data }: { data: ActivitySnapshot }) {
|
||||
const maximum = Math.max(1, ...data.items.map((day) => day.count ?? 0));
|
||||
const magnitude = 10 ** Math.floor(Math.log10(maximum / 4));
|
||||
const step = Math.max(
|
||||
1,
|
||||
[1, 2, 2.5, 5, 10].find((factor) => factor * magnitude >= maximum / 4)! *
|
||||
magnitude,
|
||||
);
|
||||
const ceiling = step * 4;
|
||||
return (
|
||||
<>
|
||||
<p className="home-chart-description">平台每日回测活动 · 美东日期</p>
|
||||
<div className="home-chart-scroll">
|
||||
<div
|
||||
className="home-chart"
|
||||
role="group"
|
||||
aria-label="近 14 天回测次数柱状图"
|
||||
>
|
||||
<div className="home-y-axis" aria-hidden="true">
|
||||
{[4, 3, 2, 1, 0].map((tick) => (
|
||||
<span key={tick}>{(tick * step).toLocaleString("zh-CN")}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-chart-plot">
|
||||
<div className="home-chart-grid" aria-hidden="true">
|
||||
{[0, 1, 2, 3, 4].map((tick) => (
|
||||
<i key={tick} />
|
||||
))}
|
||||
</div>
|
||||
<div className="home-chart-bars">
|
||||
{data.items.map((day) => (
|
||||
<div className="home-chart-column" key={day.date}>
|
||||
<Tooltip
|
||||
content={`${day.date} · ${day.count === null ? "暂无记录" : `回测 ${day.count.toLocaleString("zh-CN")} 次`}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`home-chart-bar ${day.count === null ? "is-unknown" : ""} ${day.date === data.today ? "is-today" : ""}`}
|
||||
style={{
|
||||
height: day.count
|
||||
? `${(day.count / ceiling) * 100}%`
|
||||
: 3,
|
||||
}}
|
||||
aria-label={`${day.date}:${day.count === null ? "暂无回测记录" : `回测 ${day.count} 次`}`}
|
||||
>
|
||||
<span>
|
||||
{day.count === null
|
||||
? "—"
|
||||
: day.count.toLocaleString("zh-CN")}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span
|
||||
className={`home-x-label ${day.date === data.today ? "is-today" : ""}`}
|
||||
>
|
||||
{day.date.slice(5).replace("-", "/")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="home-footnote">
|
||||
包含今日;“—”表示平台暂无记录,0 表示当日无回测。
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Banner, Button, Input, Table, Toast } from "@douyinfe/semi-ui-19";
|
||||
import { api, formatTime, post } from "../api";
|
||||
import { api, formatTime, patch, post } from "../api";
|
||||
import "./mcp-keys.css";
|
||||
|
||||
type Token = {
|
||||
@@ -22,8 +22,16 @@ type TokenPage = {
|
||||
};
|
||||
const scopes = [
|
||||
["research:read", "读取研究数据", "查询目录、历史、运行与结果"],
|
||||
["research:refresh", "刷新研究数据", "更新缓存、检查自相关及恢复 WorldQuant 认证"],
|
||||
["research:write", "保存研究模板", "保存大模型总结的模板及来源,供后续批量回测"],
|
||||
[
|
||||
"research:refresh",
|
||||
"刷新研究数据",
|
||||
"更新缓存、检查自相关及恢复 WorldQuant 认证",
|
||||
],
|
||||
[
|
||||
"research:write",
|
||||
"保存研究模板",
|
||||
"保存大模型总结的模板及来源,供后续批量回测",
|
||||
],
|
||||
["backtests:execute", "执行回测", "提交新的回测批次"],
|
||||
["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"],
|
||||
] as const;
|
||||
@@ -47,6 +55,9 @@ export function MCPKeysPage() {
|
||||
const [created, setCreated] = useState<(Token & { token: string }) | null>(
|
||||
null,
|
||||
);
|
||||
const [editing, setEditing] = useState<Token | null>(null);
|
||||
const [editPermissions, setEditPermissions] = useState<string[]>([]);
|
||||
const [editError, setEditError] = useState("");
|
||||
const [revoking, setRevoking] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -89,6 +100,24 @@ export function MCPKeysPage() {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function savePermissions(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!editing || busy) return;
|
||||
setBusy(true);
|
||||
setEditError("");
|
||||
try {
|
||||
await patch<Token>(`/mcp-tokens/${editing.id}`, {
|
||||
scopes: editPermissions,
|
||||
});
|
||||
setEditing(null);
|
||||
setVersion((v) => v + 1);
|
||||
Toast.success("MCP Key 权限已更新");
|
||||
} catch (e) {
|
||||
setEditError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function revoke(id: string) {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -238,6 +267,53 @@ export function MCPKeysPage() {
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{editing && (
|
||||
<form
|
||||
className="mcp-key-card"
|
||||
aria-label="编辑 Key 权限"
|
||||
onSubmit={(event) => void savePermissions(event)}
|
||||
>
|
||||
<h2>编辑权限:{editing.name}</h2>
|
||||
<p>保存后新请求使用更新后的权限,无需更换客户端 Key。</p>
|
||||
{editError && <Banner type="danger" description={editError} />}
|
||||
<fieldset>
|
||||
<legend>访问权限</legend>
|
||||
<div className="mcp-key-permissions">
|
||||
{scopes.map(([value, label, description]) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.includes(value)}
|
||||
disabled={value === "research:read" || busy}
|
||||
onChange={(e) =>
|
||||
setEditPermissions((old) =>
|
||||
e.target.checked
|
||||
? [...old, value]
|
||||
: old.filter((item) => item !== value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
<small>
|
||||
{description}
|
||||
{value === "research:read" ? "(必选)" : ""}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="mcp-key-actions">
|
||||
<Button htmlType="submit" theme="solid" loading={busy}>
|
||||
保存权限
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => setEditing(null)}>
|
||||
取消编辑
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<section
|
||||
className="mcp-key-card"
|
||||
aria-label="Key 列表"
|
||||
@@ -317,13 +393,29 @@ export function MCPKeysPage() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Button
|
||||
disabled={busy || item.status !== "active"}
|
||||
onClick={() => {
|
||||
setEditing(item);
|
||||
setEditPermissions([...item.scopes]);
|
||||
setEditError("");
|
||||
setRevoking(null);
|
||||
}}
|
||||
>
|
||||
编辑权限
|
||||
</Button>
|
||||
<Button
|
||||
type="danger"
|
||||
disabled={busy}
|
||||
onClick={() => setRevoking(item.id)}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setRevoking(item.id);
|
||||
}}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
+391
-119
@@ -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;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.home-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.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-metric {
|
||||
padding: 14px;
|
||||
.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-metric > strong {
|
||||
font-size: 26px;
|
||||
.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-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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user