feat: add neighborhood detail workspace
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user