feat: add import center dashboard

This commit is contained in:
2026-06-23 18:39:54 +08:00
parent 0cbde3aab7
commit 7efef8ae64
3 changed files with 399 additions and 3 deletions

View File

@@ -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,

View File

@@ -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,