feat: add cached homepage information and manual AI interpretations
This commit is contained in:
@@ -111,23 +111,23 @@ export function ModelSettingsPanel() {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
模型标识
|
||||
alpha研究使用模型
|
||||
<Input
|
||||
aria-label="模型标识"
|
||||
aria-label="alpha研究使用模型"
|
||||
value={draft.model}
|
||||
onChange={(model) => setDraft({ ...draft, model })}
|
||||
placeholder="供应商提供的模型名称"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Description 模型标识
|
||||
基础信息处理模型
|
||||
<Input
|
||||
aria-label="Description 模型标识"
|
||||
aria-label="基础信息处理模型"
|
||||
value={draft.description_model}
|
||||
onChange={(description_model) =>
|
||||
setDraft({ ...draft, description_model })
|
||||
}
|
||||
placeholder="用于 Alpha 三段式描述的模型名称"
|
||||
placeholder="用于 Alpha Description、公告摘要与规则解读"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -162,9 +162,11 @@ export function ModelSettingsPanel() {
|
||||
</div>
|
||||
<p className="muted">
|
||||
填写服务端可访问的 API 根地址,按供应商要求包含 /v1;无需填写
|
||||
/chat/completions 或 /responses。密钥加密保存在后端。 Description
|
||||
模型与研究助手共享 Base URL、API Key
|
||||
和接口协议;留空时不启用描述生成。
|
||||
/chat/completions 或 /responses。密钥加密保存在后端。
|
||||
基础信息处理模型用于 Alpha
|
||||
Description、公告摘要和首页信息解读,与研究助手共享 Base URL、API Key
|
||||
和接口协议;留空时不启用描述生成及首页 AI
|
||||
解读。首页解读仅在手动点击时生成。
|
||||
</p>
|
||||
<Checkbox
|
||||
checked={draft.enabled}
|
||||
@@ -192,7 +194,8 @@ export function ModelSettingsPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
测试连接会向已保存的服务发送少量合成请求,按供应商规则计费。
|
||||
测试连接使用
|
||||
alpha研究使用模型,向已保存的服务发送少量合成请求,按供应商规则计费。
|
||||
</p>
|
||||
<div
|
||||
className="ai-test-results"
|
||||
|
||||
@@ -117,7 +117,7 @@ export function SubmissionPanel({
|
||||
)}
|
||||
{!data.can_generate && (
|
||||
<p className="muted">
|
||||
请在个人信息 → 大模型服务中配置 Description 模型。也可以手动填写。
|
||||
请在个人信息 → 大模型服务中配置 基础信息处理模型。也可以手动填写。
|
||||
</p>
|
||||
)}
|
||||
<div className="inline-actions">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ test("model settings, text stream, contextual query, confirmation and preserved
|
||||
.getByRole("textbox", { name: "模型 Base URL" })
|
||||
.fill("https://model.test/v1");
|
||||
await page
|
||||
.getByRole("textbox", { name: "模型标识", exact: true })
|
||||
.getByRole("textbox", { name: "alpha研究使用模型", exact: true })
|
||||
.fill("test-model");
|
||||
await page.getByLabel("模型 API Key").fill("synthetic-model-key");
|
||||
await page.getByRole("button", { name: "保存模型配置" }).click();
|
||||
@@ -254,7 +254,7 @@ test("Lark workspace keeps pagination, account details and chat usable at every
|
||||
);
|
||||
await page.getByRole("button", { name: "配置", exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "模型标识", exact: true })
|
||||
.getByRole("textbox", { name: "alpha研究使用模型", exact: true })
|
||||
.fill("保留模型设置草稿");
|
||||
await page.locator(".settings-drawer .semi-sidesheet-close").click();
|
||||
await page.getByRole("button", { name: "切换研究助手" }).click();
|
||||
@@ -272,7 +272,7 @@ test("Lark workspace keeps pagination, account details and chat usable at every
|
||||
await chat.getByRole("button", { name: "收起研究助手" }).click();
|
||||
await page.getByRole("button", { name: "配置", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "模型标识", exact: true }),
|
||||
page.getByRole("textbox", { name: "alpha研究使用模型", exact: true }),
|
||||
).toHaveValue("保留模型设置草稿");
|
||||
await page.screenshot({
|
||||
path: "../output/playwright/settings-drawer.png",
|
||||
|
||||
Reference in New Issue
Block a user