feat: add report generation runs

This commit is contained in:
2026-06-24 18:00:25 +08:00
parent 049d6a711e
commit 66e2d66055
8 changed files with 1034 additions and 58 deletions

View File

@@ -37,6 +37,7 @@ import {
createRawArtifact,
createAreaScoreModelRun,
createReport,
createReportGenerationRun,
createScenarioRun,
createWatchlistEvent,
createWatchlistItem,
@@ -53,6 +54,8 @@ import {
fetchNeighborhoodScores,
fetchRawArtifacts,
fetchReport,
fetchReportGenerationRuns,
fetchReportTemplates,
fetchReports,
fetchScenarioRuns,
fetchWatchlistEvents,
@@ -71,6 +74,7 @@ import type {
CreateIngestionRun,
CreateRawArtifact,
CreateReport,
CreateReportGenerationRun,
CreateScenarioRun,
CreateWatchlistEvent,
CreateWatchlistItem,
@@ -83,6 +87,8 @@ import type {
NeighborhoodScore,
RawArtifact,
Report,
ReportGenerationRun,
ReportTemplate,
ScenarioRun,
ScenarioRunResponse,
SimilarNeighborhood,
@@ -196,6 +202,14 @@ export function MarketDashboard() {
queryFn: () => fetchReport(selectedReportId ?? 0),
enabled: selectedReportId !== null,
});
const reportTemplates = useQuery({
queryKey: ["report-templates"],
queryFn: fetchReportTemplates,
});
const reportGenerationRuns = useQuery({
queryKey: ["report-generation-runs", MONTH],
queryFn: () => fetchReportGenerationRuns({ target_month: MONTH }),
});
const dataSources = useQuery({
queryKey: ["data-sources"],
queryFn: fetchDataSources,
@@ -274,6 +288,21 @@ export function MarketDashboard() {
]);
},
});
const createReportGenerationMutation = useMutation({
mutationFn: createReportGenerationRun,
onSuccess: async (response) => {
if (response.report) {
setSelectedReportId(response.report.report_id);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reports"] }),
queryClient.invalidateQueries({ queryKey: ["report-generation-runs"] }),
response.report
? queryClient.invalidateQueries({ queryKey: ["report", response.report.report_id] })
: Promise.resolve(),
]);
},
});
const createDataSourceMutation = useMutation({
mutationFn: createDataSource,
onSuccess: async () => {
@@ -364,6 +393,8 @@ export function MarketDashboard() {
scenarioRuns.isLoading ||
reports.isLoading ||
selectedReport.isLoading ||
reportTemplates.isLoading ||
reportGenerationRuns.isLoading ||
dataSources.isLoading ||
ingestionRuns.isLoading;
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
@@ -382,6 +413,8 @@ export function MarketDashboard() {
scenarioRuns.isError ||
reports.isError ||
selectedReport.isError ||
reportTemplates.isError ||
reportGenerationRuns.isError ||
dataSources.isError ||
ingestionRuns.isError ||
rawArtifacts.isError;
@@ -417,6 +450,8 @@ export function MarketDashboard() {
void scenarioRuns.refetch();
void reports.refetch();
void selectedReport.refetch();
void reportTemplates.refetch();
void reportGenerationRuns.refetch();
}}
disabled={isLoading}
aria-label="刷新"
@@ -597,17 +632,26 @@ export function MarketDashboard() {
areaScores={scores.data ?? []}
neighborhoodScores={neighborhoodScores.data ?? []}
reports={reports.data ?? []}
templates={reportTemplates.data ?? []}
generationRuns={reportGenerationRuns.data ?? []}
selectedReport={selectedReport.data ?? null}
selectedReportId={selectedReportId}
reportTypeFilter={reportTypeFilter}
loading={reports.isLoading || selectedReport.isLoading}
loading={
reports.isLoading ||
selectedReport.isLoading ||
reportTemplates.isLoading ||
reportGenerationRuns.isLoading
}
creating={createReportMutation.isPending}
generating={createReportGenerationMutation.isPending}
onReportTypeFilterChange={(value) => {
setReportTypeFilter(value);
setSelectedReportId(null);
}}
onSelectReport={setSelectedReportId}
onCreate={(payload) => createReportMutation.mutate(payload)}
onGenerate={(payload) => createReportGenerationMutation.mutate(payload)}
/>
<ModelRunPanel
@@ -2561,26 +2605,34 @@ function ReportCenter({
areaScores,
neighborhoodScores,
reports,
templates,
generationRuns,
selectedReport,
selectedReportId,
reportTypeFilter,
loading,
creating,
generating,
onReportTypeFilterChange,
onSelectReport,
onCreate,
onGenerate,
}: {
areaScores: AreaScore[];
neighborhoodScores: NeighborhoodScore[];
reports: Report[];
templates: ReportTemplate[];
generationRuns: ReportGenerationRun[];
selectedReport: Report | null;
selectedReportId: number | null;
reportTypeFilter: string;
loading: boolean;
creating: boolean;
generating: boolean;
onReportTypeFilterChange: (value: string) => void;
onSelectReport: (reportId: number) => void;
onCreate: (payload: CreateReport) => void;
onGenerate: (payload: CreateReportGenerationRun) => void;
}) {
const [draftType, setDraftType] = useState<CreateReport["report_type"]>("monthly");
const [draftAreaId, setDraftAreaId] = useState("");
@@ -2629,7 +2681,7 @@ function ReportCenter({
</p>
</div>
<div className="flex gap-2">
{(["monthly", "area_special", "neighborhood_memo"] as const).map((type) => (
{(["monthly", "weekly", "area_special", "neighborhood_memo"] as const).map((type) => (
<Button
key={type}
variant={reportTypeFilter === type ? "default" : "outline"}
@@ -2642,6 +2694,14 @@ function ReportCenter({
</CardHeader>
<CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="grid gap-4">
<ReportGenerationPanel
templates={templates}
runs={generationRuns}
loading={loading}
generating={generating}
onGenerate={onGenerate}
onSelectReport={onSelectReport}
/>
<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">
@@ -2789,6 +2849,115 @@ function ReportList({
);
}
function ReportGenerationPanel({
templates,
runs,
loading,
generating,
onGenerate,
onSelectReport,
}: {
templates: ReportTemplate[];
runs: ReportGenerationRun[];
loading: boolean;
generating: boolean;
onGenerate: (payload: CreateReportGenerationRun) => void;
onSelectReport: (reportId: number) => void;
}) {
const [templateKey, setTemplateKey] = useState("");
const selectedTemplate = templates.find((template) => template.template_key === templateKey);
useEffect(() => {
if (!templateKey && templates.length > 0) {
setTemplateKey(templates[0].template_key);
}
}, [templateKey, templates]);
return (
<div className="rounded-md border border-[var(--border)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-950"></p>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
/
</p>
</div>
<Badge variant="muted">{runs.length}</Badge>
</div>
<div className="mt-3 grid gap-3">
<label className="grid gap-1 text-sm">
<span className="font-medium text-slate-950"></span>
<select
value={templateKey}
onChange={(event) => setTemplateKey(event.target.value)}
className="h-9 rounded-md border border-[var(--border)] px-3"
disabled={templates.length === 0}
>
{templates.map((template) => (
<option key={template.template_key} value={template.template_key}>
{template.name}
</option>
))}
</select>
</label>
{selectedTemplate ? (
<p className="text-xs text-[var(--muted-foreground)]">
{selectedTemplate.cadence} · {selectedTemplate.description}
</p>
) : null}
<Button
onClick={() =>
onGenerate({
template_key: templateKey,
target_month: MONTH,
parameters: { source: "dashboard" },
})
}
disabled={generating || loading || !templateKey}
>
{generating ? "生成中" : "生成报告"}
</Button>
</div>
{runs.length > 0 ? (
<div className="mt-4 overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableBody>
{runs.slice(0, 5).map((run) => (
<TableRow key={run.generation_run_id}>
<TableCell>
<span className="block font-medium text-slate-950">{run.template_name}</span>
<span className="block text-xs text-[var(--muted-foreground)]">
{run.target_month} · {run.status}
</span>
{run.error_message ? (
<span className="block text-xs text-rose-700">{run.error_message}</span>
) : null}
</TableCell>
<TableCell className="text-right">
{run.report_id ? (
<Button
variant="outline"
className="h-8 px-2"
onClick={() => onSelectReport(run.report_id ?? 0)}
>
</Button>
) : (
<Badge variant={run.status === "failed" ? "danger" : "muted"}>
{run.status}
</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : null}
</div>
);
}
function ReportViewer({ report, loading }: { report: Report | null; loading: boolean }) {
if (loading && !report) {
return <div className="h-[520px] rounded-md bg-[var(--muted)]" />;
@@ -2854,6 +3023,9 @@ function reportTypeLabel(type: string) {
if (type === "monthly") {
return "月报";
}
if (type === "weekly") {
return "周报";
}
if (type === "area_special") {
return "板块专题";
}

View File

@@ -486,7 +486,7 @@ export type Report = {
};
export type CreateReport = {
report_type: "monthly" | "area_special" | "neighborhood_memo";
report_type: "monthly" | "weekly" | "area_special" | "neighborhood_memo";
title: string;
target_month?: string;
area_id?: string;
@@ -505,6 +505,44 @@ export type ReportFilters = {
status?: string;
};
export type ReportTemplate = {
template_id: number;
template_key: string;
name: string;
report_type: string;
cadence: string;
description: string;
body_template: string;
created_at: string;
updated_at: string;
};
export type ReportGenerationRun = {
generation_run_id: number;
template_id: number;
template_key: string;
template_name: string;
target_month: string;
status: string;
report_id: number | null;
report_title: string | null;
parameters: Record<string, unknown>;
error_message: string | null;
started_at: string;
finished_at: string | null;
};
export type CreateReportGenerationRun = {
template_key: string;
target_month: string;
parameters?: Record<string, unknown>;
};
export type ReportGenerationRunResponse = {
generation_run: ReportGenerationRun;
report: Report | null;
};
export type ComparisonMetricValue = {
id: string;
value: number | null;
@@ -798,6 +836,34 @@ export async function createReport(payload: CreateReport): Promise<Report> {
});
}
export async function fetchReportTemplates(): Promise<ReportTemplate[]> {
return fetchJson("/api/v1/report-templates");
}
export async function fetchReportGenerationRuns(filters: {
target_month?: string;
template_key?: string;
status?: string;
} = {}): Promise<ReportGenerationRun[]> {
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/report-generation-runs${query ? `?${query}` : ""}`);
}
export async function createReportGenerationRun(
payload: CreateReportGenerationRun,
): Promise<ReportGenerationRunResponse> {
return fetchJson("/api/v1/report-generation-runs", {
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,