646 lines
19 KiB
TypeScript
646 lines
19 KiB
TypeScript
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 = {
|
|
ephemeral?: boolean;
|
|
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">
|
|
{state.data?.ephemeral
|
|
? "解读仅暂存在内存,不写入数据库;重启或缓存到期后清除。"
|
|
: "手动生成并保存,重新打开首页不会调用模型。"}
|
|
</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>
|
|
);
|
|
}
|