feat: add cached homepage information and manual AI interpretations

This commit is contained in:
yuxuanhui
2026-09-12 01:55:24 +08:00
parent 394438e753
commit d9fbaa7cf7
19 changed files with 1674 additions and 18 deletions
+638
View File
@@ -0,0 +1,638 @@
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
Banner,
Button,
Card,
SideSheet,
Spin,
Tag,
} from "@douyinfe/semi-ui-19";
import {
CalendarClock,
ExternalLink,
Megaphone,
RefreshCw,
Sparkles,
Trophy,
TrendingUp,
} from "lucide-react";
import { api, formatTime } from "../api";
import "./home-information.css";
type Item = {
id: string;
title: string;
description: string;
date?: string;
type?: string;
start?: string;
end?: string;
timezone?: string;
original_start?: string;
original_end?: string;
date_status?: string;
status?: string;
rank?: number | null;
alphas?: number | null;
robustness_score?: number | null;
progress?: string | null;
url: string;
links: string[];
};
type Content = {
items?: Item[];
total?: number;
next_offset?: number | null;
rank?: number | null;
change?: number | null;
label?: string;
url?: string;
detail?: Item;
sources?: { id: string; text: string }[];
agreement_updated?: string;
agreement_title?: string;
agreement_url?: string;
};
type Analysis = {
items: {
title: string;
text: string;
evidence: { source_id: string; quote: string }[];
}[];
suggestions: string[];
model: string;
generated_at: string;
outdated: boolean;
};
type Snapshot = {
content: Content | null;
fetched_at: string | null;
error: string | null;
stale: boolean;
source_version: string;
analysis: Analysis | null;
can_generate: boolean;
};
function useInformation(
module: string,
refresh: number,
query = "",
dependency = "",
) {
const [data, setData] = useState<Snapshot>();
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const sequence = useRef(0);
const controller = useRef<AbortController | null>(null);
const previousRefresh = useRef(refresh);
const generating = useRef(false);
const deferredRead = useRef<string | null>(null);
const load = useCallback(
async function loadInformation(action = "") {
if (generating.current) {
if (action !== "generate")
deferredRead.current =
deferredRead.current === "refresh" ? "refresh" : action;
return;
}
generating.current = action === "generate";
const id = ++sequence.current;
controller.current?.abort();
const abort = new AbortController();
controller.current = abort;
setBusy(action || "read");
setError("");
try {
const result = await api<Snapshot>(
`/dashboard/information/${module}${action ? `/${action}` : ""}${query}`,
{
method: action ? "POST" : "GET",
signal: abort.signal,
},
);
if (id === sequence.current) setData(result);
} catch (e) {
if (!abort.signal.aborted && id === sequence.current)
setError((e as Error).message);
} finally {
if (id === sequence.current) {
generating.current = false;
setBusy("");
const deferred = deferredRead.current;
deferredRead.current = null;
if (deferred !== null) void loadInformation(deferred);
}
}
},
[module, query],
);
useEffect(() => {
const action = previousRefresh.current === refresh ? "" : "refresh";
previousRefresh.current = refresh;
void load(action);
}, [load, refresh, dependency]);
useEffect(
() => () => {
controller.current?.abort();
sequence.current++;
},
[],
);
useEffect(() => {
const update = () => {
void load();
};
window.addEventListener("ai-settings-changed", update);
return () => window.removeEventListener("ai-settings-changed", update);
}, [load]);
return { data, busy, error, load };
}
type Resource = ReturnType<typeof useInformation>;
function SourceLink({
url,
children = "查看来源",
}: {
url?: string;
children?: ReactNode;
}) {
if (!url || !/^https?:\/\//i.test(url)) return null;
return (
<a className="home-info-link" href={url} target="_blank" rel="noreferrer">
{children}
<ExternalLink size={12} aria-hidden="true" />
</a>
);
}
function DateLabel({ value, zone }: { value?: string | null; zone: string }) {
// Date-only/naive timestamps have no proven timezone; preserve instead of guessing.
if (!value) return <span>日期不明</span>;
if (!/(Z|[+-]\d{2}:\d{2})$/i.test(value))
return <span>{value}(时区未知)</span>;
return (
<time dateTime={value} title={`平台原始时间:${value}`}>
{formatTime(value, zone)} · {zone}
</time>
);
}
function Interpretation({ state, zone }: { state: Resource; zone: string }) {
const analysis = state.data?.analysis;
return (
<div className="home-info-analysis">
<div className="home-info-row">
<strong>
<Sparkles size={15} aria-hidden="true" /> AI 解读
</strong>
<Button
size="small"
icon={<Sparkles size={14} aria-hidden="true" />}
loading={state.busy === "generate"}
disabled={!!state.busy || !state.data?.can_generate}
onClick={() => void state.load("generate")}
>
{analysis ? "重新生成" : "生成解读"}
</Button>
</div>
{!state.data?.can_generate && (
<p className="home-info-muted">
配置基础信息处理模型并读取平台信息后,可手动生成解读。
</p>
)}
{analysis ? (
<>
<div className="home-info-meta">
<span>
{analysis.model} · {formatTime(analysis.generated_at, zone)}
</span>
{analysis.outdated && <Tag color="orange">解读待更新</Tag>}
</div>
{analysis.outdated && (
<p className="home-info-muted">
来源或模型配置已变化,以下为上次解读。
</p>
)}
{analysis.items.map((item, index) => (
<div className="home-insight" key={index}>
<strong>{item.title}</strong>
<p>{item.text}</p>
{item.evidence.map((e, i) => (
<details key={i}>
<summary>原文依据 · {e.source_id}</summary>
<blockquote>{e.quote}</blockquote>
</details>
))}
</div>
))}
{!!analysis.suggestions.length && (
<>
<strong>建议安排 · 仅供参考</strong>
<ul>
{analysis.suggestions.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
</>
)}
</>
) : (
<p className="home-info-muted">
手动生成并保存,重新打开首页不会调用模型。
</p>
)}
</div>
);
}
function InformationCard({
title,
icon,
state,
zone,
children,
analysis = true,
}: {
title: string;
icon: ReactNode;
state: Resource;
zone: string;
children: ReactNode;
analysis?: boolean;
}) {
return (
<Card
className="home-card home-information-card"
title={
<h2 className="home-panel-title">
{icon}
{title}
</h2>
}
headerExtraContent={
<Button
size="small"
type="tertiary"
aria-label={`刷新${title}`}
icon={<RefreshCw size={14} aria-hidden="true" />}
disabled={!!state.busy}
loading={state.busy === "refresh"}
onClick={() => void state.load("refresh")}
>
刷新
</Button>
}
>
<div className="home-info-meta">
<span>更新:{formatTime(state.data?.fetched_at, zone)}</span>
{(state.data?.stale || (state.error && state.data?.content)) && (
<Tag color="orange">缓存已过期</Tag>
)}
</div>
{(state.error || state.data?.error) && (
<Banner type="warning" description={state.error || state.data?.error} />
)}
{!state.data && state.busy && (
<div className="home-info-loading">
<Spin /> 正在读取{title}…
</div>
)}
{state.data?.content && children}
{analysis && <Interpretation state={state} zone={zone} />}
</Card>
);
}
function Empty({ children }: { children: ReactNode }) {
return <p className="home-info-empty">{children}</p>;
}
function MessageCard({ refresh, zone }: { refresh: number; zone: string }) {
const [offsets, setOffsets] = useState([0]);
const offset = offsets[offsets.length - 1];
return (
<MessagePage
key={offset}
offset={offset}
refresh={refresh}
zone={zone}
back={
offsets.length > 1 ? () => setOffsets(offsets.slice(0, -1)) : undefined
}
next={(value) => setOffsets([...offsets, value])}
/>
);
}
function MessagePage({
offset,
refresh,
zone,
back,
next,
}: {
offset: number;
refresh: number;
zone: string;
back?: () => void;
next: (offset: number) => void;
}) {
const state = useInformation("messages", refresh, `?offset=${offset}`);
const content = state.data?.content;
return (
<InformationCard
title="公告与社区资讯"
icon={<Megaphone size={18} />}
state={state}
zone={zone}
>
<p className="home-info-muted">来自平台消息的公告、更新及社区资讯。</p>
{content?.items?.length ? (
<div className="home-info-feed">
{content.items.map((item, index) => (
<article className="home-info-item" key={item.id || index}>
<div className="home-info-row">
<strong>{item.title}</strong>
{item.type && <Tag>{item.type}</Tag>}
</div>
<p className="home-info-muted">
<DateLabel value={item.date} zone={zone} />
</p>
<details>
<summary>查看消息正文</summary>
<p className="home-info-prose">
{item.description || "平台未提供正文"}
</p>
{item.links.map((link) => (
<div key={link}>
<SourceLink url={link}>{link}</SourceLink>
</div>
))}
</details>
<SourceLink url={item.url} />
</article>
))}
</div>
) : (
<Empty>本页暂无平台消息</Empty>
)}
<div className="home-info-row home-info-pagination">
<span className="home-info-muted">
从第 {offset + 1} 条开始
{content?.total != null ? ` · 平台共 ${content.total} 条` : ""} ·
解读仅针对本页
</span>
<div className="home-info-actions">
<Button size="small" disabled={!back || !!state.busy} onClick={back}>
上一页
</Button>
<Button
size="small"
disabled={content?.next_offset == null || !!state.busy}
onClick={() => next(content!.next_offset!)}
>
下一页
</Button>
</div>
</div>
</InformationCard>
);
}
function LeaderboardCard({ refresh, zone }: { refresh: number; zone: string }) {
const state = useInformation("leaderboard", refresh);
const data = state.data?.content;
return (
<InformationCard
title="顾问排行榜"
icon={<TrendingUp size={18} />}
state={state}
zone={zone}
analysis={false}
>
<p className="home-info-muted">{data?.label}</p>
<div className="home-info-rank">
{data?.rank?.toLocaleString() ?? "—"}
</div>
<p>
{data?.rank == null
? "平台未提供有效名次"
: data?.change == null
? "暂无可比较的历史快照"
: data.change === 0
? "较上次有效快照持平"
: `较上次有效快照${data.change > 0 ? "上升" : "下降"} ${Math.abs(data.change)} 名`}
</p>
<p className="home-info-muted">
仅比较同一账户、美东同日的顾问日度榜快照。比赛排名在比赛卡片中单独展示。
</p>
<SourceLink url={data?.url} />
</InformationCard>
);
}
function CompetitionDetail({
id,
zone,
refresh,
}: {
id: string;
zone: string;
refresh: number;
}) {
const state = useInformation(
"competition",
refresh,
`?competition_id=${encodeURIComponent(id)}`,
);
const data = state.data?.content;
return (
<InformationCard
title="比赛详情与规则"
icon={<Trophy size={18} />}
state={state}
zone={zone}
>
{data?.detail && (
<>
<h3>{data.detail.title}</h3>
<p className="home-info-prose">{data.detail.description}</p>
<p>
截止:
<DateLabel value={data.detail.end} zone={zone} />
</p>
<p>
比赛排名:{data.detail.rank ?? "平台未提供"} · Alpha 数:
{data.detail.alphas ?? "平台未提供"}
</p>
<p>
进度:{data.detail.progress ?? "平台未提供"} · 稳健性分数:
{data.detail.robustness_score ?? "平台未提供"}
</p>
<SourceLink url={data.detail.url}>平台比赛页面</SourceLink>
</>
)}
<h3>{data?.agreement_title || "比赛协议"}</h3>
<p className="home-info-muted">
协议更新:
<DateLabel value={data?.agreement_updated} zone={zone} />
</p>
<SourceLink url={data?.agreement_url}>协议原文入口</SourceLink>
<details className="home-info-agreement">
<summary>展开协议原文</summary>
{data?.sources?.length ? (
data.sources.map((s) => (
<section key={s.id}>
<strong>{s.id}</strong>
<p className="home-info-prose">{s.text}</p>
</section>
))
) : (
<Empty>平台未提供可解读的协议正文</Empty>
)}
</details>
</InformationCard>
);
}
function EventsCard({
refresh,
zone,
dependency,
}: {
refresh: number;
zone: string;
dependency: string;
}) {
const state = useInformation("events", refresh, "", dependency);
return (
<InformationCard
title="未来活动"
icon={<CalendarClock size={18} />}
state={state}
zone={zone}
>
{state.data?.content?.items?.length ? (
<div className="home-event-grid">
{state.data.content.items.map((item, i) => (
<article key={item.id || i} className="home-info-item">
<div className="home-info-row">
<strong>{item.title}</strong>
<Tag
color={item.date_status === "日期不明" ? "orange" : "blue"}
>
{item.date_status}
</Tag>
</div>
<p>
开始:
<DateLabel value={item.start} zone={zone} />
<br />
结束:
<DateLabel value={item.end} zone={zone} />
</p>
<p className="home-info-muted">
平台时区:{item.timezone || "未知"} · 原始时间:
{item.original_start || item.start || "未知"} 至{" "}
{item.original_end || item.end || "未知"}
</p>
<p className="home-info-prose">{item.description}</p>
<SourceLink url={item.url} />
</article>
))}
</div>
) : (
<Empty>暂无尚未结束的活动</Empty>
)}
</InformationCard>
);
}
export function HomeInformation({ timezone: zone }: { timezone: string }) {
const [refresh, setRefresh] = useState(0);
const [selected, setSelected] = useState("");
const competitions = useInformation("competitions", refresh);
return (
<div className="home-information">
<div className="home-info-row">
<h2>平台动向</h2>
<Button
icon={<RefreshCw size={15} />}
onClick={() => setRefresh(refresh + 1)}
>
刷新信息
</Button>
</div>
<div className="home-platform-grid">
<MessageCard refresh={refresh} zone={zone} />
<LeaderboardCard refresh={refresh} zone={zone} />
</div>
<InformationCard
title="比赛参与与进度"
icon={<Trophy size={18} />}
state={competitions}
zone={zone}
>
<p className="home-info-muted">
优先展示未结束比赛,按截止时间排序;点击查看当前协议及规则解读。
</p>
{competitions.data?.content?.items?.length ? (
<div className="home-competition-grid">
{competitions.data.content.items.map((item) => (
<article className="home-info-item" key={item.id}>
<div className="home-info-row">
<strong>{item.title}</strong>
<Tag
color={
item.date_status === "已结束"
? "grey"
: item.date_status === "日期不明"
? "orange"
: "green"
}
>
{item.date_status}
</Tag>
</div>
<p>
截止:
<DateLabel value={item.end} zone={zone} />
</p>
<p className="home-info-muted">
平台状态:{item.status || "平台未提供"}
<br />
比赛排名:{item.rank ?? "平台未提供"} · Alpha 数:
{item.alphas ?? "平台未提供"}
</p>
<Button size="small" onClick={() => setSelected(item.id)}>
查看详情与规则
</Button>
</article>
))}
</div>
) : (
<Empty>暂无已参加的比赛</Empty>
)}
</InformationCard>
<EventsCard
refresh={refresh}
zone={zone}
dependency={competitions.data?.source_version || ""}
/>
<SideSheet
title="比赛信息"
visible={!!selected}
onCancel={() => setSelected("")}
width="min(760px, 100vw)"
closeOnEsc
>
{selected && (
<CompetitionDetail
key={selected}
id={selected}
zone={zone}
refresh={refresh}
/>
)}
</SideSheet>
</div>
);
}
+4
View File
@@ -19,6 +19,7 @@ import {
import { api, post } from "../api";
import type { Account, Job } from "../types";
import "./home.css";
import { HomeInformation } from "./HomeInformation";
type ActivityDay = { date: string; count: number | null };
type ActivitySnapshot = {
@@ -290,6 +291,9 @@ export function HomePage({
</ResourceContent>
</Card>
</div>
{connected && account && (
<HomeInformation key={account.wq_user_id} timezone={account.timezone} />
)}
</section>
);
}
+159
View File
@@ -0,0 +1,159 @@
.home-information {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
.home-information h2 {
margin: 0;
font-size: 18px;
}
.home-platform-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
gap: 20px;
align-items: start;
}
.home-information-card {
min-width: 0;
overflow-wrap: anywhere;
}
.home-info-row {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.home-info-row strong {
display: inline-flex;
align-items: center;
gap: 6px;
}
.home-info-row .semi-tag {
flex-shrink: 0;
}
.home-info-meta,
.home-info-muted {
font-size: 12px;
line-height: 1.7;
color: var(--semi-color-text-2);
}
.home-info-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.home-info-feed {
max-height: 450px;
overflow-y: auto;
padding-right: 8px;
}
.home-info-item {
padding: 16px 0;
border-bottom: 1px solid var(--semi-color-border);
min-width: 0;
}
.home-info-item:first-child {
padding-top: 4px;
}
.home-info-item p {
line-height: 1.7;
}
.home-info-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--semi-color-link);
font-size: 12px;
}
.home-info-prose,
.home-insight p,
.home-insight blockquote {
white-space: pre-wrap;
line-height: 1.8;
overflow-wrap: anywhere;
}
.home-info-prose {
font-size: 13px;
}
.home-info-pagination {
margin-top: 16px;
}
.home-info-actions {
display: flex;
gap: 8px;
}
.home-info-analysis {
margin-top: 20px;
padding: 16px;
background: var(--semi-color-fill-0);
border-radius: 8px;
}
.home-info-analysis li {
margin: 8px 0;
line-height: 1.7;
}
.home-info-analysis ul {
padding-left: 20px;
}
.home-insight {
margin: 16px 0;
}
.home-insight blockquote {
margin: 8px 0;
padding: 8px 12px;
border-left: 2px solid var(--semi-color-primary);
font-size: 12px;
color: var(--semi-color-text-2);
}
.home-information-card summary {
cursor: pointer;
font-size: 13px;
color: var(--semi-color-primary);
padding: 6px 0;
}
.home-info-empty,
.home-info-loading {
padding: 24px 0;
color: var(--semi-color-text-2);
font-size: 13px;
}
.home-info-loading {
display: flex;
align-items: center;
gap: 12px;
}
.home-info-rank {
font-size: 44px;
font-weight: 600;
margin: 24px 0 8px;
font-variant-numeric: tabular-nums;
}
.home-competition-grid,
.home-event-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 24px;
}
.home-info-agreement {
margin: 16px 0;
max-height: 450px;
overflow-y: auto;
}
@media (max-width: 1000px) {
.home-platform-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 650px) {
.home-competition-grid,
.home-event-grid {
grid-template-columns: minmax(0, 1fr);
}
.home-info-analysis {
padding: 12px;
}
}