feat: add neighborhood detail workspace

This commit is contained in:
2026-06-24 13:40:24 +08:00
parent 6ca67e7555
commit ff6e41a6e5
6 changed files with 551 additions and 26 deletions

View File

@@ -12,7 +12,7 @@ import {
RefreshCw,
TrendingUp,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Bar,
BarChart,
@@ -36,6 +36,7 @@ import {
fetchAreaDetail,
fetchIngestionRuns,
fetchMarketOverview,
fetchNeighborhoodDetail,
fetchNeighborhoodScores,
fetchRawArtifacts,
fetchWatchlistItems,
@@ -50,6 +51,7 @@ import type {
CreateWatchlistItem,
DataSource,
IngestionRun,
NeighborhoodDetail,
NeighborhoodScore,
RawArtifact,
WatchlistItem,
@@ -73,6 +75,7 @@ export function MarketDashboard() {
const queryClient = useQueryClient();
const [selectedAreaId, setSelectedAreaId] = useState("");
const [detailAreaId, setDetailAreaId] = useState("qiantan");
const [detailNeighborhoodId, setDetailNeighborhoodId] = useState("");
const overview = useQuery({
queryKey: ["market-overview", MONTH],
queryFn: () => fetchMarketOverview(MONTH),
@@ -90,6 +93,11 @@ export function MarketDashboard() {
queryKey: ["neighborhood-scores", MONTH],
queryFn: () => fetchNeighborhoodScores(MONTH),
});
const neighborhoodDetail = useQuery({
queryKey: ["neighborhood-detail", detailNeighborhoodId, MONTH],
queryFn: () => fetchNeighborhoodDetail(detailNeighborhoodId, MONTH),
enabled: Boolean(detailNeighborhoodId),
});
const watchlist = useQuery({
queryKey: ["watchlist"],
queryFn: fetchWatchlistItems,
@@ -109,7 +117,10 @@ export function MarketDashboard() {
const createMutation = useMutation({
mutationFn: createWatchlistItem,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["watchlist"] });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["watchlist"] }),
queryClient.invalidateQueries({ queryKey: ["neighborhood-detail"] }),
]);
},
});
const archiveMutation = useMutation({
@@ -147,10 +158,17 @@ export function MarketDashboard() {
},
});
useEffect(() => {
if (!detailNeighborhoodId && neighborhoodScores.data?.[0]) {
setDetailNeighborhoodId(neighborhoodScores.data[0].neighborhood_id);
}
}, [detailNeighborhoodId, neighborhoodScores.data]);
const isLoading =
overview.isLoading ||
scores.isLoading ||
areaDetail.isLoading ||
neighborhoodDetail.isLoading ||
neighborhoodScores.isLoading ||
watchlist.isLoading ||
dataSources.isLoading ||
@@ -160,6 +178,7 @@ export function MarketDashboard() {
overview.isError ||
scores.isError ||
areaDetail.isError ||
neighborhoodDetail.isError ||
neighborhoodScores.isError ||
watchlist.isError ||
dataSources.isError ||
@@ -184,6 +203,7 @@ export function MarketDashboard() {
void overview.refetch();
void scores.refetch();
void areaDetail.refetch();
void neighborhoodDetail.refetch();
void neighborhoodScores.refetch();
void watchlist.refetch();
void dataSources.refetch();
@@ -295,18 +315,28 @@ export function MarketDashboard() {
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)}
/>
<WatchlistPanel
scores={scores.data ?? []}
neighborhoodScores={neighborhoodScores.data ?? []}
@@ -745,11 +775,13 @@ function AreaDetailPanel({
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)]" />;
@@ -826,6 +858,7 @@ function AreaDetailPanel({
scores={detail.neighborhood_scores}
creating={creating}
onCreate={onCreateWatchlist}
onSelectNeighborhood={onSelectNeighborhood}
/>
</div>
{lineage ? (
@@ -930,10 +963,12 @@ function AreaNeighborhoodTable({
scores,
creating,
onCreate,
onSelectNeighborhood,
}: {
scores: NeighborhoodScore[];
creating: boolean;
onCreate: (payload: CreateWatchlistItem) => void;
onSelectNeighborhood: (neighborhoodId: string) => void;
}) {
if (scores.length === 0) {
return (
@@ -956,7 +991,11 @@ function AreaNeighborhoodTable({
</TableHeader>
<TableBody>
{scores.slice(0, 6).map((score) => (
<TableRow key={score.neighborhood_id}>
<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>
@@ -964,13 +1003,14 @@ function AreaNeighborhoodTable({
<Button
variant="outline"
disabled={creating}
onClick={() =>
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="加入观察池"
>
@@ -989,17 +1029,21 @@ 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
@@ -1033,6 +1077,8 @@ function NeighborhoodRankingPanel({
scores={filteredScores}
loading={loading}
creating={creating}
selectedNeighborhoodId={selectedNeighborhoodId}
onSelectNeighborhood={onSelectNeighborhood}
onCreate={onCreate}
/>
</CardContent>
@@ -1044,11 +1090,15 @@ 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) {
@@ -1076,7 +1126,15 @@ function NeighborhoodScoreTable({
</TableHeader>
<TableBody>
{scores.map((score) => (
<TableRow key={score.neighborhood_id}>
<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">
@@ -1097,13 +1155,14 @@ function NeighborhoodScoreTable({
<Button
variant="outline"
disabled={creating}
onClick={() =>
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="加入观察池"
>
@@ -1117,6 +1176,286 @@ function NeighborhoodScoreTable({
);
}
function NeighborhoodDetailPanel({
detail,
loading,
creating,
onCreateWatchlist,
}: {
detail: NeighborhoodDetail | null;
loading: boolean;
creating: boolean;
onCreateWatchlist: (payload: CreateWatchlistItem) => 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="占位"
/>
<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>
</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 WatchlistPanel({
scores,
neighborhoodScores,