feat: add report center
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
||||
createIngestionRun,
|
||||
createRawArtifact,
|
||||
createAreaScoreModelRun,
|
||||
createReport,
|
||||
createScenarioRun,
|
||||
createWatchlistEvent,
|
||||
createWatchlistItem,
|
||||
@@ -51,6 +52,8 @@ import {
|
||||
fetchNeighborhoodDetail,
|
||||
fetchNeighborhoodScores,
|
||||
fetchRawArtifacts,
|
||||
fetchReport,
|
||||
fetchReports,
|
||||
fetchScenarioRuns,
|
||||
fetchWatchlistEvents,
|
||||
fetchWatchlistItems,
|
||||
@@ -67,6 +70,7 @@ import type {
|
||||
CreateDataSource,
|
||||
CreateIngestionRun,
|
||||
CreateRawArtifact,
|
||||
CreateReport,
|
||||
CreateScenarioRun,
|
||||
CreateWatchlistEvent,
|
||||
CreateWatchlistItem,
|
||||
@@ -78,6 +82,7 @@ import type {
|
||||
NeighborhoodDetail,
|
||||
NeighborhoodScore,
|
||||
RawArtifact,
|
||||
Report,
|
||||
ScenarioRun,
|
||||
ScenarioRunResponse,
|
||||
SimilarNeighborhood,
|
||||
@@ -114,6 +119,8 @@ export function MarketDashboard() {
|
||||
const [baseModelRunId, setBaseModelRunId] = useState<number | null>(null);
|
||||
const [candidateModelRunId, setCandidateModelRunId] = useState<number | null>(null);
|
||||
const [latestScenario, setLatestScenario] = useState<ScenarioRunResponse | null>(null);
|
||||
const [reportTypeFilter, setReportTypeFilter] = useState("monthly");
|
||||
const [selectedReportId, setSelectedReportId] = useState<number | null>(null);
|
||||
const overview = useQuery({
|
||||
queryKey: ["market-overview", MONTH],
|
||||
queryFn: () => fetchMarketOverview(MONTH),
|
||||
@@ -176,6 +183,19 @@ export function MarketDashboard() {
|
||||
queryKey: ["scenario-runs", MONTH],
|
||||
queryFn: () => fetchScenarioRuns({ target_month: MONTH }),
|
||||
});
|
||||
const reports = useQuery({
|
||||
queryKey: ["reports", reportTypeFilter, MONTH],
|
||||
queryFn: () =>
|
||||
fetchReports({
|
||||
report_type: reportTypeFilter,
|
||||
target_month: reportTypeFilter === "monthly" ? MONTH : undefined,
|
||||
}),
|
||||
});
|
||||
const selectedReport = useQuery({
|
||||
queryKey: ["report", selectedReportId],
|
||||
queryFn: () => fetchReport(selectedReportId ?? 0),
|
||||
enabled: selectedReportId !== null,
|
||||
});
|
||||
const dataSources = useQuery({
|
||||
queryKey: ["data-sources"],
|
||||
queryFn: fetchDataSources,
|
||||
@@ -244,6 +264,16 @@ export function MarketDashboard() {
|
||||
await queryClient.invalidateQueries({ queryKey: ["scenario-runs"] });
|
||||
},
|
||||
});
|
||||
const createReportMutation = useMutation({
|
||||
mutationFn: createReport,
|
||||
onSuccess: async (report) => {
|
||||
setSelectedReportId(report.report_id);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reports"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["report", report.report_id] }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const createDataSourceMutation = useMutation({
|
||||
mutationFn: createDataSource,
|
||||
onSuccess: async () => {
|
||||
@@ -308,6 +338,17 @@ export function MarketDashboard() {
|
||||
}
|
||||
}, [baseModelRunId, candidateModelRunId, modelRuns.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reports.data && reports.data.length > 0) {
|
||||
const selectedExists =
|
||||
selectedReportId !== null &&
|
||||
reports.data.some((report) => report.report_id === selectedReportId);
|
||||
if (!selectedExists) {
|
||||
setSelectedReportId(reports.data[0].report_id);
|
||||
}
|
||||
}
|
||||
}, [reports.data, selectedReportId]);
|
||||
|
||||
const isLoading =
|
||||
overview.isLoading ||
|
||||
scores.isLoading ||
|
||||
@@ -321,6 +362,8 @@ export function MarketDashboard() {
|
||||
modelRuns.isLoading ||
|
||||
areaScoreRunDiff.isLoading ||
|
||||
scenarioRuns.isLoading ||
|
||||
reports.isLoading ||
|
||||
selectedReport.isLoading ||
|
||||
dataSources.isLoading ||
|
||||
ingestionRuns.isLoading;
|
||||
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
||||
@@ -337,6 +380,8 @@ export function MarketDashboard() {
|
||||
modelRuns.isError ||
|
||||
areaScoreRunDiff.isError ||
|
||||
scenarioRuns.isError ||
|
||||
reports.isError ||
|
||||
selectedReport.isError ||
|
||||
dataSources.isError ||
|
||||
ingestionRuns.isError ||
|
||||
rawArtifacts.isError;
|
||||
@@ -370,6 +415,8 @@ export function MarketDashboard() {
|
||||
void modelRuns.refetch();
|
||||
void areaScoreRunDiff.refetch();
|
||||
void scenarioRuns.refetch();
|
||||
void reports.refetch();
|
||||
void selectedReport.refetch();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
aria-label="刷新"
|
||||
@@ -546,6 +593,23 @@ export function MarketDashboard() {
|
||||
onCreate={(payload) => createScenarioMutation.mutate(payload)}
|
||||
/>
|
||||
|
||||
<ReportCenter
|
||||
areaScores={scores.data ?? []}
|
||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||
reports={reports.data ?? []}
|
||||
selectedReport={selectedReport.data ?? null}
|
||||
selectedReportId={selectedReportId}
|
||||
reportTypeFilter={reportTypeFilter}
|
||||
loading={reports.isLoading || selectedReport.isLoading}
|
||||
creating={createReportMutation.isPending}
|
||||
onReportTypeFilterChange={(value) => {
|
||||
setReportTypeFilter(value);
|
||||
setSelectedReportId(null);
|
||||
}}
|
||||
onSelectReport={setSelectedReportId}
|
||||
onCreate={(payload) => createReportMutation.mutate(payload)}
|
||||
/>
|
||||
|
||||
<ModelRunPanel
|
||||
runs={modelRuns.data ?? []}
|
||||
diff={areaScoreRunDiff.data ?? null}
|
||||
@@ -2493,6 +2557,342 @@ function scenarioDeltaClass(value: number) {
|
||||
return "text-xs font-medium text-[var(--muted-foreground)]";
|
||||
}
|
||||
|
||||
function ReportCenter({
|
||||
areaScores,
|
||||
neighborhoodScores,
|
||||
reports,
|
||||
selectedReport,
|
||||
selectedReportId,
|
||||
reportTypeFilter,
|
||||
loading,
|
||||
creating,
|
||||
onReportTypeFilterChange,
|
||||
onSelectReport,
|
||||
onCreate,
|
||||
}: {
|
||||
areaScores: AreaScore[];
|
||||
neighborhoodScores: NeighborhoodScore[];
|
||||
reports: Report[];
|
||||
selectedReport: Report | null;
|
||||
selectedReportId: number | null;
|
||||
reportTypeFilter: string;
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
onReportTypeFilterChange: (value: string) => void;
|
||||
onSelectReport: (reportId: number) => void;
|
||||
onCreate: (payload: CreateReport) => void;
|
||||
}) {
|
||||
const [draftType, setDraftType] = useState<CreateReport["report_type"]>("monthly");
|
||||
const [draftAreaId, setDraftAreaId] = useState("");
|
||||
const [draftNeighborhoodId, setDraftNeighborhoodId] = useState("");
|
||||
const [draftTitle, setDraftTitle] = useState(`上海房市投资研究月报 ${MONTH}`);
|
||||
const [draftSummary, setDraftSummary] = useState("核心结论待复核。");
|
||||
const [draftContent, setDraftContent] = useState(defaultReportMarkdown("monthly"));
|
||||
|
||||
function submitReport() {
|
||||
onCreate({
|
||||
report_type: draftType,
|
||||
title: draftTitle,
|
||||
target_month: MONTH,
|
||||
area_id: draftAreaId || undefined,
|
||||
neighborhood_id: draftNeighborhoodId || undefined,
|
||||
summary: draftSummary,
|
||||
content_markdown: draftContent,
|
||||
linked_metrics: {
|
||||
month: MONTH,
|
||||
area_id: draftAreaId || null,
|
||||
neighborhood_id: draftNeighborhoodId || null,
|
||||
},
|
||||
status: "draft",
|
||||
});
|
||||
}
|
||||
|
||||
function changeDraftType(value: CreateReport["report_type"]) {
|
||||
setDraftType(value);
|
||||
setDraftTitle(defaultReportTitle(value));
|
||||
setDraftContent(defaultReportMarkdown(value));
|
||||
if (value !== "area_special") {
|
||||
setDraftAreaId("");
|
||||
}
|
||||
if (value !== "neighborhood_memo") {
|
||||
setDraftNeighborhoodId("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<CardTitle>报告中心</CardTitle>
|
||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||
月报、板块专题与小区备忘录
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(["monthly", "area_special", "neighborhood_memo"] as const).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={reportTypeFilter === type ? "default" : "outline"}
|
||||
onClick={() => onReportTypeFilterChange(type)}
|
||||
>
|
||||
{reportTypeLabel(type)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-md border border-[var(--border)] p-4">
|
||||
<p className="text-sm font-semibold text-slate-950">新建报告</p>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">类型</span>
|
||||
<select
|
||||
value={draftType}
|
||||
onChange={(event) =>
|
||||
changeDraftType(event.target.value as CreateReport["report_type"])
|
||||
}
|
||||
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||
>
|
||||
<option value="monthly">月报</option>
|
||||
<option value="area_special">板块专题</option>
|
||||
<option value="neighborhood_memo">小区备忘录</option>
|
||||
</select>
|
||||
</label>
|
||||
{draftType === "area_special" ? (
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">关联板块</span>
|
||||
<select
|
||||
value={draftAreaId}
|
||||
onChange={(event) => setDraftAreaId(event.target.value)}
|
||||
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
{areaScores.map((score) => (
|
||||
<option key={score.area_id} value={score.area_id}>
|
||||
{score.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
{draftType === "neighborhood_memo" ? (
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">关联小区</span>
|
||||
<select
|
||||
value={draftNeighborhoodId}
|
||||
onChange={(event) => setDraftNeighborhoodId(event.target.value)}
|
||||
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
{neighborhoodScores.map((score) => (
|
||||
<option key={score.neighborhood_id} value={score.neighborhood_id}>
|
||||
{score.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">标题</span>
|
||||
<input
|
||||
value={draftTitle}
|
||||
onChange={(event) => setDraftTitle(event.target.value)}
|
||||
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">摘要</span>
|
||||
<textarea
|
||||
value={draftSummary}
|
||||
onChange={(event) => setDraftSummary(event.target.value)}
|
||||
className="min-h-16 rounded-md border border-[var(--border)] px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">Markdown 正文</span>
|
||||
<textarea
|
||||
value={draftContent}
|
||||
onChange={(event) => setDraftContent(event.target.value)}
|
||||
className="min-h-56 rounded-md border border-[var(--border)] px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
onClick={submitReport}
|
||||
disabled={creating || !draftTitle.trim() || !draftContent.trim()}
|
||||
>
|
||||
{creating ? "保存中" : "保存草稿"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ReportList
|
||||
reports={reports}
|
||||
selectedReportId={selectedReportId}
|
||||
loading={loading}
|
||||
onSelectReport={onSelectReport}
|
||||
/>
|
||||
</div>
|
||||
<ReportViewer report={selectedReport} loading={loading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportList({
|
||||
reports,
|
||||
selectedReportId,
|
||||
loading,
|
||||
onSelectReport,
|
||||
}: {
|
||||
reports: Report[];
|
||||
selectedReportId: number | null;
|
||||
loading: boolean;
|
||||
onSelectReport: (reportId: number) => void;
|
||||
}) {
|
||||
if (loading && reports.length === 0) {
|
||||
return <div className="h-56 rounded-md bg-[var(--muted)]" />;
|
||||
}
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||
暂无报告
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{reports.slice(0, 8).map((report) => (
|
||||
<TableRow
|
||||
key={report.report_id}
|
||||
className={
|
||||
selectedReportId === report.report_id
|
||||
? "cursor-pointer bg-teal-50"
|
||||
: "cursor-pointer hover:bg-slate-50"
|
||||
}
|
||||
onClick={() => onSelectReport(report.report_id)}
|
||||
>
|
||||
<TableCell>
|
||||
<span className="block font-medium text-slate-950">{report.title}</span>
|
||||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||
{reportTypeLabel(report.report_type)} · {report.target_month ?? "-"} ·{" "}
|
||||
{report.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportViewer({ report, loading }: { report: Report | null; loading: boolean }) {
|
||||
if (loading && !report) {
|
||||
return <div className="h-[520px] rounded-md bg-[var(--muted)]" />;
|
||||
}
|
||||
if (!report) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||
选择或新建一篇报告
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const linkedMetricRows = Object.entries(report.linked_metrics ?? {});
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-md border border-[var(--border)] p-4">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-950">{report.title}</p>
|
||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||
{reportTypeLabel(report.report_type)} · {report.target_month ?? "-"}
|
||||
{report.area_name ? ` · ${report.area_name}` : ""}
|
||||
{report.neighborhood_name ? ` · ${report.neighborhood_name}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={report.status === "published" ? "success" : "muted"}>
|
||||
{report.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{report.summary ? (
|
||||
<p className="mt-3 text-sm text-[var(--muted-foreground)]">{report.summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{linkedMetricRows.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>关联指标</TableHead>
|
||||
<TableHead>值</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{linkedMetricRows.map(([key, value]) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell className="font-medium">{key}</TableCell>
|
||||
<TableCell>{formatLinkedMetricValue(value)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
<article className="max-h-[640px] overflow-auto whitespace-pre-wrap rounded-md border border-[var(--border)] bg-white p-5 font-mono text-sm leading-6 text-slate-900">
|
||||
{report.content_markdown}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function reportTypeLabel(type: string) {
|
||||
if (type === "monthly") {
|
||||
return "月报";
|
||||
}
|
||||
if (type === "area_special") {
|
||||
return "板块专题";
|
||||
}
|
||||
if (type === "neighborhood_memo") {
|
||||
return "小区备忘录";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
function defaultReportTitle(type: CreateReport["report_type"]) {
|
||||
if (type === "area_special") {
|
||||
return `板块专题 ${MONTH}`;
|
||||
}
|
||||
if (type === "neighborhood_memo") {
|
||||
return `小区备忘录 ${MONTH}`;
|
||||
}
|
||||
return `上海房市投资研究月报 ${MONTH}`;
|
||||
}
|
||||
|
||||
function defaultReportMarkdown(type: CreateReport["report_type"]) {
|
||||
if (type === "area_special") {
|
||||
return `# 板块专题 ${MONTH}\n\n## 核心判断\n\n- \n\n## 数据证据\n\n- \n\n## 风险与跟踪\n\n- `;
|
||||
}
|
||||
if (type === "neighborhood_memo") {
|
||||
return `# 小区备忘录 ${MONTH}\n\n## 资产画像\n\n- \n\n## 相似资产与价格\n\n- \n\n## 触发条件\n\n- `;
|
||||
}
|
||||
return `# 上海房市投资研究月报 ${MONTH}\n\n## 核心结论\n\n- \n\n## 板块变化\n\n- \n\n## 观察池动作\n\n- \n\n## 下月跟踪\n\n- `;
|
||||
}
|
||||
|
||||
function formatLinkedMetricValue(value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function WatchlistPanel({
|
||||
scores,
|
||||
neighborhoodScores,
|
||||
|
||||
@@ -468,6 +468,43 @@ export type ScenarioRunResponse = {
|
||||
variants: ScenarioVariant[];
|
||||
};
|
||||
|
||||
export type Report = {
|
||||
report_id: number;
|
||||
report_type: string;
|
||||
title: string;
|
||||
target_month: string | null;
|
||||
area_id: string | null;
|
||||
area_name: string | null;
|
||||
neighborhood_id: string | null;
|
||||
neighborhood_name: string | null;
|
||||
summary: string;
|
||||
content_markdown: string;
|
||||
linked_metrics: Record<string, unknown>;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CreateReport = {
|
||||
report_type: "monthly" | "area_special" | "neighborhood_memo";
|
||||
title: string;
|
||||
target_month?: string;
|
||||
area_id?: string;
|
||||
neighborhood_id?: string;
|
||||
summary?: string;
|
||||
content_markdown: string;
|
||||
linked_metrics?: Record<string, unknown>;
|
||||
status?: "draft" | "published" | "archived";
|
||||
};
|
||||
|
||||
export type ReportFilters = {
|
||||
report_type?: string;
|
||||
target_month?: string;
|
||||
area_id?: string;
|
||||
neighborhood_id?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type ComparisonMetricValue = {
|
||||
id: string;
|
||||
value: number | null;
|
||||
@@ -739,6 +776,28 @@ export async function createScenarioRun(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchReports(filters: ReportFilters = {}): Promise<Report[]> {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
const query = params.toString();
|
||||
return fetchJson(`/api/v1/reports${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function fetchReport(id: number): Promise<Report> {
|
||||
return fetchJson(`/api/v1/reports/${id}`);
|
||||
}
|
||||
|
||||
export async function createReport(payload: CreateReport): Promise<Report> {
|
||||
return fetchJson("/api/v1/reports", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
|
||||
Reference in New Issue
Block a user