feat: add import center dashboard
This commit is contained in:
@@ -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 (
|
||||
<main className="min-h-screen px-4 py-5 sm:px-6 lg:px-8">
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
<ImportCenterPanel
|
||||
sources={dataSources.data ?? []}
|
||||
runs={ingestionRuns.data ?? []}
|
||||
loading={isLoading}
|
||||
creatingSource={createDataSourceMutation.isPending}
|
||||
creatingRun={createRunMutation.isPending}
|
||||
finishingRunId={finishRunMutation.variables?.id ?? null}
|
||||
onCreateSource={(payload) => createDataSourceMutation.mutate(payload)}
|
||||
onCreateRun={(payload) => createRunMutation.mutate(payload)}
|
||||
onFinishRun={(id, rowCount) => finishRunMutation.mutate({ id, rowCount })}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLFormElement>) {
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<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="grid w-full gap-3 xl:w-auto xl:grid-cols-2">
|
||||
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={submitSource}>
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-40"
|
||||
placeholder="数据源名称"
|
||||
value={sourceName}
|
||||
onChange={(event) => setSourceName(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||||
value={sourceType}
|
||||
onChange={(event) => setSourceType(event.target.value)}
|
||||
aria-label="数据源类型"
|
||||
>
|
||||
<option value="manual">manual</option>
|
||||
<option value="official">official</option>
|
||||
<option value="market">market</option>
|
||||
</select>
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-52"
|
||||
placeholder="URL"
|
||||
value={sourceUrl}
|
||||
onChange={(event) => setSourceUrl(event.target.value)}
|
||||
/>
|
||||
<Button disabled={!sourceName.trim() || creatingSource} aria-label="新增数据源" title="新增数据源">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={submitRun}>
|
||||
<select
|
||||
className="h-9 min-w-40 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||||
value={sourceId}
|
||||
onChange={(event) => setSourceId(event.target.value)}
|
||||
aria-label="导入数据源"
|
||||
>
|
||||
<option value="">数据源</option>
|
||||
{sources.map((source) => (
|
||||
<option key={source.source_id} value={source.source_id}>
|
||||
{source.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-56"
|
||||
placeholder="raw 路径"
|
||||
value={rawUri}
|
||||
onChange={(event) => setRawUri(event.target.value)}
|
||||
/>
|
||||
<Button disabled={!sourceId || creatingRun} aria-label="创建导入批次" title="创建导入批次">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||
<DataSourceTable sources={sources} loading={loading} />
|
||||
<IngestionRunTable
|
||||
runs={runs}
|
||||
loading={loading}
|
||||
rowCount={rowCount}
|
||||
onRowCountChange={setRowCount}
|
||||
finishingRunId={finishingRunId}
|
||||
onFinishRun={(id) => {
|
||||
const parsedRowCount = Number(rowCount);
|
||||
onFinishRun(id, Number.isFinite(parsedRowCount) && parsedRowCount >= 0 ? parsedRowCount : undefined);
|
||||
setRowCount("");
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DataSourceTable({ sources, loading }: { sources: DataSource[]; loading: boolean }) {
|
||||
if (loading) {
|
||||
return <div className="h-48 rounded-md bg-[var(--muted)]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>数据源</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>可靠性</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sources.map((source) => (
|
||||
<TableRow key={source.source_id}>
|
||||
<TableCell className="font-medium">{source.name}</TableCell>
|
||||
<TableCell>{source.source_type}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={source.reliability === "high" ? "success" : "muted"}>
|
||||
{source.reliability}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <div className="h-48 rounded-md bg-[var(--muted)]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>批次</TableHead>
|
||||
<TableHead>数据源</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>raw 路径</TableHead>
|
||||
<TableHead className="text-right">行数</TableHead>
|
||||
<TableHead className="w-44" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((run) => (
|
||||
<TableRow key={run.run_id}>
|
||||
<TableCell className="font-medium">#{run.run_id}</TableCell>
|
||||
<TableCell>{run.source_name ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={run.status === "succeeded" ? "success" : "warning"}>
|
||||
{run.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64 truncate text-[var(--muted-foreground)]">
|
||||
{run.raw_uri ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{run.row_count ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
{run.status === "running" ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<input
|
||||
className="h-9 w-20 rounded-md border border-[var(--border)] bg-white px-2 text-sm"
|
||||
inputMode="numeric"
|
||||
placeholder="行数"
|
||||
value={rowCount}
|
||||
onChange={(event) => onRowCountChange(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={finishingRunId === run.run_id}
|
||||
onClick={() => onFinishRun(run.run_id)}
|
||||
aria-label="完成导入"
|
||||
title="完成导入"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NeighborhoodRankingPanel({
|
||||
scores,
|
||||
neighborhoodScores,
|
||||
|
||||
@@ -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<WatchlistItem> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDataSources(): Promise<DataSource[]> {
|
||||
return fetchJson("/api/v1/data-sources");
|
||||
}
|
||||
|
||||
export async function createDataSource(payload: CreateDataSource): Promise<DataSource> {
|
||||
return fetchJson("/api/v1/data-sources", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchIngestionRuns(): Promise<IngestionRun[]> {
|
||||
return fetchJson("/api/v1/ingestion-runs");
|
||||
}
|
||||
|
||||
export async function createIngestionRun(payload: CreateIngestionRun): Promise<IngestionRun> {
|
||||
return fetchJson("/api/v1/ingestion-runs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function finishIngestionRun(
|
||||
id: number,
|
||||
payload: FinishIngestionRun,
|
||||
): Promise<IngestionRun> {
|
||||
return fetchJson(`/api/v1/ingestion-runs/${id}/finish`, {
|
||||
method: "PATCH",
|
||||
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