feat: add neighborhood ranking workflow
This commit is contained in:
@@ -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 (
|
||||
<main className="min-h-screen px-4 py-5 sm:px-6 lg:px-8">
|
||||
@@ -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() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<NeighborhoodRankingPanel
|
||||
scores={scores.data ?? []}
|
||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||
selectedAreaId={selectedAreaId}
|
||||
loading={isLoading}
|
||||
creating={createMutation.isPending}
|
||||
onSelectArea={setSelectedAreaId}
|
||||
onCreate={(payload) => createMutation.mutate(payload)}
|
||||
/>
|
||||
|
||||
<WatchlistPanel
|
||||
scores={scores.data ?? []}
|
||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||
items={watchlist.data ?? []}
|
||||
loading={isLoading}
|
||||
creating={createMutation.isPending}
|
||||
@@ -203,8 +223,141 @@ export function MarketDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
function NeighborhoodRankingPanel({
|
||||
scores,
|
||||
neighborhoodScores,
|
||||
selectedAreaId,
|
||||
loading,
|
||||
creating,
|
||||
onSelectArea,
|
||||
onCreate,
|
||||
}: {
|
||||
scores: AreaScore[];
|
||||
neighborhoodScores: NeighborhoodScore[];
|
||||
selectedAreaId: string;
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
onSelectArea: (areaId: 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}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function NeighborhoodScoreTable({
|
||||
scores,
|
||||
loading,
|
||||
creating,
|
||||
onCreate,
|
||||
}: {
|
||||
scores: NeighborhoodScore[];
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
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}>
|
||||
<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={() =>
|
||||
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 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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -283,6 +445,7 @@ function WatchlistPanel({
|
||||
<WatchlistTable
|
||||
items={items}
|
||||
areaNameById={areaNameById}
|
||||
neighborhoodNameById={neighborhoodNameById}
|
||||
loading={loading}
|
||||
archivingId={archivingId}
|
||||
onArchive={onArchive}
|
||||
@@ -295,12 +458,14 @@ function WatchlistPanel({
|
||||
function WatchlistTable({
|
||||
items,
|
||||
areaNameById,
|
||||
neighborhoodNameById,
|
||||
loading,
|
||||
archivingId,
|
||||
onArchive,
|
||||
}: {
|
||||
items: WatchlistItem[];
|
||||
areaNameById: Map<string, string>;
|
||||
neighborhoodNameById: Map<string, string>;
|
||||
loading: boolean;
|
||||
archivingId: number | null;
|
||||
onArchive: (id: number) => void;
|
||||
@@ -328,7 +493,11 @@ function WatchlistTable({
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.watchlist_item_id}>
|
||||
<TableCell className="font-medium">
|
||||
{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
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.target_price_psm ? formatPrice(item.target_price_psm) : "-"}
|
||||
|
||||
@@ -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<AreaScore[]> {
|
||||
return fetchJson(`/api/v1/areas/scores?month=${encodeURIComponent(month)}`);
|
||||
}
|
||||
|
||||
export async function fetchNeighborhoodScores(month: string): Promise<NeighborhoodScore[]> {
|
||||
return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`);
|
||||
}
|
||||
|
||||
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
|
||||
return fetchJson("/api/v1/watchlist");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user