feat: add comparison workspace

This commit is contained in:
2026-06-24 13:56:39 +08:00
parent ff6e41a6e5
commit f8fb09d2a9
6 changed files with 871 additions and 15 deletions

View File

@@ -19,6 +19,11 @@ import {
CartesianGrid,
Line,
LineChart,
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
ResponsiveContainer,
Tooltip,
XAxis,
@@ -33,9 +38,11 @@ import {
createWatchlistItem,
fetchDataSources,
fetchAreaScores,
fetchAreaComparison,
fetchAreaDetail,
fetchIngestionRuns,
fetchMarketOverview,
fetchNeighborhoodComparison,
fetchNeighborhoodDetail,
fetchNeighborhoodScores,
fetchRawArtifacts,
@@ -44,13 +51,17 @@ import {
} from "@/lib/api";
import type {
AreaScore,
AreaComparison,
AreaDetail,
ComparisonMetric,
CreateDataSource,
CreateIngestionRun,
CreateRawArtifact,
CreateWatchlistItem,
DataSource,
IngestionRun,
NeighborhoodComparison,
NeighborhoodComparisonItem,
NeighborhoodDetail,
NeighborhoodScore,
RawArtifact,
@@ -76,6 +87,8 @@ export function MarketDashboard() {
const [selectedAreaId, setSelectedAreaId] = useState("");
const [detailAreaId, setDetailAreaId] = useState("qiantan");
const [detailNeighborhoodId, setDetailNeighborhoodId] = useState("");
const [comparisonAreaIds, setComparisonAreaIds] = useState<string[]>([]);
const [comparisonNeighborhoodIds, setComparisonNeighborhoodIds] = useState<string[]>([]);
const overview = useQuery({
queryKey: ["market-overview", MONTH],
queryFn: () => fetchMarketOverview(MONTH),
@@ -98,6 +111,16 @@ export function MarketDashboard() {
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"],
queryFn: fetchWatchlistItems,
@@ -164,11 +187,31 @@ export function MarketDashboard() {
}
}, [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]);
const isLoading =
overview.isLoading ||
scores.isLoading ||
areaDetail.isLoading ||
neighborhoodDetail.isLoading ||
areaComparison.isLoading ||
neighborhoodComparison.isLoading ||
neighborhoodScores.isLoading ||
watchlist.isLoading ||
dataSources.isLoading ||
@@ -179,6 +222,8 @@ export function MarketDashboard() {
scores.isError ||
areaDetail.isError ||
neighborhoodDetail.isError ||
areaComparison.isError ||
neighborhoodComparison.isError ||
neighborhoodScores.isError ||
watchlist.isError ||
dataSources.isError ||
@@ -204,6 +249,8 @@ export function MarketDashboard() {
void scores.refetch();
void areaDetail.refetch();
void neighborhoodDetail.refetch();
void areaComparison.refetch();
void neighborhoodComparison.refetch();
void neighborhoodScores.refetch();
void watchlist.refetch();
void dataSources.refetch();
@@ -337,6 +384,18 @@ export function MarketDashboard() {
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
/>
<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 ?? []}
@@ -1456,6 +1515,320 @@ function NeighborhoodProfileTable({ detail }: { detail: NeighborhoodDetail }) {
);
}
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 WatchlistPanel({
scores,
neighborhoodScores,

View File

@@ -288,6 +288,42 @@ export type NeighborhoodDetail = {
watchlist_items: WatchlistItem[];
};
export type ComparisonMetricValue = {
id: string;
value: number | null;
};
export type ComparisonMetric = {
key: string;
label: string;
unit: string;
direction: "higher" | "lower" | "neutral";
values: ComparisonMetricValue[];
};
export type AreaComparison = {
month: string;
requested_ids: string[];
missing_ids: string[];
items: AreaScore[];
metrics: ComparisonMetric[];
};
export type NeighborhoodComparisonItem = NeighborhoodScore & {
built_year: number | null;
property_type: string;
metro_distance_m: number | null;
school_quality: string | null;
};
export type NeighborhoodComparison = {
month: string;
requested_ids: string[];
missing_ids: string[];
items: NeighborhoodComparisonItem[];
metrics: ComparisonMetric[];
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080";
@@ -318,6 +354,24 @@ export async function fetchNeighborhoodDetail(
);
}
export async function fetchAreaComparison(
areaIds: string[],
month: string,
): Promise<AreaComparison> {
return fetchJson(
`/api/v1/compare/areas?month=${encodeURIComponent(month)}&ids=${encodeURIComponent(areaIds.join(","))}`,
);
}
export async function fetchNeighborhoodComparison(
neighborhoodIds: string[],
month: string,
): Promise<NeighborhoodComparison> {
return fetchJson(
`/api/v1/compare/neighborhoods?month=${encodeURIComponent(month)}&ids=${encodeURIComponent(neighborhoodIds.join(","))}`,
);
}
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
return fetchJson("/api/v1/watchlist");
}