From 7efef8ae64299fcbf779becee4fa70a74fae6df3 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Tue, 23 Jun 2026 18:39:54 +0800 Subject: [PATCH] feat: add import center dashboard --- .../components/dashboard/market-dashboard.tsx | 322 +++++++++++++++++- apps/web/src/lib/api.ts | 78 +++++ docs/product_roadmap.md | 2 + 3 files changed, 399 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index 167212a..3cbd964 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -6,6 +6,7 @@ import { Activity, AlertTriangle, Archive, + Check, MapPinned, Plus, RefreshCw, @@ -24,13 +25,27 @@ import { import { archiveWatchlistItem, + createDataSource, + createIngestionRun, createWatchlistItem, + fetchDataSources, fetchAreaScores, + fetchIngestionRuns, fetchMarketOverview, fetchNeighborhoodScores, fetchWatchlistItems, + finishIngestionRun, +} from "@/lib/api"; +import type { + AreaScore, + CreateDataSource, + CreateIngestionRun, + CreateWatchlistItem, + DataSource, + IngestionRun, + NeighborhoodScore, + WatchlistItem, } from "@/lib/api"; -import type { AreaScore, CreateWatchlistItem, NeighborhoodScore, WatchlistItem } from "@/lib/api"; import { formatNumber, formatPrice } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -65,6 +80,14 @@ export function MarketDashboard() { queryKey: ["watchlist"], queryFn: fetchWatchlistItems, }); + const dataSources = useQuery({ + queryKey: ["data-sources"], + queryFn: fetchDataSources, + }); + const ingestionRuns = useQuery({ + queryKey: ["ingestion-runs"], + queryFn: fetchIngestionRuns, + }); const createMutation = useMutation({ mutationFn: createWatchlistItem, onSuccess: async () => { @@ -77,11 +100,40 @@ export function MarketDashboard() { await queryClient.invalidateQueries({ queryKey: ["watchlist"] }); }, }); + const createDataSourceMutation = useMutation({ + mutationFn: createDataSource, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["data-sources"] }); + }, + }); + const createRunMutation = useMutation({ + mutationFn: createIngestionRun, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] }); + }, + }); + const finishRunMutation = useMutation({ + mutationFn: ({ id, rowCount }: { id: number; rowCount?: number }) => + finishIngestionRun(id, { status: "succeeded", row_count: rowCount }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] }); + }, + }); const isLoading = - overview.isLoading || scores.isLoading || neighborhoodScores.isLoading || watchlist.isLoading; + overview.isLoading || + scores.isLoading || + neighborhoodScores.isLoading || + watchlist.isLoading || + dataSources.isLoading || + ingestionRuns.isLoading; const hasError = - overview.isError || scores.isError || neighborhoodScores.isError || watchlist.isError; + overview.isError || + scores.isError || + neighborhoodScores.isError || + watchlist.isError || + dataSources.isError || + ingestionRuns.isError; return (
@@ -102,6 +154,8 @@ export function MarketDashboard() { void scores.refetch(); void neighborhoodScores.refetch(); void watchlist.refetch(); + void dataSources.refetch(); + void ingestionRuns.refetch(); }} disabled={isLoading} aria-label="刷新" @@ -218,11 +272,273 @@ export function MarketDashboard() { onCreate={(payload) => createMutation.mutate(payload)} onArchive={(id) => archiveMutation.mutate(id)} /> + + createDataSourceMutation.mutate(payload)} + onCreateRun={(payload) => createRunMutation.mutate(payload)} + onFinishRun={(id, rowCount) => finishRunMutation.mutate({ id, rowCount })} + />
); } +function ImportCenterPanel({ + sources, + runs, + loading, + creatingSource, + creatingRun, + finishingRunId, + onCreateSource, + onCreateRun, + onFinishRun, +}: { + sources: DataSource[]; + runs: IngestionRun[]; + loading: boolean; + creatingSource: boolean; + creatingRun: boolean; + finishingRunId: number | null; + onCreateSource: (payload: CreateDataSource) => void; + onCreateRun: (payload: CreateIngestionRun) => void; + onFinishRun: (id: number, rowCount?: number) => void; +}) { + const [sourceName, setSourceName] = useState(""); + const [sourceType, setSourceType] = useState("manual"); + const [sourceUrl, setSourceUrl] = useState(""); + const [sourceId, setSourceId] = useState(""); + const [rawUri, setRawUri] = useState(""); + const [rowCount, setRowCount] = useState(""); + + function submitSource(event: React.FormEvent) { + event.preventDefault(); + if (!sourceName.trim()) { + return; + } + onCreateSource({ + name: sourceName.trim(), + source_type: sourceType, + url: sourceUrl.trim() || undefined, + cadence: "ad hoc", + reliability: sourceType === "official" ? "high" : "medium", + notes: "front-end registered source", + }); + setSourceName(""); + setSourceUrl(""); + } + + function submitRun(event: React.FormEvent) { + event.preventDefault(); + const parsedSourceId = Number(sourceId); + if (!Number.isFinite(parsedSourceId) || parsedSourceId <= 0) { + return; + } + onCreateRun({ + source_id: parsedSourceId, + raw_uri: rawUri.trim() || undefined, + status: "running", + }); + setRawUri(""); + } + + return ( + + +
+ 导入中心 +

+ 数据源登记与导入批次审计 +

+
+
+
+ setSourceName(event.target.value)} + /> + + setSourceUrl(event.target.value)} + /> + +
+
+ + setRawUri(event.target.value)} + /> + +
+
+
+ + + { + const parsedRowCount = Number(rowCount); + onFinishRun(id, Number.isFinite(parsedRowCount) && parsedRowCount >= 0 ? parsedRowCount : undefined); + setRowCount(""); + }} + /> + +
+ ); +} + +function DataSourceTable({ sources, loading }: { sources: DataSource[]; loading: boolean }) { + if (loading) { + return
; + } + + return ( +
+ + + + 数据源 + 类型 + 可靠性 + + + + {sources.map((source) => ( + + {source.name} + {source.source_type} + + + {source.reliability} + + + + ))} + +
+
+ ); +} + +function IngestionRunTable({ + runs, + loading, + rowCount, + onRowCountChange, + finishingRunId, + onFinishRun, +}: { + runs: IngestionRun[]; + loading: boolean; + rowCount: string; + onRowCountChange: (value: string) => void; + finishingRunId: number | null; + onFinishRun: (id: number) => void; +}) { + if (loading) { + return
; + } + + return ( +
+ + + + 批次 + 数据源 + 状态 + raw 路径 + 行数 + + + + + {runs.map((run) => ( + + #{run.run_id} + {run.source_name ?? "-"} + + + {run.status} + + + + {run.raw_uri ?? "-"} + + {run.row_count ?? "-"} + + {run.status === "running" ? ( +
+ onRowCountChange(event.target.value)} + /> + +
+ ) : null} +
+
+ ))} +
+
+
+ ); +} + function NeighborhoodRankingPanel({ scores, neighborhoodScores, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 1ca8736..99567fa 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -85,6 +85,52 @@ export type CreateWatchlistItem = { notes?: string; }; +export type DataSource = { + source_id: number; + name: string; + source_type: string; + url: string | null; + cadence: string | null; + reliability: string; + notes: string; + created_at: string; +}; + +export type CreateDataSource = { + name: string; + source_type: string; + url?: string; + cadence?: string; + reliability?: string; + notes?: string; +}; + +export type IngestionRun = { + run_id: number; + source_id: number | null; + source_name: string | null; + status: string; + started_at: string; + finished_at: string | null; + raw_uri: string | null; + row_count: number | null; + error_message: string | null; +}; + +export type CreateIngestionRun = { + source_id?: number; + status?: string; + raw_uri?: string; + row_count?: number; + error_message?: string; +}; + +export type FinishIngestionRun = { + status: "succeeded" | "failed"; + row_count?: number; + error_message?: string; +}; + const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080"; @@ -119,6 +165,38 @@ export async function archiveWatchlistItem(id: number): Promise { }); } +export async function fetchDataSources(): Promise { + return fetchJson("/api/v1/data-sources"); +} + +export async function createDataSource(payload: CreateDataSource): Promise { + return fetchJson("/api/v1/data-sources", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function fetchIngestionRuns(): Promise { + return fetchJson("/api/v1/ingestion-runs"); +} + +export async function createIngestionRun(payload: CreateIngestionRun): Promise { + return fetchJson("/api/v1/ingestion-runs", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function finishIngestionRun( + id: number, + payload: FinishIngestionRun, +): Promise { + return fetchJson(`/api/v1/ingestion-runs/${id}/finish`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + async function fetchJson(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, diff --git a/docs/product_roadmap.md b/docs/product_roadmap.md index 0b42339..fe23914 100644 --- a/docs/product_roadmap.md +++ b/docs/product_roadmap.md @@ -55,6 +55,8 @@ ### M1.2 前端导入中心 +状态:已完成。 + 目标: - 在前端工作台中查看数据源和导入批次。