feat(dashboard): add Eastern-time activity overview and submission calendar
This commit is contained in:
+600
-326
@@ -1,7 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Banner, Button, Input, Spin, Table } from "@douyinfe/semi-ui-19";
|
||||
import { api, formatTime } from "../api";
|
||||
import type { Account } from "../types";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Toast,
|
||||
Tooltip,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
BarChart3,
|
||||
CalendarDays,
|
||||
Clock3,
|
||||
Layers3,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { api, post } from "../api";
|
||||
import type { Account, Job } from "../types";
|
||||
import "./home.css";
|
||||
|
||||
type PyramidRow = {
|
||||
@@ -11,126 +31,202 @@ type PyramidRow = {
|
||||
category_name: string;
|
||||
value: number | null;
|
||||
};
|
||||
type Snapshot = {
|
||||
items: PyramidRow[];
|
||||
type PyramidSnapshot = { items: PyramidRow[]; fetched_at: string };
|
||||
type ActivityDay = { date: string; count: number | null };
|
||||
type ActivitySnapshot = {
|
||||
items: ActivityDay[];
|
||||
today: string;
|
||||
timezone: string;
|
||||
fetched_at: string;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
};
|
||||
|
||||
function useSnapshot(
|
||||
path: string,
|
||||
enabled: boolean,
|
||||
version: number,
|
||||
identity: string,
|
||||
) {
|
||||
const [data, setData] = useState<Snapshot | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const easternFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/New_York",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
function easternTime(date: Date) {
|
||||
const parts = Object.fromEntries(
|
||||
easternFormatter
|
||||
.formatToParts(date)
|
||||
.map(({ type, value }) => [type, value]),
|
||||
);
|
||||
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
|
||||
}
|
||||
function useResource<T>(path: string, enabled: boolean, revision: string) {
|
||||
const [result, setResult] = useState<{
|
||||
key: string;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}>();
|
||||
const [retry, setRetry] = useState(0);
|
||||
const key = `${path}:${revision}:${enabled}:${retry}`;
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
const controller = new AbortController();
|
||||
setData(null);
|
||||
setError("");
|
||||
setLoading(enabled);
|
||||
if (enabled) {
|
||||
api<Snapshot>(path, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (!ignore) setData(next);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!ignore) setError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ignore) setLoading(false);
|
||||
});
|
||||
if (!enabled) {
|
||||
setResult(undefined);
|
||||
return;
|
||||
}
|
||||
return () => {
|
||||
ignore = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [path, enabled, version, identity, retry]);
|
||||
const controller = new AbortController();
|
||||
api<T>(path, { signal: controller.signal })
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) setResult({ key, data });
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (!controller.signal.aborted)
|
||||
setResult({ key, error: error.message });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [path, enabled, key]);
|
||||
// Keyed results prevent another account or yesterday's totals flashing while a new read starts.
|
||||
const current = enabled && result?.key === key ? result : undefined;
|
||||
return {
|
||||
data: enabled ? data : null,
|
||||
error: enabled ? error : "",
|
||||
loading: enabled && loading,
|
||||
refresh: () => setRetry((value) => value + 1),
|
||||
data: current?.data,
|
||||
error: current?.error,
|
||||
loading: enabled && !current,
|
||||
refresh: () => setRetry((n) => n + 1),
|
||||
};
|
||||
}
|
||||
type ResourceState = ReturnType<typeof useSnapshot>;
|
||||
type ResourceState = { error?: string; loading: boolean; refresh: () => void };
|
||||
|
||||
export function HomePage({
|
||||
account,
|
||||
jobs,
|
||||
onAccount,
|
||||
onChange,
|
||||
}: {
|
||||
account: Account | null;
|
||||
jobs: Job[];
|
||||
onAccount: () => void;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [version, setVersion] = useState(0);
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
const [period, setPeriod] = useState({ start: "", end: "" });
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verificationId, setVerificationId] = useState<string | null>(null);
|
||||
const [verificationError, setVerificationError] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const time = easternTime(now);
|
||||
const today = time.slice(0, 10);
|
||||
const connected =
|
||||
account?.connection_status === "connected" && !!account?.wq_user_id;
|
||||
const identity = account?.wq_user_id ?? "";
|
||||
const params = new URLSearchParams();
|
||||
if (period.start) params.set("start_date", period.start);
|
||||
if (period.end) params.set("end_date", period.end);
|
||||
const multipliers = useSnapshot(
|
||||
account?.connection_status === "connected" && !!account.wq_user_id;
|
||||
const revision = `${account?.wq_user_id ?? ""}:${today}:${version}`;
|
||||
const submissions = useResource<ActivitySnapshot>(
|
||||
"/dashboard/activity/submissions",
|
||||
connected,
|
||||
revision,
|
||||
);
|
||||
const simulations = useResource<ActivitySnapshot>(
|
||||
"/dashboard/activity/simulations",
|
||||
connected,
|
||||
revision,
|
||||
);
|
||||
const multipliers = useResource<PyramidSnapshot>(
|
||||
"/dashboard/pyramid-multipliers",
|
||||
connected,
|
||||
version,
|
||||
identity,
|
||||
revision,
|
||||
);
|
||||
const alphas = useSnapshot(
|
||||
`/dashboard/pyramid-alphas?${params}`,
|
||||
connected,
|
||||
version,
|
||||
identity,
|
||||
const verification = jobs.find((job) => job.id === verificationId);
|
||||
const authRunning = jobs.some(
|
||||
(job) =>
|
||||
["connect", "verify", "profile"].includes(job.kind) &&
|
||||
["queued", "running"].includes(job.status),
|
||||
);
|
||||
const multiplierValues =
|
||||
multipliers.data?.items.flatMap((item) =>
|
||||
item.value === null ? [] : [item.value],
|
||||
) ?? [];
|
||||
const counts = alphas.data?.items;
|
||||
const completeCounts = counts && counts.every((item) => item.value !== null);
|
||||
const coverage = completeCounts
|
||||
? counts.filter((item) => item.value! > 0).length
|
||||
: null;
|
||||
const total = completeCounts
|
||||
? counts.reduce((sum, item) => sum + item.value!, 0)
|
||||
: null;
|
||||
const invalidDates = !!(start && end && start > end);
|
||||
const dirtyDates = start !== period.start || end !== period.end;
|
||||
const metrics = [
|
||||
["Pyramid 类别", multipliers.data?.items.length, "当前乘数列表中的类别"],
|
||||
[
|
||||
"最高乘数",
|
||||
multiplierValues.length &&
|
||||
multiplierValues.length === multipliers.data?.items.length
|
||||
? `${Math.max(...multiplierValues).toLocaleString("zh-CN")}×`
|
||||
: null,
|
||||
"平台当前鼓励程度",
|
||||
],
|
||||
["已覆盖类别", coverage, "所选范围内 Alpha 数量大于 0"],
|
||||
["Alpha 归属次数", total, "跨类别累计,同一 Alpha 可能重复计入"],
|
||||
] as const;
|
||||
const busy = sending || authRunning || !!verificationId;
|
||||
useEffect(() => {
|
||||
if (!verification || ["queued", "running"].includes(verification.status))
|
||||
return;
|
||||
setVerificationId(null);
|
||||
if (verification.status === "completed") {
|
||||
setVersion((n) => n + 1);
|
||||
Toast.success("验证完成,正在更新看板");
|
||||
} else {
|
||||
setVerificationError(
|
||||
verification.error || "验证尚未完成,请到个人信息页查看账户状态。",
|
||||
);
|
||||
}
|
||||
}, [verification]);
|
||||
async function refreshVerification() {
|
||||
setSending(true);
|
||||
setVerificationError("");
|
||||
try {
|
||||
const job = await post<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 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
|
||||
theme="solid"
|
||||
icon={<RefreshCw size={16} aria-hidden="true" />}
|
||||
loading={busy}
|
||||
disabled={
|
||||
!account?.configured ||
|
||||
busy ||
|
||||
account.connection_status === "connecting"
|
||||
}
|
||||
onClick={() => void refreshVerification()}
|
||||
>
|
||||
刷新验证
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!connected || multipliers.loading || alphas.loading}
|
||||
onClick={() => setVersion((value) => value + 1)}
|
||||
>
|
||||
刷新看板
|
||||
</Button>
|
||||
</header>
|
||||
{verificationError && (
|
||||
<Banner type="warning" description={verificationError} />
|
||||
)}
|
||||
{!account ? (
|
||||
<div className="home-empty">
|
||||
<Spin />
|
||||
@@ -139,254 +235,432 @@ 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>
|
||||
)
|
||||
)}
|
||||
<div className="home-metrics">
|
||||
{metrics.map(([label, value, hint]) => (
|
||||
<article className="home-metric" key={label} aria-label={label}>
|
||||
<span>{label}</span>
|
||||
<strong>
|
||||
{typeof value === "number"
|
||||
? value.toLocaleString("zh-CN")
|
||||
: (value ?? "—")}
|
||||
</strong>
|
||||
<small>{hint}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-panels">
|
||||
<PyramidPanel
|
||||
title="Pyramid 乘数"
|
||||
description="按乘数从高到低排列,日期筛选不影响当前乘数。"
|
||||
metric="乘数"
|
||||
state={multipliers}
|
||||
connected={connected}
|
||||
timezone={account?.timezone}
|
||||
{account?.verification_url && (
|
||||
<Banner
|
||||
type="warning"
|
||||
description={
|
||||
<span>
|
||||
请先
|
||||
<a
|
||||
href={account.verification_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
打开 WorldQuant 验证页面
|
||||
</a>
|
||||
,完成后点击“刷新验证”。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<PyramidPanel
|
||||
title="Alpha 分布"
|
||||
description="按归属数量从高到低排列;数量为 0 的类别也会保留。"
|
||||
metric="Alpha 数量"
|
||||
state={alphas}
|
||||
)}
|
||||
<div className="home-metrics">
|
||||
<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}
|
||||
timezone={account?.timezone}
|
||||
name="提交日历"
|
||||
>
|
||||
<form
|
||||
className="home-period"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (invalidDates) return;
|
||||
if (dirtyDates) setPeriod({ start, end });
|
||||
else alphas.refresh();
|
||||
}}
|
||||
{submissions.data && <SubmissionCalendar data={submissions.data} />}
|
||||
</ResourceContent>
|
||||
</Card>
|
||||
<div className="home-panels">
|
||||
<Card
|
||||
className="home-card"
|
||||
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">
|
||||
<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>
|
||||
{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 乘数"
|
||||
/>
|
||||
}
|
||||
headerExtraContent={<Tag color="blue">乘数 > 1</Tag>}
|
||||
>
|
||||
<ResourceContent
|
||||
state={multipliers}
|
||||
connected={connected}
|
||||
name="Pyramid 乘数"
|
||||
>
|
||||
{multipliers.data && (
|
||||
<>
|
||||
<div className="home-table-tools">
|
||||
<Input
|
||||
aria-label="搜索 Pyramid 类别"
|
||||
prefix={<Search size={16} aria-hidden="true" />}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
showClear
|
||||
placeholder="搜索地区、延迟或类别"
|
||||
/>
|
||||
<span>{rows.length} 类</span>
|
||||
</div>
|
||||
<div className="home-table-wrap">
|
||||
<Table<PyramidRow>
|
||||
dataSource={rows}
|
||||
rowKey={(row) =>
|
||||
row ? `${row.region}:${row.delay}:${row.category_id}` : ""
|
||||
}
|
||||
pagination={false}
|
||||
size="small"
|
||||
sticky
|
||||
empty={
|
||||
eligible.length
|
||||
? "没有匹配的 Pyramid 类别"
|
||||
: "当前没有乘数大于 1 的类别"
|
||||
}
|
||||
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>
|
||||
</PyramidPanel>
|
||||
</ResourceContent>
|
||||
</Card>
|
||||
</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));
|
||||
function PanelTitle({ icon, title }: { icon: ReactNode; title: string }) {
|
||||
return (
|
||||
<section
|
||||
className="home-panel"
|
||||
aria-label={title}
|
||||
aria-busy={state.loading}
|
||||
>
|
||||
<header>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
{children}
|
||||
{state.error ? (
|
||||
<div className="home-panel-error">
|
||||
<Banner type="warning" description={state.error} />
|
||||
<Button onClick={state.refresh}>重试{title}</Button>
|
||||
</div>
|
||||
) : state.loading ? (
|
||||
<div className="home-empty" role="status">
|
||||
<Spin />
|
||||
<span>正在加载{title}…</span>
|
||||
</div>
|
||||
) : !connected ? (
|
||||
<div className="home-empty">连接账户后显示{title}。</div>
|
||||
) : state.data ? (
|
||||
<>
|
||||
<div className="home-table-tools">
|
||||
<Input
|
||||
aria-label={`搜索${title}`}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="搜索地区、延迟或类别"
|
||||
showClear
|
||||
/>
|
||||
<span>
|
||||
{rows.length} / {state.data.items.length} 类
|
||||
</span>
|
||||
</div>
|
||||
<div className="home-table-wrap">
|
||||
<Table<PyramidRow>
|
||||
dataSource={rows}
|
||||
rowKey={(row) =>
|
||||
row ? `${row.region}:${row.delay}:${row.category_id}` : ""
|
||||
}
|
||||
pagination={false}
|
||||
size="small"
|
||||
sticky
|
||||
style={{ minWidth: 440 }}
|
||||
empty={
|
||||
state.data.items.length
|
||||
? "没有匹配的 Pyramid 类别。"
|
||||
: "平台暂未返回 Pyramid 数据。"
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
title: "地区",
|
||||
key: "region",
|
||||
width: 64,
|
||||
render: (_, row) => row.region,
|
||||
},
|
||||
{
|
||||
title: "延迟",
|
||||
key: "delay",
|
||||
width: 64,
|
||||
render: (_, row) => <>D{row.delay}</>,
|
||||
},
|
||||
{
|
||||
title: "数据类别",
|
||||
key: "category",
|
||||
render: (_, row) => (
|
||||
<>
|
||||
<strong>{row.category_name}</strong>
|
||||
<small>{row.category_id}</small>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: metric,
|
||||
key: "value",
|
||||
width: 110,
|
||||
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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<footer>
|
||||
{metric === "Alpha 数量" && (
|
||||
<span>
|
||||
查询范围:
|
||||
{state.data.start_date || state.data.end_date
|
||||
? `${state.data.start_date ?? "不限开始"} 至 ${state.data.end_date ?? "不限结束"}`
|
||||
: "平台默认"}
|
||||
</span>
|
||||
)}
|
||||
<span>更新于 {formatTime(state.data.fetched_at, timezone)}</span>
|
||||
</footer>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
<h2 className="home-panel-title">
|
||||
{icon}
|
||||
{title}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
hint,
|
||||
icon,
|
||||
loading,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null | undefined;
|
||||
unit: string;
|
||||
hint: string;
|
||||
icon: ReactNode;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="home-metric" aria-label={label}>
|
||||
<div className="home-metric-label">
|
||||
<span>{label}</span>
|
||||
<span className="home-metric-icon">{icon}</span>
|
||||
</div>
|
||||
<div className="home-metric-value">
|
||||
{loading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<>
|
||||
<strong>
|
||||
{value == null ? "—" : value.toLocaleString("zh-CN")}
|
||||
</strong>
|
||||
{value != null && <span>{unit}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p>{hint}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
function ResourceContent({
|
||||
state,
|
||||
connected,
|
||||
name,
|
||||
children,
|
||||
}: {
|
||||
state: ResourceState;
|
||||
connected: boolean;
|
||||
name: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!connected)
|
||||
return <div className="home-empty">连接账户后显示{name}。</div>;
|
||||
if (state.error)
|
||||
return (
|
||||
<div className="home-panel-error">
|
||||
<Banner type="warning" description={state.error} />
|
||||
<Button onClick={state.refresh}>重试{name}</Button>
|
||||
</div>
|
||||
);
|
||||
if (state.loading)
|
||||
return (
|
||||
<div className="home-empty" role="status">
|
||||
<Spin />
|
||||
<span>正在加载{name}…</span>
|
||||
</div>
|
||||
);
|
||||
return children;
|
||||
}
|
||||
function level(count: number | null) {
|
||||
return count === null ? "unknown" : String(Math.min(count, 4));
|
||||
}
|
||||
function SubmissionCalendar({ data }: { data: ActivitySnapshot }) {
|
||||
const first = new Date(`${data.items[0].date}T00:00:00Z`);
|
||||
const offset = first.getUTCDay();
|
||||
const cells: (ActivityDay | null)[] = [
|
||||
...Array<null>(offset).fill(null),
|
||||
...data.items,
|
||||
];
|
||||
const weeks = Array.from(
|
||||
{ length: Math.ceil(cells.length / 7) },
|
||||
(_, index) => cells.slice(index * 7, index * 7 + 7),
|
||||
);
|
||||
const known = data.items.filter((day) => day.count !== null);
|
||||
const total = known.reduce((sum, day) => sum + day.count!, 0);
|
||||
const activeDays = known.filter((day) => day.count! > 0).length;
|
||||
let lastMonth = "";
|
||||
return (
|
||||
<>
|
||||
<div className="home-calendar-summary">
|
||||
<span>
|
||||
{known.length ? (
|
||||
<>
|
||||
<strong>{total.toLocaleString("zh-CN")}</strong> 次提交 ·{" "}
|
||||
<strong>{activeDays}</strong> 个活跃日
|
||||
</>
|
||||
) : (
|
||||
"暂无提交记录"
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{data.items[0].date} 至 {data.today}
|
||||
</span>
|
||||
</div>
|
||||
<div className="home-calendar-scroll">
|
||||
<div className="home-calendar">
|
||||
<div className="home-weekdays" aria-hidden="true">
|
||||
{["", "一", "", "三", "", "五", ""].map((day, i) => (
|
||||
<span key={i}>{day}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-calendar-weeks">
|
||||
{weeks.map((week, index) => {
|
||||
const month =
|
||||
(
|
||||
week.find((day) => day?.date.endsWith("-01")) ??
|
||||
week.find(Boolean)
|
||||
)?.date.slice(0, 7) ?? "";
|
||||
const monthLabel =
|
||||
month !== lastMonth ? `${Number(month.slice(5))}月` : "";
|
||||
lastMonth = month;
|
||||
return (
|
||||
<div className="home-calendar-week" key={index}>
|
||||
<span className="home-month">{monthLabel}</span>
|
||||
{week.map((day, row) =>
|
||||
day ? (
|
||||
<Tooltip
|
||||
key={day.date}
|
||||
content={`${day.date} · ${day.count === null ? "暂无记录" : `提交 ${day.count} 个 Alpha`}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`home-heat-cell heat-${level(day.count)}`}
|
||||
aria-label={`${day.date}:${day.count === null ? "暂无提交记录" : `提交 ${day.count} 个 Alpha`}`}
|
||||
aria-current={
|
||||
day.date === data.today ? "date" : undefined
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="home-heat-spacer" key={`empty-${row}`} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-calendar-bottom">
|
||||
<p>按平台提交活动统计 · 美东日期 · 数据可能延迟</p>
|
||||
<div className="home-legend">
|
||||
<i className="heat-unknown" />
|
||||
暂无记录<span>少</span>
|
||||
{[0, 1, 2, 3, 4].map((n) => (
|
||||
<i
|
||||
key={n}
|
||||
className={`heat-${n}`}
|
||||
title={n === 4 ? "4 次及以上" : `${n} 次`}
|
||||
/>
|
||||
))}
|
||||
<span>多</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function SimulationChart({ data }: { data: ActivitySnapshot }) {
|
||||
const maximum = Math.max(1, ...data.items.map((day) => day.count ?? 0));
|
||||
const magnitude = 10 ** Math.floor(Math.log10(maximum / 4));
|
||||
const step = Math.max(
|
||||
1,
|
||||
[1, 2, 2.5, 5, 10].find((factor) => factor * magnitude >= maximum / 4)! *
|
||||
magnitude,
|
||||
);
|
||||
const ceiling = step * 4;
|
||||
return (
|
||||
<>
|
||||
<p className="home-chart-description">平台每日回测活动 · 美东日期</p>
|
||||
<div className="home-chart-scroll">
|
||||
<div
|
||||
className="home-chart"
|
||||
role="group"
|
||||
aria-label="近 14 天回测次数柱状图"
|
||||
>
|
||||
<div className="home-y-axis" aria-hidden="true">
|
||||
{[4, 3, 2, 1, 0].map((tick) => (
|
||||
<span key={tick}>{(tick * step).toLocaleString("zh-CN")}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-chart-plot">
|
||||
<div className="home-chart-grid" aria-hidden="true">
|
||||
{[0, 1, 2, 3, 4].map((tick) => (
|
||||
<i key={tick} />
|
||||
))}
|
||||
</div>
|
||||
<div className="home-chart-bars">
|
||||
{data.items.map((day) => (
|
||||
<div className="home-chart-column" key={day.date}>
|
||||
<Tooltip
|
||||
content={`${day.date} · ${day.count === null ? "暂无记录" : `回测 ${day.count.toLocaleString("zh-CN")} 次`}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`home-chart-bar ${day.count === null ? "is-unknown" : ""} ${day.date === data.today ? "is-today" : ""}`}
|
||||
style={{
|
||||
height: day.count
|
||||
? `${(day.count / ceiling) * 100}%`
|
||||
: 3,
|
||||
}}
|
||||
aria-label={`${day.date}:${day.count === null ? "暂无回测记录" : `回测 ${day.count} 次`}`}
|
||||
>
|
||||
<span>
|
||||
{day.count === null
|
||||
? "—"
|
||||
: day.count.toLocaleString("zh-CN")}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span
|
||||
className={`home-x-label ${day.date === data.today ? "is-today" : ""}`}
|
||||
>
|
||||
{day.date.slice(5).replace("-", "/")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="home-footnote">
|
||||
包含今日;“—”表示平台暂无记录,0 表示当日无回测。
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+393
-121
@@ -2,183 +2,455 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px 4px 24px;
|
||||
padding: 12px 8px 24px;
|
||||
}
|
||||
.home-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.home-heading p,
|
||||
.home-panel header p,
|
||||
.home-connect p {
|
||||
.home-heading p {
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.home-heading-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.home-clock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.home-clock > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.home-clock time {
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.home-page > .semi-banner {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.home-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.home-metric,
|
||||
.home-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
.home-metric.semi-card,
|
||||
.home-card.semi-card {
|
||||
background: var(--surface);
|
||||
border-color: var(--line);
|
||||
border-radius: 8px;
|
||||
min-width: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.home-metric {
|
||||
.home-metric .semi-card-body {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.home-metric-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
.home-metric > span {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.home-metric > strong {
|
||||
font-size: 30px;
|
||||
.home-metric-icon {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--accent);
|
||||
background: var(--semi-color-primary-light-default);
|
||||
}
|
||||
.home-metric-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-height: 53px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.home-metric-value strong {
|
||||
font-size: 34px;
|
||||
line-height: 1.35;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.home-metric small,
|
||||
.home-period small,
|
||||
.home-panel footer {
|
||||
.home-metric-value > span,
|
||||
.home-metric p {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.home-card .semi-card-header {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.home-card .semi-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
.home-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.home-panel-title svg {
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.home-calendar-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.home-panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr));
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.home-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
.home-panel header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.home-panel header p {
|
||||
font-size: 12px;
|
||||
}
|
||||
.home-connect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
background: var(--semi-color-primary-light-default);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.home-connect > div {
|
||||
flex: 1;
|
||||
}
|
||||
.home-connect p {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.home-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 20px;
|
||||
min-height: 180px;
|
||||
padding: 24px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
.home-period {
|
||||
padding: 16px 20px;
|
||||
background: var(--canvas);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.home-date-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.home-date-fields input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 4px 8px;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
body[theme-mode="dark"] .home-date-fields input {
|
||||
color-scheme: dark;
|
||||
}
|
||||
.home-table-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.home-table-tools > span {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.home-table-wrap {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
}
|
||||
.home-panel td strong {
|
||||
font-weight: 400;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.home-panel td small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.home-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
}
|
||||
.home-bar {
|
||||
height: 3px;
|
||||
background: var(--canvas);
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.home-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.home-panel footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.home-panel-error {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
min-height: 180px;
|
||||
}
|
||||
.home-calendar-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.home-calendar-summary strong {
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.home-calendar-scroll {
|
||||
overflow-x: auto;
|
||||
padding: 3px 2px 5px;
|
||||
}
|
||||
.home-calendar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
min-width: 550px;
|
||||
max-width: 840px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.home-weekdays {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(7, 1fr);
|
||||
gap: 5px;
|
||||
padding-top: 25px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.home-weekdays span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.home-calendar-weeks {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(14px, 1fr);
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
}
|
||||
.home-calendar-week {
|
||||
display: grid;
|
||||
grid-template-rows: 20px repeat(7, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
.home-month {
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.home-heat-cell,
|
||||
.home-heat-spacer {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.home-heat-cell {
|
||||
border: 1px solid rgb(0 0 0 / 4%);
|
||||
padding: 0;
|
||||
}
|
||||
.home-heat-cell:hover {
|
||||
outline: 1px solid var(--muted);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.home-heat-cell[aria-current="date"] {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.heat-unknown {
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--surface),
|
||||
var(--surface) 3px,
|
||||
var(--semi-color-fill-0) 3px,
|
||||
var(--semi-color-fill-0) 5px
|
||||
);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.heat-0 {
|
||||
background: var(--semi-color-fill-0);
|
||||
}
|
||||
.heat-1 {
|
||||
background: #c2e7b6;
|
||||
}
|
||||
.heat-2 {
|
||||
background: #83cb7a;
|
||||
}
|
||||
.heat-3 {
|
||||
background: #4da663;
|
||||
}
|
||||
.heat-4 {
|
||||
background: #287c47;
|
||||
}
|
||||
body[theme-mode="dark"] .heat-1 {
|
||||
background: #244f32;
|
||||
}
|
||||
body[theme-mode="dark"] .heat-2 {
|
||||
background: #347644;
|
||||
}
|
||||
body[theme-mode="dark"] .heat-3 {
|
||||
background: #45a05a;
|
||||
}
|
||||
body[theme-mode="dark"] .heat-4 {
|
||||
background: #73c888;
|
||||
}
|
||||
.home-calendar-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.home-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.home-legend i {
|
||||
display: inline-block;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.home-legend > span {
|
||||
margin: 0 4px;
|
||||
}
|
||||
.home-table-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.home-table-tools > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.home-table-wrap {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
.home-category {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.home-category strong {
|
||||
font-weight: 400;
|
||||
}
|
||||
.home-category small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.home-footnote {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.home-chart-description {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.home-chart-scroll {
|
||||
overflow-x: auto;
|
||||
padding: 0 2px 5px;
|
||||
}
|
||||
.home-chart {
|
||||
display: flex;
|
||||
min-width: 470px;
|
||||
height: 260px;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 28px;
|
||||
gap: 10px;
|
||||
}
|
||||
.home-y-axis {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 30px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
top: -6px;
|
||||
}
|
||||
.home-chart-plot {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.home-chart-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
.home-chart-grid i {
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
.home-chart-grid i:last-child {
|
||||
border-top-style: solid;
|
||||
}
|
||||
.home-chart-bars {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(14, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
.home-chart-column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.home-chart-bar {
|
||||
width: 70%;
|
||||
max-width: 26px;
|
||||
background: var(--accent);
|
||||
opacity: 0.7;
|
||||
border: 0;
|
||||
border-radius: 3px 3px 0 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.home-chart-bar.is-today,
|
||||
.home-chart-bar:hover,
|
||||
.home-chart-bar:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.home-chart-bar.is-unknown {
|
||||
background: var(--line);
|
||||
}
|
||||
.home-chart-bar > span {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: var(--ink);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.home-x-label {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.home-x-label.is-today {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.home-panels {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.home-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.home-metric {
|
||||
padding: 14px;
|
||||
}
|
||||
.home-metric > strong {
|
||||
font-size: 26px;
|
||||
.home-page {
|
||||
padding: 8px 0 20px;
|
||||
}
|
||||
.home-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.home-heading-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.home-metrics {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.home-metric .semi-card-body {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.home-metric-value strong {
|
||||
font-size: 30px;
|
||||
}
|
||||
.home-card .semi-card-header,
|
||||
.home-card .semi-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.home-connect {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user