feat: add report generation runs
This commit is contained in:
@@ -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 "板块专题";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user