feat: add raw artifact audit trail

This commit is contained in:
2026-06-24 09:38:15 +08:00
parent 6a17c9a336
commit a770070b34
12 changed files with 670 additions and 16 deletions

View File

@@ -27,12 +27,14 @@ import {
archiveWatchlistItem,
createDataSource,
createIngestionRun,
createRawArtifact,
createWatchlistItem,
fetchDataSources,
fetchAreaScores,
fetchIngestionRuns,
fetchMarketOverview,
fetchNeighborhoodScores,
fetchRawArtifacts,
fetchWatchlistItems,
finishIngestionRun,
} from "@/lib/api";
@@ -40,10 +42,12 @@ import type {
AreaScore,
CreateDataSource,
CreateIngestionRun,
CreateRawArtifact,
CreateWatchlistItem,
DataSource,
IngestionRun,
NeighborhoodScore,
RawArtifact,
WatchlistItem,
} from "@/lib/api";
import { formatNumber, formatPrice } from "@/lib/utils";
@@ -88,6 +92,10 @@ export function MarketDashboard() {
queryKey: ["ingestion-runs"],
queryFn: fetchIngestionRuns,
});
const rawArtifacts = useQuery({
queryKey: ["raw-artifacts"],
queryFn: fetchRawArtifacts,
});
const createMutation = useMutation({
mutationFn: createWatchlistItem,
onSuccess: async () => {
@@ -112,6 +120,15 @@ export function MarketDashboard() {
await queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] });
},
});
const createRawArtifactMutation = useMutation({
mutationFn: createRawArtifact,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["raw-artifacts"] }),
queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] }),
]);
},
});
const finishRunMutation = useMutation({
mutationFn: ({ id, rowCount }: { id: number; rowCount?: number }) =>
finishIngestionRun(id, { status: "succeeded", row_count: rowCount }),
@@ -127,13 +144,15 @@ export function MarketDashboard() {
watchlist.isLoading ||
dataSources.isLoading ||
ingestionRuns.isLoading;
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
const hasError =
overview.isError ||
scores.isError ||
neighborhoodScores.isError ||
watchlist.isError ||
dataSources.isError ||
ingestionRuns.isError;
ingestionRuns.isError ||
rawArtifacts.isError;
return (
<main className="min-h-screen px-4 py-5 sm:px-6 lg:px-8">
@@ -156,6 +175,7 @@ export function MarketDashboard() {
void watchlist.refetch();
void dataSources.refetch();
void ingestionRuns.refetch();
void rawArtifacts.refetch();
}}
disabled={isLoading}
aria-label="刷新"
@@ -276,12 +296,16 @@ export function MarketDashboard() {
<ImportCenterPanel
sources={dataSources.data ?? []}
runs={ingestionRuns.data ?? []}
artifacts={rawArtifacts.data ?? []}
loading={isLoading}
importLoading={importLoading}
creatingSource={createDataSourceMutation.isPending}
creatingRun={createRunMutation.isPending}
creatingArtifact={createRawArtifactMutation.isPending}
finishingRunId={finishRunMutation.variables?.id ?? null}
onCreateSource={(payload) => createDataSourceMutation.mutate(payload)}
onCreateRun={(payload) => createRunMutation.mutate(payload)}
onCreateArtifact={(payload) => createRawArtifactMutation.mutate(payload)}
onFinishRun={(id, rowCount) => finishRunMutation.mutate({ id, rowCount })}
/>
</div>
@@ -292,22 +316,30 @@ export function MarketDashboard() {
function ImportCenterPanel({
sources,
runs,
artifacts,
loading,
importLoading,
creatingSource,
creatingRun,
creatingArtifact,
finishingRunId,
onCreateSource,
onCreateRun,
onCreateArtifact,
onFinishRun,
}: {
sources: DataSource[];
runs: IngestionRun[];
artifacts: RawArtifact[];
loading: boolean;
importLoading: boolean;
creatingSource: boolean;
creatingRun: boolean;
creatingArtifact: boolean;
finishingRunId: number | null;
onCreateSource: (payload: CreateDataSource) => void;
onCreateRun: (payload: CreateIngestionRun) => void;
onCreateArtifact: (payload: CreateRawArtifact) => void;
onFinishRun: (id: number, rowCount?: number) => void;
}) {
const [sourceName, setSourceName] = useState("");
@@ -316,6 +348,12 @@ function ImportCenterPanel({
const [sourceId, setSourceId] = useState("");
const [rawUri, setRawUri] = useState("");
const [rowCount, setRowCount] = useState("");
const [artifactRunId, setArtifactRunId] = useState("");
const [artifactSourceId, setArtifactSourceId] = useState("");
const [artifactFilename, setArtifactFilename] = useState("");
const [artifactRawUri, setArtifactRawUri] = useState("");
const [artifactSha256, setArtifactSha256] = useState("");
const [artifactSizeBytes, setArtifactSizeBytes] = useState("");
function submitSource(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -348,6 +386,36 @@ function ImportCenterPanel({
setRawUri("");
}
function submitArtifact(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const parsedRunId = Number(artifactRunId);
const parsedSourceId = Number(artifactSourceId);
const parsedSizeBytes = Number(artifactSizeBytes);
if (
!artifactFilename.trim() ||
!artifactRawUri.trim() ||
artifactSha256.trim().length !== 64 ||
!Number.isFinite(parsedSizeBytes) ||
parsedSizeBytes < 0
) {
return;
}
onCreateArtifact({
run_id: Number.isFinite(parsedRunId) && parsedRunId > 0 ? parsedRunId : undefined,
source_id:
Number.isFinite(parsedSourceId) && parsedSourceId > 0 ? parsedSourceId : undefined,
original_filename: artifactFilename.trim(),
raw_uri: artifactRawUri.trim(),
sha256: artifactSha256.trim(),
size_bytes: parsedSizeBytes,
uploaded_by: "dashboard",
});
setArtifactFilename("");
setArtifactRawUri("");
setArtifactSha256("");
setArtifactSizeBytes("");
}
return (
<Card>
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
@@ -415,7 +483,7 @@ function ImportCenterPanel({
<DataSourceTable sources={sources} loading={loading} />
<IngestionRunTable
runs={runs}
loading={loading}
loading={importLoading}
rowCount={rowCount}
onRowCountChange={setRowCount}
finishingRunId={finishingRunId}
@@ -425,6 +493,73 @@ function ImportCenterPanel({
setRowCount("");
}}
/>
<div className="xl:col-span-2">
<form className="mb-3 grid gap-2 lg:grid-cols-[120px_120px_1fr_1fr_1.5fr_120px_44px]" onSubmit={submitArtifact}>
<select
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
value={artifactRunId}
onChange={(event) => setArtifactRunId(event.target.value)}
aria-label="原始文件批次"
>
<option value=""></option>
{runs.map((run) => (
<option key={run.run_id} value={run.run_id}>
#{run.run_id}
</option>
))}
</select>
<select
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
value={artifactSourceId}
onChange={(event) => setArtifactSourceId(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 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
placeholder="文件名"
value={artifactFilename}
onChange={(event) => setArtifactFilename(event.target.value)}
/>
<input
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
placeholder="raw uri"
value={artifactRawUri}
onChange={(event) => setArtifactRawUri(event.target.value)}
/>
<input
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
placeholder="sha256"
value={artifactSha256}
onChange={(event) => setArtifactSha256(event.target.value)}
/>
<input
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
inputMode="numeric"
placeholder="字节"
value={artifactSizeBytes}
onChange={(event) => setArtifactSizeBytes(event.target.value)}
/>
<Button
disabled={
creatingArtifact ||
!artifactFilename.trim() ||
artifactSha256.trim().length !== 64
}
aria-label="登记原始文件"
title="登记原始文件"
>
<Plus className="h-4 w-4" />
</Button>
</form>
<RawArtifactTable artifacts={artifacts} loading={importLoading} />
</div>
</CardContent>
</Card>
);
@@ -539,6 +674,47 @@ function IngestionRunTable({
);
}
function RawArtifactTable({
artifacts,
loading,
}: {
artifacts: RawArtifact[];
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>
<TableHead>SHA-256</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{artifacts.map((artifact) => (
<TableRow key={artifact.artifact_id}>
<TableCell className="font-medium">{artifact.original_filename}</TableCell>
<TableCell>{artifact.source_name ?? "-"}</TableCell>
<TableCell>{artifact.run_id ? `#${artifact.run_id}` : "-"}</TableCell>
<TableCell className="max-w-80 truncate font-mono text-xs text-[var(--muted-foreground)]">
{artifact.sha256}
</TableCell>
<TableCell className="text-right">{formatNumber(artifact.size_bytes)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function NeighborhoodRankingPanel({
scores,
neighborhoodScores,

View File

@@ -109,6 +109,7 @@ export type IngestionRun = {
run_id: number;
source_id: number | null;
source_name: string | null;
raw_artifact_id: number | null;
status: string;
started_at: string;
finished_at: string | null;
@@ -121,6 +122,7 @@ export type CreateIngestionRun = {
source_id?: number;
status?: string;
raw_uri?: string;
raw_artifact_id?: number;
row_count?: number;
error_message?: string;
};
@@ -131,6 +133,33 @@ export type FinishIngestionRun = {
error_message?: string;
};
export type RawArtifact = {
artifact_id: number;
run_id: number | null;
source_id: number | null;
source_name: string | null;
original_filename: string;
raw_uri: string;
sha256: string;
size_bytes: number;
mime_type: string | null;
uploaded_by: string;
notes: string;
created_at: string;
};
export type CreateRawArtifact = {
run_id?: number;
source_id?: number;
original_filename: string;
raw_uri: string;
sha256: string;
size_bytes: number;
mime_type?: string;
uploaded_by?: string;
notes?: string;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080";
@@ -197,6 +226,17 @@ export async function finishIngestionRun(
});
}
export async function fetchRawArtifacts(): Promise<RawArtifact[]> {
return fetchJson("/api/v1/raw-artifacts");
}
export async function createRawArtifact(payload: CreateRawArtifact): Promise<RawArtifact> {
return fetchJson("/api/v1/raw-artifacts", {
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,