diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index 43dd345..167212a 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -27,9 +27,10 @@ import { createWatchlistItem, fetchAreaScores, fetchMarketOverview, + fetchNeighborhoodScores, fetchWatchlistItems, } from "@/lib/api"; -import type { AreaScore, WatchlistItem } from "@/lib/api"; +import type { AreaScore, CreateWatchlistItem, NeighborhoodScore, WatchlistItem } from "@/lib/api"; import { formatNumber, formatPrice } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -47,6 +48,7 @@ const MONTH = "2026-05"; export function MarketDashboard() { const queryClient = useQueryClient(); + const [selectedAreaId, setSelectedAreaId] = useState(""); const overview = useQuery({ queryKey: ["market-overview", MONTH], queryFn: () => fetchMarketOverview(MONTH), @@ -55,6 +57,10 @@ export function MarketDashboard() { queryKey: ["area-scores", MONTH], queryFn: () => fetchAreaScores(MONTH), }); + const neighborhoodScores = useQuery({ + queryKey: ["neighborhood-scores", MONTH], + queryFn: () => fetchNeighborhoodScores(MONTH), + }); const watchlist = useQuery({ queryKey: ["watchlist"], queryFn: fetchWatchlistItems, @@ -72,8 +78,10 @@ export function MarketDashboard() { }, }); - const isLoading = overview.isLoading || scores.isLoading || watchlist.isLoading; - const hasError = overview.isError || scores.isError || watchlist.isError; + const isLoading = + overview.isLoading || scores.isLoading || neighborhoodScores.isLoading || watchlist.isLoading; + const hasError = + overview.isError || scores.isError || neighborhoodScores.isError || watchlist.isError; return (
@@ -92,6 +100,7 @@ export function MarketDashboard() { onClick={() => { void overview.refetch(); void scores.refetch(); + void neighborhoodScores.refetch(); void watchlist.refetch(); }} disabled={isLoading} @@ -189,8 +198,19 @@ export function MarketDashboard() { + createMutation.mutate(payload)} + /> + void; + onCreate: (payload: CreateWatchlistItem) => void; +}) { + const filteredScores = selectedAreaId + ? neighborhoodScores.filter((score) => score.area_id === selectedAreaId) + : neighborhoodScores; + const topNeighborhood = filteredScores[0]; + + return ( + + +
+ 小区排行 + {topNeighborhood ? 首位:{topNeighborhood.name} : null} +
+ +
+ + + +
+ ); +} + +function NeighborhoodScoreTable({ + scores, + loading, + creating, + onCreate, +}: { + scores: NeighborhoodScore[]; + loading: boolean; + creating: boolean; + onCreate: (payload: CreateWatchlistItem) => void; +}) { + if (loading) { + return
; + } + + if (scores.length === 0) { + return
暂无小区评分
; + } + + return ( + + + + 小区 + 板块 + 综合分 + 建议 + 成交均价/㎡ + 租金收益率 + 流动性 + 位置 + + + + + {scores.map((score) => ( + + {score.name} + {score.area_name} + + {formatNumber(score.investment_score)} + + + + {score.recommendation} + + + {formatPrice(score.transaction_price_psm)} + + {formatNumber(score.annual_rent_yield_pct, 2)}% + + {formatNumber(score.liquidity_score)} + {formatNumber(score.location_score)} + + + + + ))} + +
+ ); +} + function WatchlistPanel({ scores, + neighborhoodScores, items, loading, creating, @@ -213,11 +366,12 @@ function WatchlistPanel({ onArchive, }: { scores: AreaScore[]; + neighborhoodScores: NeighborhoodScore[]; items: WatchlistItem[]; loading: boolean; creating: boolean; archivingId: number | null; - onCreate: (payload: { area_id: string; target_price_psm?: number; notes?: string }) => void; + onCreate: (payload: CreateWatchlistItem) => void; onArchive: (id: number) => void; }) { const [areaId, setAreaId] = useState(""); @@ -227,6 +381,14 @@ function WatchlistPanel({ 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) { event.preventDefault(); @@ -283,6 +445,7 @@ function WatchlistPanel({ ; + neighborhoodNameById: Map; loading: boolean; archivingId: number | null; onArchive: (id: number) => void; @@ -328,7 +493,11 @@ function WatchlistTable({ {items.map((item) => ( - {item.area_id ? areaNameById.get(item.area_id) ?? item.area_id : item.neighborhood_id} + {item.neighborhood_id + ? neighborhoodNameById.get(item.neighborhood_id) ?? item.neighborhood_id + : item.area_id + ? areaNameById.get(item.area_id) ?? item.area_id + : "-"} {item.target_price_psm ? formatPrice(item.target_price_psm) : "-"} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4b4fcd2..1ca8736 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -44,6 +44,31 @@ export type MarketOverview = { highest_supply_risk_area: AreaScoreSummary | null; }; +export type NeighborhoodScore = { + neighborhood_id: string; + area_id: string; + name: string; + area_name: string; + district: string; + month: string; + investment_score: number; + recommendation: string; + liquidity_score: number; + rent_support_score: number; + price_safety_score: number; + location_score: number; + building_age_score: number; + transaction_count: number; + transaction_price_psm: number; + listing_count: number; + listing_price_psm: number; + rent_price_psm: number; + median_days_on_market: number; + annual_rent_yield_pct: number; + discount_pct: number; + available_units: number; +}; + export type WatchlistItem = { watchlist_item_id: number; neighborhood_id: string | null; @@ -71,6 +96,10 @@ export async function fetchAreaScores(month: string): Promise { return fetchJson(`/api/v1/areas/scores?month=${encodeURIComponent(month)}`); } +export async function fetchNeighborhoodScores(month: string): Promise { + return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`); +} + export async function fetchWatchlistItems(): Promise { return fetchJson("/api/v1/watchlist"); }