Files
huheng-research/apps/web/src/components/dashboard/market-dashboard.tsx

2957 lines
102 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
Archive,
Check,
MapPinned,
Plus,
RefreshCw,
TrendingUp,
} from "lucide-react";
import { Fragment, useEffect, useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
archiveWatchlistItem,
createDataSource,
createIngestionRun,
createRawArtifact,
createAreaScoreModelRun,
createWatchlistEvent,
createWatchlistItem,
fetchDataSources,
fetchAreaScores,
fetchAreaComparison,
fetchAreaDetail,
fetchAreaScoreRunDiff,
fetchIngestionRuns,
fetchMarketOverview,
fetchModelRuns,
fetchNeighborhoodComparison,
fetchNeighborhoodDetail,
fetchNeighborhoodScores,
fetchRawArtifacts,
fetchWatchlistEvents,
fetchWatchlistItems,
finishIngestionRun,
updateWatchlistItem,
} from "@/lib/api";
import type {
AreaScore,
AreaComparison,
AreaDetail,
AreaScoreRunDiff,
ComparisonMetric,
CreateAreaScoreModelRun,
CreateDataSource,
CreateIngestionRun,
CreateRawArtifact,
CreateWatchlistEvent,
CreateWatchlistItem,
DataSource,
IngestionRun,
ModelRun,
NeighborhoodComparison,
NeighborhoodComparisonItem,
NeighborhoodDetail,
NeighborhoodScore,
RawArtifact,
SimilarNeighborhood,
UpdateWatchlistItem,
WatchlistEvent,
WatchlistItem,
} from "@/lib/api";
import { formatNumber, formatPrice } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const MONTH = "2026-05";
export function MarketDashboard() {
const queryClient = useQueryClient();
const [selectedAreaId, setSelectedAreaId] = useState("");
const [detailAreaId, setDetailAreaId] = useState("qiantan");
const [detailNeighborhoodId, setDetailNeighborhoodId] = useState("");
const [comparisonAreaIds, setComparisonAreaIds] = useState<string[]>([]);
const [comparisonNeighborhoodIds, setComparisonNeighborhoodIds] = useState<string[]>([]);
const [watchlistStatus, setWatchlistStatus] = useState("active");
const [watchlistAreaId, setWatchlistAreaId] = useState("");
const [watchlistLabel, setWatchlistLabel] = useState("");
const [expandedWatchlistItemId, setExpandedWatchlistItemId] = useState<number | null>(null);
const [baseModelRunId, setBaseModelRunId] = useState<number | null>(null);
const [candidateModelRunId, setCandidateModelRunId] = useState<number | null>(null);
const overview = useQuery({
queryKey: ["market-overview", MONTH],
queryFn: () => fetchMarketOverview(MONTH),
});
const scores = useQuery({
queryKey: ["area-scores", MONTH],
queryFn: () => fetchAreaScores(MONTH),
});
const areaDetail = useQuery({
queryKey: ["area-detail", detailAreaId, MONTH],
queryFn: () => fetchAreaDetail(detailAreaId, MONTH),
enabled: Boolean(detailAreaId),
});
const neighborhoodScores = useQuery({
queryKey: ["neighborhood-scores", MONTH],
queryFn: () => fetchNeighborhoodScores(MONTH),
});
const neighborhoodDetail = useQuery({
queryKey: ["neighborhood-detail", detailNeighborhoodId, MONTH],
queryFn: () => fetchNeighborhoodDetail(detailNeighborhoodId, MONTH),
enabled: Boolean(detailNeighborhoodId),
});
const areaComparison = useQuery({
queryKey: ["area-comparison", comparisonAreaIds, MONTH],
queryFn: () => fetchAreaComparison(comparisonAreaIds, MONTH),
enabled: comparisonAreaIds.length >= 2,
});
const neighborhoodComparison = useQuery({
queryKey: ["neighborhood-comparison", comparisonNeighborhoodIds, MONTH],
queryFn: () => fetchNeighborhoodComparison(comparisonNeighborhoodIds, MONTH),
enabled: comparisonNeighborhoodIds.length >= 2,
});
const watchlist = useQuery({
queryKey: ["watchlist", watchlistStatus, watchlistAreaId, watchlistLabel],
queryFn: () =>
fetchWatchlistItems({
status: watchlistStatus,
area_id: watchlistAreaId,
label: watchlistLabel,
}),
});
const watchlistEvents = useQuery({
queryKey: ["watchlist-events", expandedWatchlistItemId],
queryFn: () => fetchWatchlistEvents(expandedWatchlistItemId ?? 0),
enabled: expandedWatchlistItemId !== null,
});
const modelRuns = useQuery({
queryKey: ["model-runs", "area_scores"],
queryFn: () => fetchModelRuns({ model_name: "area_scores" }),
});
const areaScoreRunDiff = useQuery({
queryKey: ["area-score-run-diff", baseModelRunId, candidateModelRunId],
queryFn: () => fetchAreaScoreRunDiff(baseModelRunId ?? 0, candidateModelRunId ?? 0),
enabled:
baseModelRunId !== null &&
candidateModelRunId !== null &&
baseModelRunId !== candidateModelRunId,
});
const dataSources = useQuery({
queryKey: ["data-sources"],
queryFn: fetchDataSources,
});
const ingestionRuns = useQuery({
queryKey: ["ingestion-runs"],
queryFn: fetchIngestionRuns,
});
const rawArtifacts = useQuery({
queryKey: ["raw-artifacts"],
queryFn: fetchRawArtifacts,
});
const createMutation = useMutation({
mutationFn: createWatchlistItem,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["watchlist"] }),
queryClient.invalidateQueries({ queryKey: ["neighborhood-detail"] }),
]);
},
});
const archiveMutation = useMutation({
mutationFn: archiveWatchlistItem,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["watchlist"] });
},
});
const updateWatchlistMutation = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: UpdateWatchlistItem }) =>
updateWatchlistItem(id, payload),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["watchlist"] }),
queryClient.invalidateQueries({ queryKey: ["watchlist-events"] }),
queryClient.invalidateQueries({ queryKey: ["neighborhood-detail"] }),
]);
},
});
const createWatchlistEventMutation = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: CreateWatchlistEvent }) =>
createWatchlistEvent(id, payload),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["watchlist-events"] });
},
});
const createAreaScoreModelRunMutation = useMutation({
mutationFn: createAreaScoreModelRun,
onSuccess: async (response) => {
setCandidateModelRunId(response.model_run.model_run_id);
if (baseModelRunId === null && modelRuns.data?.[0]) {
setBaseModelRunId(modelRuns.data[0].model_run_id);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["model-runs"] }),
queryClient.invalidateQueries({ queryKey: ["area-scores"] }),
queryClient.invalidateQueries({ queryKey: ["market-overview"] }),
queryClient.invalidateQueries({ queryKey: ["area-detail"] }),
queryClient.invalidateQueries({ queryKey: ["area-score-run-diff"] }),
]);
},
});
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 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 }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] });
},
});
useEffect(() => {
if (!detailNeighborhoodId && neighborhoodScores.data?.[0]) {
setDetailNeighborhoodId(neighborhoodScores.data[0].neighborhood_id);
}
}, [detailNeighborhoodId, neighborhoodScores.data]);
useEffect(() => {
if (comparisonAreaIds.length === 0 && scores.data && scores.data.length >= 2) {
setComparisonAreaIds(scores.data.slice(0, 3).map((score) => score.area_id));
}
}, [comparisonAreaIds.length, scores.data]);
useEffect(() => {
if (
comparisonNeighborhoodIds.length === 0 &&
neighborhoodScores.data &&
neighborhoodScores.data.length >= 2
) {
setComparisonNeighborhoodIds(
neighborhoodScores.data.slice(0, 3).map((score) => score.neighborhood_id),
);
}
}, [comparisonNeighborhoodIds.length, neighborhoodScores.data]);
useEffect(() => {
if (modelRuns.data && modelRuns.data.length >= 2) {
if (candidateModelRunId === null) {
setCandidateModelRunId(modelRuns.data[0].model_run_id);
}
if (baseModelRunId === null) {
setBaseModelRunId(modelRuns.data[1].model_run_id);
}
}
}, [baseModelRunId, candidateModelRunId, modelRuns.data]);
const isLoading =
overview.isLoading ||
scores.isLoading ||
areaDetail.isLoading ||
neighborhoodDetail.isLoading ||
areaComparison.isLoading ||
neighborhoodComparison.isLoading ||
neighborhoodScores.isLoading ||
watchlist.isLoading ||
watchlistEvents.isLoading ||
modelRuns.isLoading ||
areaScoreRunDiff.isLoading ||
dataSources.isLoading ||
ingestionRuns.isLoading;
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
const hasError =
overview.isError ||
scores.isError ||
areaDetail.isError ||
neighborhoodDetail.isError ||
areaComparison.isError ||
neighborhoodComparison.isError ||
neighborhoodScores.isError ||
watchlist.isError ||
watchlistEvents.isError ||
modelRuns.isError ||
areaScoreRunDiff.isError ||
dataSources.isError ||
ingestionRuns.isError ||
rawArtifacts.isError;
return (
<main className="min-h-screen px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<header className="flex flex-col gap-3 border-b border-[var(--border)] pb-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-medium text-[var(--muted-foreground)]"></p>
<h1 className="mt-1 text-2xl font-semibold tracking-normal text-slate-950">
</h1>
</div>
<div className="flex items-center gap-2">
<Badge variant="muted">{MONTH}</Badge>
<Button
variant="outline"
onClick={() => {
void overview.refetch();
void scores.refetch();
void areaDetail.refetch();
void neighborhoodDetail.refetch();
void areaComparison.refetch();
void neighborhoodComparison.refetch();
void neighborhoodScores.refetch();
void watchlist.refetch();
void dataSources.refetch();
void ingestionRuns.refetch();
void rawArtifacts.refetch();
void modelRuns.refetch();
void areaScoreRunDiff.refetch();
}}
disabled={isLoading}
aria-label="刷新"
title="刷新"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</header>
{hasError ? <ErrorState /> : null}
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard
title="样本板块"
value={overview.data?.area_count ?? 0}
suffix="个"
icon={<MapPinned className="h-4 w-4" />}
loading={isLoading}
/>
<MetricCard
title="平均综合分"
value={overview.data?.average_investment_score ?? 0}
icon={<Activity className="h-4 w-4" />}
loading={isLoading}
/>
<MetricCard
title="平均租金收益率"
value={overview.data?.average_rent_yield_pct ?? 0}
suffix="%"
icon={<TrendingUp className="h-4 w-4" />}
loading={isLoading}
/>
<MetricCard
title="供应压力最高"
textValue={overview.data?.highest_supply_risk_area?.name ?? "-"}
icon={<AlertTriangle className="h-4 w-4" />}
loading={isLoading}
tone="warning"
/>
</section>
<section className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle></CardTitle>
{overview.data?.top_area ? (
<Badge variant="success">{overview.data.top_area.name}</Badge>
) : null}
</CardHeader>
<CardContent className="h-[340px]">
<ScoreChart scores={scores.data ?? []} loading={isLoading} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<InsightRow
label="最高评分"
value={overview.data?.top_area?.name ?? "-"}
detail={
overview.data?.top_area
? `${formatNumber(overview.data.top_area.investment_score)}`
: "-"
}
/>
<InsightRow
label="最低评分"
value={overview.data?.weakest_area?.name ?? "-"}
detail={
overview.data?.weakest_area
? `${formatNumber(overview.data.weakest_area.investment_score)}`
: "-"
}
/>
<InsightRow
label="供应压力"
value={overview.data?.highest_supply_risk_area?.name ?? "-"}
detail={overview.data?.highest_supply_risk_area?.recommendation ?? "-"}
/>
</CardContent>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto p-0">
<AreaScoreTable
scores={scores.data ?? []}
loading={isLoading}
selectedAreaId={detailAreaId}
onSelectArea={setDetailAreaId}
/>
</CardContent>
</Card>
<AreaDetailPanel
detail={areaDetail.data ?? null}
loading={areaDetail.isLoading}
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
creating={createMutation.isPending}
onSelectNeighborhood={setDetailNeighborhoodId}
/>
<NeighborhoodRankingPanel
scores={scores.data ?? []}
neighborhoodScores={neighborhoodScores.data ?? []}
selectedAreaId={selectedAreaId}
selectedNeighborhoodId={detailNeighborhoodId}
loading={isLoading}
creating={createMutation.isPending}
onSelectArea={setSelectedAreaId}
onSelectNeighborhood={setDetailNeighborhoodId}
onCreate={(payload) => createMutation.mutate(payload)}
/>
<NeighborhoodDetailPanel
detail={neighborhoodDetail.data ?? null}
loading={neighborhoodDetail.isLoading}
creating={createMutation.isPending}
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
onSelectNeighborhood={setDetailNeighborhoodId}
/>
<ComparisonWorkspace
areaScores={scores.data ?? []}
neighborhoodScores={neighborhoodScores.data ?? []}
areaComparison={areaComparison.data ?? null}
neighborhoodComparison={neighborhoodComparison.data ?? null}
selectedAreaIds={comparisonAreaIds}
selectedNeighborhoodIds={comparisonNeighborhoodIds}
loading={areaComparison.isLoading || neighborhoodComparison.isLoading}
onAreaIdsChange={setComparisonAreaIds}
onNeighborhoodIdsChange={setComparisonNeighborhoodIds}
/>
<WatchlistPanel
scores={scores.data ?? []}
neighborhoodScores={neighborhoodScores.data ?? []}
items={watchlist.data ?? []}
events={watchlistEvents.data ?? []}
loading={isLoading}
creating={createMutation.isPending}
archivingId={archiveMutation.variables ?? null}
updatingId={updateWatchlistMutation.variables?.id ?? null}
creatingEvent={createWatchlistEventMutation.isPending}
statusFilter={watchlistStatus}
areaFilter={watchlistAreaId}
labelFilter={watchlistLabel}
expandedItemId={expandedWatchlistItemId}
onStatusFilterChange={setWatchlistStatus}
onAreaFilterChange={setWatchlistAreaId}
onLabelFilterChange={setWatchlistLabel}
onToggleEvents={(id) =>
setExpandedWatchlistItemId((current) => (current === id ? null : id))
}
onCreate={(payload) => createMutation.mutate(payload)}
onUpdate={(id, payload) => updateWatchlistMutation.mutate({ id, payload })}
onCreateEvent={(id, payload) => createWatchlistEventMutation.mutate({ id, payload })}
onArchive={(id) => archiveMutation.mutate(id)}
/>
<ModelRunPanel
runs={modelRuns.data ?? []}
diff={areaScoreRunDiff.data ?? null}
baseRunId={baseModelRunId}
candidateRunId={candidateModelRunId}
loading={modelRuns.isLoading || areaScoreRunDiff.isLoading}
creating={createAreaScoreModelRunMutation.isPending}
onBaseRunChange={setBaseModelRunId}
onCandidateRunChange={setCandidateModelRunId}
onCreateRun={(payload) => createAreaScoreModelRunMutation.mutate(payload)}
/>
<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>
</main>
);
}
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("");
const [sourceType, setSourceType] = useState("manual");
const [sourceUrl, setSourceUrl] = useState("");
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();
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("");
}
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">
<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={importLoading}
rowCount={rowCount}
onRowCountChange={setRowCount}
finishingRunId={finishingRunId}
onFinishRun={(id) => {
const parsedRowCount = Number(rowCount);
onFinishRun(id, Number.isFinite(parsedRowCount) && parsedRowCount >= 0 ? parsedRowCount : undefined);
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>
);
}
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 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 AreaDetailPanel({
detail,
loading,
creating,
onCreateWatchlist,
onSelectNeighborhood,
}: {
detail: AreaDetail | null;
loading: boolean;
creating: boolean;
onCreateWatchlist: (payload: CreateWatchlistItem) => void;
onSelectNeighborhood: (neighborhoodId: string) => void;
}) {
if (loading) {
return <div className="h-80 rounded-md bg-[var(--muted)]" />;
}
if (!detail) {
return null;
}
const score = detail.current_score;
const lineage = detail.score_lineage;
const latestMetric = detail.monthly_metrics[0];
return (
<Card>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<CardTitle>{detail.profile.name}</CardTitle>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
{detail.profile.district} · {detail.profile.segment}
</p>
</div>
<div className="flex items-center gap-2">
{score ? <Badge variant="success">{score.recommendation}</Badge> : null}
<Button
variant="outline"
disabled={creating}
onClick={() =>
onCreateWatchlist({
area_id: detail.profile.area_id,
target_price_psm: score
? Math.round(score.transaction_price_psm * 0.96)
: undefined,
notes: `${MONTH} 板块详情:${score?.recommendation ?? "待评分"}`,
})
}
aria-label="加入观察池"
title="加入观察池"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<InsightRow
label="综合分"
value={score ? formatNumber(score.investment_score) : "-"}
detail={lineage?.model_version ?? "gold"}
/>
<InsightRow
label="成交均价/㎡"
value={latestMetric ? formatPrice(latestMetric.transaction_price_psm) : "-"}
detail={latestMetric?.month ?? MONTH}
/>
<InsightRow
label="挂牌压力"
value={score ? formatNumber(score.listing_pressure_ratio, 2) : "-"}
detail={latestMetric ? `${latestMetric.listing_count}` : "-"}
/>
<InsightRow
label="数据追溯"
value={lineage?.source_name ?? "-"}
detail={lineage?.raw_artifact_id ? `#${lineage.raw_artifact_id}` : "-"}
/>
</div>
<div className="grid gap-4">
<div className="h-72 rounded-md border border-[var(--border)] p-3">
<AreaMetricTrendChart metrics={detail.monthly_metrics} />
</div>
<AreaDiagnosticsPanel diagnostics={detail.diagnostics} />
<div className="grid gap-4 lg:grid-cols-2">
<AreaMetricHistoryTable metrics={detail.monthly_metrics} />
<AreaNeighborhoodTable
scores={detail.neighborhood_scores}
creating={creating}
onCreate={onCreateWatchlist}
onSelectNeighborhood={onSelectNeighborhood}
/>
</div>
{lineage ? (
<div className="rounded-md border border-[var(--border)] px-3 py-2 text-xs text-[var(--muted-foreground)]">
SHA-256<span className="font-mono">{lineage.sha256 ?? "-"}</span>
</div>
) : null}
</div>
</CardContent>
</Card>
);
}
function AreaDiagnosticsPanel({ diagnostics }: { diagnostics: AreaDetail["diagnostics"] }) {
if (!diagnostics) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="rounded-md border border-[var(--border)] p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-slate-950"></p>
<Badge variant={diagnostics.anomaly_count > 0 ? "warning" : "success"}>
{diagnostics.anomaly_count}
</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{diagnostics.metrics.map((metric) => (
<div key={metric.key} className="rounded-md border border-[var(--border)] bg-white p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-slate-950">{metric.label}</span>
<Badge variant={metric.anomaly ? "warning" : "muted"}>{metric.severity}</Badge>
</div>
<p className="mt-2 text-lg font-semibold text-slate-950">
{formatMetricValue(metric.current_value, metric.unit)}
</p>
<div className="mt-3 h-2 rounded-full bg-[var(--muted)]">
<div
className={
metric.anomaly
? "h-2 rounded-full bg-[var(--warning)]"
: "h-2 rounded-full bg-[var(--primary)]"
}
style={{ width: `${Math.max(0, Math.min(100, metric.percentile))}%` }}
/>
</div>
<div className="mt-2 flex items-center justify-between text-xs text-[var(--muted-foreground)]">
<span>P{formatNumber(metric.percentile, 1)}</span>
<span>
{formatMetricValue(metric.historical_min, metric.unit)}-
{formatMetricValue(metric.historical_max, metric.unit)}
</span>
</div>
</div>
))}
</div>
</div>
);
}
function AreaMetricTrendChart({ metrics }: { metrics: AreaDetail["monthly_metrics"] }) {
const data = [...metrics].reverse();
if (data.length === 0) {
return (
<div className="flex h-full items-center text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
<CartesianGrid stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
<YAxis yAxisId="price" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
<YAxis
yAxisId="volume"
orientation="right"
tickLine={false}
axisLine={false}
tick={{ fontSize: 12 }}
/>
<Tooltip
formatter={(value, name) => {
if (name === "transaction_price_psm") {
return [formatPrice(Number(value)), "成交均价/㎡"];
}
return [formatNumber(Number(value)), "成交套数"];
}}
/>
<Line
yAxisId="price"
type="monotone"
dataKey="transaction_price_psm"
stroke="#0f766e"
strokeWidth={2}
dot={false}
/>
<Line
yAxisId="volume"
type="monotone"
dataKey="transaction_count"
stroke="#b45309"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
}
function AreaMetricHistoryTable({ metrics }: { metrics: AreaDetail["monthly_metrics"] }) {
if (metrics.length === 0) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{metrics.slice(0, 6).map((metric) => (
<TableRow key={`${metric.area_id}-${metric.month}`}>
<TableCell className="font-medium">{metric.month}</TableCell>
<TableCell className="text-right">{metric.transaction_count}</TableCell>
<TableCell className="text-right">{formatPrice(metric.transaction_price_psm)}</TableCell>
<TableCell className="text-right">{metric.listing_count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function AreaNeighborhoodTable({
scores,
creating,
onCreate,
onSelectNeighborhood,
}: {
scores: NeighborhoodScore[];
creating: boolean;
onCreate: (payload: CreateWatchlistItem) => void;
onSelectNeighborhood: (neighborhoodId: string) => void;
}) {
if (scores.length === 0) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead className="w-14" />
</TableRow>
</TableHeader>
<TableBody>
{scores.slice(0, 6).map((score) => (
<TableRow
key={score.neighborhood_id}
className="cursor-pointer hover:bg-slate-50"
onClick={() => onSelectNeighborhood(score.neighborhood_id)}
>
<TableCell className="font-medium">{score.name}</TableCell>
<TableCell className="text-right">{formatNumber(score.investment_score)}</TableCell>
<TableCell className="text-right">{formatPrice(score.transaction_price_psm)}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
disabled={creating}
onClick={(event) => {
event.stopPropagation();
onCreate({
neighborhood_id: score.neighborhood_id,
target_price_psm: Math.round(score.transaction_price_psm * 0.96),
notes: `${score.month} 板块详情:${score.recommendation}`,
});
}}
aria-label="加入观察池"
title="加入观察池"
>
<Plus className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function NeighborhoodRankingPanel({
scores,
neighborhoodScores,
selectedAreaId,
selectedNeighborhoodId,
loading,
creating,
onSelectArea,
onSelectNeighborhood,
onCreate,
}: {
scores: AreaScore[];
neighborhoodScores: NeighborhoodScore[];
selectedAreaId: string;
selectedNeighborhoodId: string;
loading: boolean;
creating: boolean;
onSelectArea: (areaId: string) => void;
onSelectNeighborhood: (neighborhoodId: string) => void;
onCreate: (payload: CreateWatchlistItem) => void;
}) {
const filteredScores = selectedAreaId
? neighborhoodScores.filter((score) => score.area_id === selectedAreaId)
: neighborhoodScores;
const topNeighborhood = filteredScores[0];
return (
<Card>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-2">
<CardTitle></CardTitle>
{topNeighborhood ? <Badge variant="success">{topNeighborhood.name}</Badge> : null}
</div>
<select
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm lg:w-44"
value={selectedAreaId}
onChange={(event) => onSelectArea(event.target.value)}
aria-label="小区板块筛选"
>
<option value=""></option>
{scores.map((score) => (
<option key={score.area_id} value={score.area_id}>
{score.name}
</option>
))}
</select>
</CardHeader>
<CardContent className="overflow-x-auto p-0">
<NeighborhoodScoreTable
scores={filteredScores}
loading={loading}
creating={creating}
selectedNeighborhoodId={selectedNeighborhoodId}
onSelectNeighborhood={onSelectNeighborhood}
onCreate={onCreate}
/>
</CardContent>
</Card>
);
}
function NeighborhoodScoreTable({
scores,
loading,
creating,
selectedNeighborhoodId,
onSelectNeighborhood,
onCreate,
}: {
scores: NeighborhoodScore[];
loading: boolean;
creating: boolean;
selectedNeighborhoodId: string;
onSelectNeighborhood: (neighborhoodId: string) => void;
onCreate: (payload: CreateWatchlistItem) => void;
}) {
if (loading) {
return <div className="h-56 bg-[var(--muted)]" />;
}
if (scores.length === 0) {
return <div className="px-4 py-8 text-sm text-[var(--muted-foreground)]"></div>;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="w-14" />
</TableRow>
</TableHeader>
<TableBody>
{scores.map((score) => (
<TableRow
key={score.neighborhood_id}
className={
selectedNeighborhoodId === score.neighborhood_id
? "cursor-pointer bg-teal-50/60"
: "cursor-pointer hover:bg-slate-50"
}
onClick={() => onSelectNeighborhood(score.neighborhood_id)}
>
<TableCell className="font-medium">{score.name}</TableCell>
<TableCell>{score.area_name}</TableCell>
<TableCell className="text-right font-semibold">
{formatNumber(score.investment_score)}
</TableCell>
<TableCell>
<Badge variant={score.recommendation === "重点研究" ? "success" : "muted"}>
{score.recommendation}
</Badge>
</TableCell>
<TableCell className="text-right">{formatPrice(score.transaction_price_psm)}</TableCell>
<TableCell className="text-right">
{formatNumber(score.annual_rent_yield_pct, 2)}%
</TableCell>
<TableCell className="text-right">{formatNumber(score.liquidity_score)}</TableCell>
<TableCell className="text-right">{formatNumber(score.location_score)}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
disabled={creating}
onClick={(event) => {
event.stopPropagation();
onCreate({
neighborhood_id: score.neighborhood_id,
target_price_psm: Math.round(score.transaction_price_psm * 0.96),
notes: `${score.month} 小区排行:${score.recommendation}`,
});
}}
aria-label="加入观察池"
title="加入观察池"
>
<Plus className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function NeighborhoodDetailPanel({
detail,
loading,
creating,
onCreateWatchlist,
onSelectNeighborhood,
}: {
detail: NeighborhoodDetail | null;
loading: boolean;
creating: boolean;
onCreateWatchlist: (payload: CreateWatchlistItem) => void;
onSelectNeighborhood: (neighborhoodId: string) => void;
}) {
if (loading) {
return <div className="h-80 rounded-md bg-[var(--muted)]" />;
}
if (!detail) {
return null;
}
const score = detail.current_score;
const latestMetric = detail.monthly_metrics[0];
const referencePrice = score?.transaction_price_psm ?? latestMetric?.transaction_price_psm ?? 0;
const lowerWatchPrice = referencePrice > 0 ? Math.round(referencePrice * 0.92) : null;
const upperWatchPrice = referencePrice > 0 ? Math.round(referencePrice * 0.96) : null;
const activeWatchlistItem = detail.watchlist_items[0];
return (
<Card>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<CardTitle>{detail.profile.name}</CardTitle>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
{detail.profile.district} · {detail.profile.area_name} · {detail.profile.property_type}
</p>
</div>
<div className="flex items-center gap-2">
{score ? <Badge variant="success">{score.recommendation}</Badge> : null}
{activeWatchlistItem ? <Badge variant="muted">{activeWatchlistItem.status}</Badge> : null}
<Button
variant="outline"
disabled={creating}
onClick={() =>
onCreateWatchlist({
neighborhood_id: detail.profile.neighborhood_id,
target_price_psm: upperWatchPrice ?? undefined,
notes: `${MONTH} 小区详情:${score?.recommendation ?? "待评分"}`,
})
}
aria-label="加入观察池"
title="加入观察池"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<InsightRow
label="综合分"
value={score ? formatNumber(score.investment_score) : "-"}
detail={score?.month ?? MONTH}
/>
<InsightRow
label="观察价区间/㎡"
value={
lowerWatchPrice && upperWatchPrice
? `${formatPrice(lowerWatchPrice)} - ${formatPrice(upperWatchPrice)}`
: "-"
}
detail="当前价 92%-96%"
/>
<InsightRow
label="建成年份"
value={detail.profile.built_year ? `${detail.profile.built_year}` : "-"}
detail={score ? `${formatNumber(score.building_age_score)}` : "-"}
/>
<InsightRow
label="地铁距离"
value={
detail.profile.metro_distance_m ? `${detail.profile.metro_distance_m}` : "-"
}
detail={detail.profile.school_quality ?? "-"}
/>
</div>
<div className="grid gap-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="h-72 rounded-md border border-[var(--border)] p-3">
<NeighborhoodMetricTrendChart metrics={detail.monthly_metrics} />
</div>
<NeighborhoodScoreBreakdown score={score} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<NeighborhoodMetricHistoryTable metrics={detail.monthly_metrics} />
<NeighborhoodProfileTable detail={detail} />
</div>
<SimilarNeighborhoodTable
items={detail.similar_neighborhoods}
onSelectNeighborhood={onSelectNeighborhood}
/>
</div>
</CardContent>
</Card>
);
}
function NeighborhoodMetricTrendChart({
metrics,
}: {
metrics: NeighborhoodDetail["monthly_metrics"];
}) {
const data = [...metrics].reverse();
if (data.length === 0) {
return (
<div className="flex h-full items-center text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
<CartesianGrid stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
<YAxis yAxisId="price" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
<YAxis
yAxisId="volume"
orientation="right"
tickLine={false}
axisLine={false}
tick={{ fontSize: 12 }}
/>
<Tooltip
formatter={(value, name) => {
if (name === "transaction_price_psm") {
return [formatPrice(Number(value)), "成交均价/㎡"];
}
if (name === "listing_price_psm") {
return [formatPrice(Number(value)), "挂牌均价/㎡"];
}
return [formatNumber(Number(value)), "成交套数"];
}}
/>
<Line
yAxisId="price"
type="monotone"
dataKey="transaction_price_psm"
stroke="#0f766e"
strokeWidth={2}
dot={false}
/>
<Line
yAxisId="price"
type="monotone"
dataKey="listing_price_psm"
stroke="#2563eb"
strokeWidth={2}
dot={false}
/>
<Line
yAxisId="volume"
type="monotone"
dataKey="transaction_count"
stroke="#b45309"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
}
function NeighborhoodScoreBreakdown({ score }: { score: NeighborhoodScore | null }) {
const items = score
? [
["流动性", score.liquidity_score],
["租金支撑", score.rent_support_score],
["价格安全", score.price_safety_score],
["位置", score.location_score],
["楼龄", score.building_age_score],
]
: [];
if (!score) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="rounded-md border border-[var(--border)] p-4">
<p className="text-sm font-semibold text-slate-950"></p>
<div className="mt-4 grid gap-3">
{items.map(([label, value]) => (
<div key={label} className="grid gap-1">
<div className="flex items-center justify-between text-sm">
<span className="text-[var(--muted-foreground)]">{label}</span>
<span className="font-medium text-slate-950">{formatNumber(Number(value))}</span>
</div>
<div className="h-2 rounded-full bg-[var(--muted)]">
<div
className="h-2 rounded-full bg-[var(--primary)]"
style={{ width: `${Math.max(0, Math.min(100, Number(value)))}%` }}
/>
</div>
</div>
))}
</div>
</div>
);
}
function NeighborhoodMetricHistoryTable({
metrics,
}: {
metrics: NeighborhoodDetail["monthly_metrics"];
}) {
if (metrics.length === 0) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{metrics.slice(0, 6).map((metric) => (
<TableRow key={`${metric.neighborhood_id}-${metric.month}`}>
<TableCell className="font-medium">{metric.month}</TableCell>
<TableCell className="text-right">{metric.transaction_count}</TableCell>
<TableCell className="text-right">{formatPrice(metric.transaction_price_psm)}</TableCell>
<TableCell className="text-right">{metric.available_units}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function NeighborhoodProfileTable({ detail }: { detail: NeighborhoodDetail }) {
const latestMetric = detail.monthly_metrics[0];
const activeWatchlistItem = detail.watchlist_items[0];
const rows = [
["挂牌均价/㎡", latestMetric ? formatPrice(latestMetric.listing_price_psm) : "-"],
["租金/㎡", latestMetric ? formatPrice(latestMetric.rent_price_psm) : "-"],
["去化天数", latestMetric ? formatNumber(latestMetric.median_days_on_market) : "-"],
["数据批次", latestMetric?.ingestion_run_id ? `#${latestMetric.ingestion_run_id}` : "-"],
["原始文件", latestMetric?.raw_artifact_id ? `#${latestMetric.raw_artifact_id}` : "-"],
[
"观察池目标/㎡",
activeWatchlistItem?.target_price_psm ? formatPrice(activeWatchlistItem.target_price_psm) : "-",
],
];
return (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableBody>
{rows.map(([label, value]) => (
<TableRow key={label}>
<TableCell className="text-[var(--muted-foreground)]">{label}</TableCell>
<TableCell className="text-right font-medium">{value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function SimilarNeighborhoodTable({
items,
onSelectNeighborhood,
}: {
items: SimilarNeighborhood[];
onSelectNeighborhood: (neighborhoodId: string) => void;
}) {
if (items.length === 0) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="w-12 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.neighborhood_id}>
<TableCell className="min-w-40">
<span className="block font-medium text-slate-950">{item.name}</span>
<span className="block text-xs text-[var(--muted-foreground)]">
{item.property_type} · {item.built_year ?? "-"}
</span>
</TableCell>
<TableCell className="min-w-32">
<span className="block">{item.area_name}</span>
<span className="block text-xs text-[var(--muted-foreground)]">
{item.district}
</span>
</TableCell>
<TableCell className="text-right font-medium">
{formatNumber(item.similarity_score)}
</TableCell>
<TableCell className="text-right">
<Badge variant={relativePriceVariant(item.relative_price_pct)}>
{formatRelativePrice(item.relative_price_pct)}
</Badge>
</TableCell>
<TableCell className="text-right">{formatPrice(item.transaction_price_psm)}</TableCell>
<TableCell className="text-right">
<span className="block font-medium">{formatNumber(item.investment_score)}</span>
<span className="block text-xs text-[var(--muted-foreground)]">
{item.recommendation}
</span>
</TableCell>
<TableCell className="min-w-64">
<div className="flex flex-wrap gap-1">
{item.reasons.slice(0, 5).map((reason) => (
<Badge key={reason} variant="muted" className="h-5">
{formatSimilarityReason(reason)}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onSelectNeighborhood(item.neighborhood_id)}
aria-label={`查看${item.name}`}
title="查看小区详情"
>
<MapPinned className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function ComparisonWorkspace({
areaScores,
neighborhoodScores,
areaComparison,
neighborhoodComparison,
selectedAreaIds,
selectedNeighborhoodIds,
loading,
onAreaIdsChange,
onNeighborhoodIdsChange,
}: {
areaScores: AreaScore[];
neighborhoodScores: NeighborhoodScore[];
areaComparison: AreaComparison | null;
neighborhoodComparison: NeighborhoodComparison | null;
selectedAreaIds: string[];
selectedNeighborhoodIds: string[];
loading: boolean;
onAreaIdsChange: (ids: string[]) => void;
onNeighborhoodIdsChange: (ids: string[]) => void;
}) {
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-[640px] xl:grid-cols-2">
<ComparisonSelector
label="板块"
options={areaScores.map((score) => ({
id: score.area_id,
label: score.name,
detail: score.district,
}))}
selectedIds={selectedAreaIds}
onChange={onAreaIdsChange}
/>
<ComparisonSelector
label="小区"
options={neighborhoodScores.map((score) => ({
id: score.neighborhood_id,
label: score.name,
detail: score.area_name,
}))}
selectedIds={selectedNeighborhoodIds}
onChange={onNeighborhoodIdsChange}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4">
{loading ? <div className="h-72 rounded-md bg-[var(--muted)]" /> : null}
{!loading ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<ComparisonMatrix
title="板块矩阵"
comparison={areaComparison}
getId={(item) => item.area_id}
getName={(item) => item.name}
getMeta={(item) => `${item.district} · ${item.segment}`}
/>
<ComparisonRadar
title="板块雷达"
metrics={areaComparison?.metrics ?? []}
items={areaComparison?.items ?? []}
getId={(item) => item.area_id}
getName={(item) => item.name}
/>
<ComparisonMatrix
title="小区矩阵"
comparison={neighborhoodComparison}
getId={(item) => item.neighborhood_id}
getName={(item) => item.name}
getMeta={(item) =>
`${item.area_name} · ${item.built_year ?? "-"} · ${item.property_type}`
}
/>
<ComparisonRadar
title="小区雷达"
metrics={neighborhoodComparison?.metrics ?? []}
items={neighborhoodComparison?.items ?? []}
getId={(item) => item.neighborhood_id}
getName={(item) => item.name}
/>
</div>
) : null}
</CardContent>
</Card>
);
}
function ComparisonSelector({
label,
options,
selectedIds,
onChange,
}: {
label: string;
options: Array<{ id: string; label: string; detail: string }>;
selectedIds: string[];
onChange: (ids: string[]) => void;
}) {
function toggle(id: string) {
if (selectedIds.includes(id)) {
if (selectedIds.length > 2) {
onChange(selectedIds.filter((selectedId) => selectedId !== id));
}
return;
}
if (selectedIds.length < 4) {
onChange([...selectedIds, id]);
}
}
return (
<div className="rounded-md border border-[var(--border)] p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-slate-950">{label}</p>
<Badge variant="muted">{selectedIds.length}/4</Badge>
</div>
<div className="grid max-h-44 gap-2 overflow-y-auto pr-1">
{options.map((option) => {
const selected = selectedIds.includes(option.id);
const locked = selected && selectedIds.length <= 2;
return (
<label
key={option.id}
className={
selected
? "flex cursor-pointer items-center gap-2 rounded-md border border-teal-200 bg-teal-50 px-2 py-2"
: "flex cursor-pointer items-center gap-2 rounded-md border border-[var(--border)] px-2 py-2 hover:bg-slate-50"
}
>
<input
type="checkbox"
checked={selected}
disabled={locked}
onChange={() => toggle(option.id)}
/>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-slate-950">
{option.label}
</span>
<span className="block truncate text-xs text-[var(--muted-foreground)]">
{option.detail}
</span>
</span>
</label>
);
})}
</div>
</div>
);
}
function ComparisonMatrix<T>({
title,
comparison,
getId,
getName,
getMeta,
}: {
title: string;
comparison: { items: T[]; metrics: ComparisonMetric[]; missing_ids: string[] } | null;
getId: (item: T) => string;
getName: (item: T) => string;
getMeta: (item: T) => string;
}) {
if (!comparison || comparison.items.length === 0) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
const nameById = new Map(comparison.items.map((item) => [getId(item), getName(item)]));
const metaById = new Map(comparison.items.map((item) => [getId(item), getMeta(item)]));
return (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>{title}</TableHead>
{comparison.items.map((item) => (
<TableHead key={getId(item)} className="min-w-36 text-right">
<span className="block text-slate-950">{getName(item)}</span>
<span className="block font-normal text-[var(--muted-foreground)]">
{metaById.get(getId(item))}
</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{comparison.metrics.map((metric) => (
<TableRow key={metric.key}>
<TableCell className="font-medium">
{metric.label}
<span className="ml-1 text-xs font-normal text-[var(--muted-foreground)]">
{metric.unit}
</span>
</TableCell>
{comparison.items.map((item) => {
const id = getId(item);
const value = metric.values.find((metricValue) => metricValue.id === id)?.value;
return (
<TableCell key={`${metric.key}-${id}`} className="text-right">
{formatMetricValue(value, metric.unit)}
</TableCell>
);
})}
</TableRow>
))}
{comparison.missing_ids.length > 0 ? (
<TableRow>
<TableCell className="text-[var(--muted-foreground)]"></TableCell>
<TableCell
colSpan={comparison.items.length}
className="text-right text-[var(--muted-foreground)]"
>
{comparison.missing_ids.map((id) => nameById.get(id) ?? id).join(", ")}
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
);
}
function ComparisonRadar<T>({
title,
metrics,
items,
getId,
getName,
}: {
title: string;
metrics: ComparisonMetric[];
items: T[];
getId: (item: T) => string;
getName: (item: T) => string;
}) {
const radarMetrics = metrics.filter(
(metric) =>
metric.unit === "分" ||
metric.key === "annual_rent_yield_pct" ||
metric.key === "transaction_count",
);
const data = radarMetrics.map((metric) => {
const row: Record<string, string | number> = { metric: metric.label };
for (const item of items) {
const id = getId(item);
const value = metric.values.find((metricValue) => metricValue.id === id)?.value ?? 0;
row[id] = metric.direction === "lower" ? 100 - value : value;
}
return row;
});
const colors = ["#0f766e", "#2563eb", "#b45309", "#be123c"];
if (data.length === 0 || items.length === 0) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="h-80 rounded-md border border-[var(--border)] p-3">
<p className="mb-2 text-sm font-semibold text-slate-950">{title}</p>
<ResponsiveContainer width="100%" height="90%">
<RadarChart data={data}>
<PolarGrid stroke="#dbe1ea" />
<PolarAngleAxis dataKey="metric" tick={{ fontSize: 12 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
<Tooltip formatter={(value) => [formatNumber(Number(value)), "标准化"]} />
{items.map((item, index) => (
<Radar
key={getId(item)}
name={getName(item)}
dataKey={getId(item)}
stroke={colors[index % colors.length]}
fill={colors[index % colors.length]}
fillOpacity={0.14}
/>
))}
</RadarChart>
</ResponsiveContainer>
</div>
);
}
function formatMetricValue(value: number | null | undefined, unit: string) {
if (value === null || value === undefined) {
return "-";
}
if (unit === "元/㎡" || unit === "米") {
return formatPrice(value);
}
if (unit === "%") {
return `${formatNumber(value, 2)}%`;
}
if (unit === "套") {
return formatNumber(value, 0);
}
return formatNumber(value);
}
function formatSignedNumber(value: number) {
const sign = value > 0 ? "+" : "";
return `${sign}${formatNumber(value)}`;
}
function formatRelativePrice(value: number) {
if (value === 0) {
return "持平";
}
const label = value > 0 ? "溢价" : "折价";
return `${label} ${formatSignedNumber(value)}%`;
}
function relativePriceVariant(value: number): "success" | "warning" | "muted" {
if (value <= -3) {
return "success";
}
if (value >= 3) {
return "warning";
}
return "muted";
}
function formatSimilarityReason(reason: string) {
if (reason === "property_type_match") {
return "物业类型一致";
}
if (reason === "same_area") {
return "同板块";
}
if (reason === "same_district") {
return "同行政区";
}
const [key, rawValue] = reason.split(":");
if (key === "price_gap_pct" && rawValue) {
return `价差 ${rawValue}%`;
}
if (key === "building_age_gap_years" && rawValue) {
return `楼龄差 ${rawValue}`;
}
if (key === "metro_gap_m" && rawValue) {
return `地铁差 ${rawValue}`;
}
return reason;
}
function WatchlistPanel({
scores,
neighborhoodScores,
items,
events,
loading,
creating,
archivingId,
updatingId,
creatingEvent,
statusFilter,
areaFilter,
labelFilter,
expandedItemId,
onStatusFilterChange,
onAreaFilterChange,
onLabelFilterChange,
onToggleEvents,
onCreate,
onUpdate,
onCreateEvent,
onArchive,
}: {
scores: AreaScore[];
neighborhoodScores: NeighborhoodScore[];
items: WatchlistItem[];
events: WatchlistEvent[];
loading: boolean;
creating: boolean;
archivingId: number | null;
updatingId: number | null;
creatingEvent: boolean;
statusFilter: string;
areaFilter: string;
labelFilter: string;
expandedItemId: number | null;
onStatusFilterChange: (value: string) => void;
onAreaFilterChange: (value: string) => void;
onLabelFilterChange: (value: string) => void;
onToggleEvents: (id: number) => void;
onCreate: (payload: CreateWatchlistItem) => void;
onUpdate: (id: number, payload: UpdateWatchlistItem) => void;
onCreateEvent: (id: number, payload: CreateWatchlistEvent) => void;
onArchive: (id: number) => void;
}) {
const [targetType, setTargetType] = useState<"area" | "neighborhood">("area");
const [areaId, setAreaId] = useState("");
const [neighborhoodId, setNeighborhoodId] = useState("");
const [targetPrice, setTargetPrice] = useState("");
const [triggerPrice, setTriggerPrice] = useState("");
const [triggerOperator, setTriggerOperator] = useState<"lte" | "gte">("lte");
const [label, setLabel] = useState("核心");
const [priority, setPriority] = useState<"low" | "medium" | "high">("medium");
const [notes, setNotes] = useState("");
const areaNameById = useMemo(() => {
return new Map(scores.map((score) => [score.area_id, score.name]));
}, [scores]);
const neighborhoodNameById = useMemo(() => {
return new Map(
neighborhoodScores.map((score) => [
score.neighborhood_id,
`${score.name}${score.area_name}`,
]),
);
}, [neighborhoodScores]);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (targetType === "area" && !areaId) {
return;
}
if (targetType === "neighborhood" && !neighborhoodId) {
return;
}
const parsedTarget = Number(targetPrice);
const parsedTrigger = Number(triggerPrice);
onCreate({
area_id: targetType === "area" ? areaId : undefined,
neighborhood_id: targetType === "neighborhood" ? neighborhoodId : undefined,
target_price_psm: Number.isFinite(parsedTarget) && parsedTarget > 0 ? parsedTarget : undefined,
trigger_price_psm:
Number.isFinite(parsedTrigger) && parsedTrigger > 0 ? parsedTrigger : undefined,
trigger_operator: triggerOperator,
label: label.trim() || undefined,
priority,
notes: notes.trim() || undefined,
});
setAreaId("");
setNeighborhoodId("");
setTargetPrice("");
setTriggerPrice("");
setNotes("");
}
return (
<Card>
<CardHeader className="flex flex-col gap-3">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<CardTitle></CardTitle>
<div className="grid gap-2 sm:grid-cols-3 xl:w-[620px]">
<select
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
value={statusFilter}
onChange={(event) => onStatusFilterChange(event.target.value)}
aria-label="观察池状态筛选"
>
<option value="active">active</option>
<option value="paused">paused</option>
<option value="archived">archived</option>
</select>
<select
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
value={areaFilter}
onChange={(event) => onAreaFilterChange(event.target.value)}
aria-label="观察池板块筛选"
>
<option value=""></option>
{scores.map((score) => (
<option key={score.area_id} value={score.area_id}>
{score.name}
</option>
))}
</select>
<input
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
placeholder="标签"
value={labelFilter}
onChange={(event) => onLabelFilterChange(event.target.value)}
/>
</div>
</div>
<form className="grid gap-2 lg:grid-cols-[100px_1fr_1fr_120px_120px_90px_90px_100px_1fr_44px]" onSubmit={submit}>
<select
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
value={targetType}
onChange={(event) => setTargetType(event.target.value as "area" | "neighborhood")}
aria-label="观察标的类型"
>
<option value="area"></option>
<option value="neighborhood"></option>
</select>
<select
className="h-9 min-w-36 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
value={areaId}
onChange={(event) => setAreaId(event.target.value)}
aria-label="板块"
disabled={targetType !== "area"}
>
<option value=""></option>
{scores.map((score) => (
<option key={score.area_id} value={score.area_id}>
{score.name}
</option>
))}
</select>
<select
className="h-9 min-w-36 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
value={neighborhoodId}
onChange={(event) => setNeighborhoodId(event.target.value)}
aria-label="小区"
disabled={targetType !== "neighborhood"}
>
<option value=""></option>
{neighborhoodScores.map((score) => (
<option key={score.neighborhood_id} value={score.neighborhood_id}>
{score.name}
</option>
))}
</select>
<input
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
inputMode="numeric"
placeholder="目标价/㎡"
value={targetPrice}
onChange={(event) => setTargetPrice(event.target.value)}
/>
<input
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
inputMode="numeric"
placeholder="触发价/㎡"
value={triggerPrice}
onChange={(event) => setTriggerPrice(event.target.value)}
/>
<select
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
value={triggerOperator}
onChange={(event) => setTriggerOperator(event.target.value as "lte" | "gte")}
aria-label="触发条件"
>
<option value="lte"></option>
<option value="gte"></option>
</select>
<select
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
value={priority}
onChange={(event) => setPriority(event.target.value as "low" | "medium" | "high")}
aria-label="优先级"
>
<option value="high">high</option>
<option value="medium">medium</option>
<option value="low">low</option>
</select>
<input
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
placeholder="标签"
value={label}
onChange={(event) => setLabel(event.target.value)}
/>
<input
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
placeholder="备注"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
<Button
disabled={
creating ||
(targetType === "area" && !areaId) ||
(targetType === "neighborhood" && !neighborhoodId)
}
aria-label="加入观察池"
title="加入观察池"
>
<Plus className="h-4 w-4" />
</Button>
</form>
</CardHeader>
<CardContent className="overflow-x-auto p-0">
<WatchlistTable
items={items}
areaNameById={areaNameById}
neighborhoodNameById={neighborhoodNameById}
loading={loading}
archivingId={archivingId}
updatingId={updatingId}
expandedItemId={expandedItemId}
events={events}
creatingEvent={creatingEvent}
onUpdate={onUpdate}
onCreateEvent={onCreateEvent}
onArchive={onArchive}
onToggleEvents={onToggleEvents}
/>
</CardContent>
</Card>
);
}
function WatchlistTable({
items,
areaNameById,
neighborhoodNameById,
loading,
archivingId,
updatingId,
expandedItemId,
events,
creatingEvent,
onUpdate,
onCreateEvent,
onArchive,
onToggleEvents,
}: {
items: WatchlistItem[];
areaNameById: Map<string, string>;
neighborhoodNameById: Map<string, string>;
loading: boolean;
archivingId: number | null;
updatingId: number | null;
expandedItemId: number | null;
events: WatchlistEvent[];
creatingEvent: boolean;
onUpdate: (id: number, payload: UpdateWatchlistItem) => void;
onCreateEvent: (id: number, payload: CreateWatchlistEvent) => void;
onArchive: (id: number) => void;
onToggleEvents: (id: number) => void;
}) {
const [eventSummary, setEventSummary] = useState("");
if (loading) {
return <div className="h-32 bg-[var(--muted)]" />;
}
if (items.length === 0) {
return <div className="px-4 py-8 text-sm text-[var(--muted-foreground)]"></div>;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-44" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const targetName = item.neighborhood_id
? neighborhoodNameById.get(item.neighborhood_id) ?? item.neighborhood_id
: item.area_id
? areaNameById.get(item.area_id) ?? item.area_id
: "-";
const expanded = expandedItemId === item.watchlist_item_id;
return (
<Fragment key={item.watchlist_item_id}>
<TableRow>
<TableCell className="font-medium">{targetName}</TableCell>
<TableCell className="text-right">
{item.target_price_psm ? formatPrice(item.target_price_psm) : "-"}
</TableCell>
<TableCell className="text-right">
{item.trigger_price_psm
? `${item.trigger_operator === "lte" ? "≤" : "≥"} ${formatPrice(item.trigger_price_psm)}`
: "-"}
</TableCell>
<TableCell>
<Badge variant="muted">{item.label}</Badge>
</TableCell>
<TableCell>
<Badge variant={item.priority === "high" ? "warning" : "muted"}>
{item.priority}
</Badge>
</TableCell>
<TableCell>
<select
className="h-8 rounded-md border border-[var(--border)] bg-white px-2 text-sm"
value={item.status}
disabled={updatingId === item.watchlist_item_id}
onChange={(event) =>
onUpdate(item.watchlist_item_id, {
status: event.target.value as "active" | "paused" | "archived",
})
}
aria-label="观察池状态"
>
<option value="active">active</option>
<option value="paused">paused</option>
<option value="archived">archived</option>
</select>
</TableCell>
<TableCell className="text-[var(--muted-foreground)]">
{item.last_reviewed_at ? item.last_reviewed_at.slice(0, 10) : "-"}
</TableCell>
<TableCell className="max-w-80 truncate text-[var(--muted-foreground)]">
{item.notes || "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
disabled={updatingId === item.watchlist_item_id}
onClick={() =>
onUpdate(item.watchlist_item_id, {
mark_reviewed: true,
})
}
aria-label="复核"
title="复核"
>
<Check className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => onToggleEvents(item.watchlist_item_id)}
aria-label="历史"
title="历史"
>
<Activity className="h-4 w-4" />
</Button>
<Button
variant="outline"
disabled={archivingId === item.watchlist_item_id}
onClick={() => onArchive(item.watchlist_item_id)}
aria-label="归档"
title="归档"
>
<Archive className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
{expanded ? (
<TableRow>
<TableCell colSpan={9} className="bg-slate-50">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="grid gap-2">
{events.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]"></p>
) : (
events.map((event) => (
<div
key={event.event_id}
className="rounded-md border border-[var(--border)] bg-white px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<Badge variant="muted">{event.event_type}</Badge>
<span className="text-xs text-[var(--muted-foreground)]">
{event.created_at.slice(0, 16)}
</span>
</div>
<p className="mt-2 text-sm text-slate-950">{event.summary}</p>
</div>
))
)}
</div>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
if (!eventSummary.trim()) {
return;
}
onCreateEvent(item.watchlist_item_id, {
event_type: "note",
summary: eventSummary.trim(),
});
setEventSummary("");
}}
>
<input
className="h-9 min-w-0 flex-1 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
placeholder="新增历史备注"
value={eventSummary}
onChange={(event) => setEventSummary(event.target.value)}
/>
<Button
disabled={creatingEvent || !eventSummary.trim()}
aria-label="新增历史"
title="新增历史"
>
<Plus className="h-4 w-4" />
</Button>
</form>
</div>
</TableCell>
</TableRow>
) : null}
</Fragment>
);
})}
</TableBody>
</Table>
);
}
function ModelRunPanel({
runs,
diff,
baseRunId,
candidateRunId,
loading,
creating,
onBaseRunChange,
onCandidateRunChange,
onCreateRun,
}: {
runs: ModelRun[];
diff: AreaScoreRunDiff | null;
baseRunId: number | null;
candidateRunId: number | null;
loading: boolean;
creating: boolean;
onBaseRunChange: (id: number | null) => void;
onCandidateRunChange: (id: number | null) => void;
onCreateRun: (payload: CreateAreaScoreModelRun) => void;
}) {
const [modelVersion, setModelVersion] = useState("area-score-rust-v1");
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
onCreateRun({
month: MONTH,
model_version: modelVersion.trim() || undefined,
});
}
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>
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={submit}>
<input
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-56"
placeholder="模型版本"
value={modelVersion}
onChange={(event) => setModelVersion(event.target.value)}
/>
<Button disabled={creating} aria-label="重算板块评分" title="重算板块评分">
<RefreshCw className="h-4 w-4" />
</Button>
</form>
</CardHeader>
<CardContent className="grid gap-4 xl:grid-cols-[420px_minmax(0,1fr)]">
<ModelRunTable runs={runs} loading={loading} />
<div className="grid gap-3">
<div className="grid gap-2 sm:grid-cols-2">
<ModelRunSelect
label="基准批次"
runs={runs}
value={baseRunId}
onChange={onBaseRunChange}
/>
<ModelRunSelect
label="候选批次"
runs={runs}
value={candidateRunId}
onChange={onCandidateRunChange}
/>
</div>
<AreaScoreRunDiffTable diff={diff} loading={loading} />
</div>
</CardContent>
</Card>
);
}
function ModelRunTable({ runs, loading }: { runs: ModelRun[]; loading: boolean }) {
if (loading) {
return <div className="h-56 rounded-md bg-[var(--muted)]" />;
}
if (runs.length === 0) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.slice(0, 8).map((run) => (
<TableRow key={run.model_run_id}>
<TableCell className="font-medium">#{run.model_run_id}</TableCell>
<TableCell className="max-w-40 truncate">{run.model_version}</TableCell>
<TableCell>{run.target_month}</TableCell>
<TableCell>
<Badge variant={run.status === "succeeded" ? "success" : "warning"}>
{run.status}
</Badge>
</TableCell>
<TableCell className="text-right">{run.row_count ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function ModelRunSelect({
label,
runs,
value,
onChange,
}: {
label: string;
runs: ModelRun[];
value: number | null;
onChange: (id: number | null) => void;
}) {
return (
<label className="grid gap-1">
<span className="text-sm font-medium text-slate-950">{label}</span>
<select
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
value={value ?? ""}
onChange={(event) => onChange(event.target.value ? Number(event.target.value) : null)}
>
<option value=""></option>
{runs.map((run) => (
<option key={run.model_run_id} value={run.model_run_id}>
#{run.model_run_id} · {run.model_version} · {run.target_month}
</option>
))}
</select>
</label>
);
}
function AreaScoreRunDiffTable({
diff,
loading,
}: {
diff: AreaScoreRunDiff | null;
loading: boolean;
}) {
if (loading) {
return <div className="h-56 rounded-md bg-[var(--muted)]" />;
}
if (!diff || diff.items.length === 0) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{diff.items.map((item) => (
<TableRow key={item.area_id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>{item.district}</TableCell>
<TableCell className="text-right">
{item.base_score === null ? "-" : formatNumber(item.base_score)}
</TableCell>
<TableCell className="text-right">
{item.candidate_score === null ? "-" : formatNumber(item.candidate_score)}
</TableCell>
<TableCell className="text-right font-semibold">
{item.score_delta === null ? "-" : formatSignedNumber(item.score_delta)}
</TableCell>
<TableCell>
{item.base_recommendation === item.candidate_recommendation ? (
<Badge variant="muted">{item.candidate_recommendation ?? "-"}</Badge>
) : (
<Badge variant="warning">
{item.base_recommendation ?? "-"} {item.candidate_recommendation ?? "-"}
</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function MetricCard({
title,
value,
textValue,
suffix,
icon,
loading,
tone = "default",
}: {
title: string;
value?: number;
textValue?: string;
suffix?: string;
icon: React.ReactNode;
loading: boolean;
tone?: "default" | "warning";
}) {
return (
<Card>
<CardContent className="flex min-h-28 items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm text-[var(--muted-foreground)]">{title}</p>
<p className="mt-2 truncate text-2xl font-semibold text-slate-950">
{loading ? "-" : textValue ?? `${formatNumber(value ?? 0)}${suffix ?? ""}`}
</p>
</div>
<div
className={
tone === "warning"
? "flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-amber-50 text-[var(--warning)]"
: "flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-teal-50 text-[var(--primary)]"
}
>
{icon}
</div>
</CardContent>
</Card>
);
}
function ScoreChart({ scores, loading }: { scores: AreaScore[]; loading: boolean }) {
if (loading) {
return <div className="h-full rounded-md bg-[var(--muted)]" />;
}
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={scores} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
<CartesianGrid stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
<YAxis domain={[0, 100]} tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
<Tooltip
cursor={{ fill: "#eef2f7" }}
formatter={(value) => [`${formatNumber(Number(value))}`, "综合分"]}
labelStyle={{ color: "#172033" }}
/>
<Bar dataKey="investment_score" fill="#0f766e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
function InsightRow({ label, value, detail }: { label: string; value: string; detail: string }) {
return (
<div className="flex items-center justify-between gap-3 border-b border-[var(--border)] pb-3 last:border-0 last:pb-0">
<div className="min-w-0">
<p className="text-sm text-[var(--muted-foreground)]">{label}</p>
<p className="mt-1 truncate text-base font-semibold text-slate-950">{value}</p>
</div>
<Badge variant="muted">{detail}</Badge>
</div>
);
}
function AreaScoreTable({
scores,
loading,
selectedAreaId,
onSelectArea,
}: {
scores: AreaScore[];
loading: boolean;
selectedAreaId: string;
onSelectArea: (areaId: string) => void;
}) {
if (loading) {
return <div className="h-48 bg-[var(--muted)]" />;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scores.map((score) => (
<TableRow
key={score.area_id}
className={
selectedAreaId === score.area_id
? "cursor-pointer bg-teal-50/60"
: "cursor-pointer hover:bg-slate-50"
}
onClick={() => onSelectArea(score.area_id)}
>
<TableCell className="font-medium">{score.name}</TableCell>
<TableCell>{score.district}</TableCell>
<TableCell className="text-right font-semibold">
{formatNumber(score.investment_score)}
</TableCell>
<TableCell>
<Badge variant={score.recommendation === "观察池" ? "success" : "muted"}>
{score.recommendation}
</Badge>
</TableCell>
<TableCell className="text-right">{formatPrice(score.transaction_price_psm)}</TableCell>
<TableCell className="text-right">{score.transaction_count}</TableCell>
<TableCell className="text-right">
{formatNumber(score.annual_rent_yield_pct, 2)}%
</TableCell>
<TableCell className="text-right">{formatNumber(score.supply_risk_score)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function ErrorState() {
return (
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
API
</div>
);
}