feat: add neighborhood ranking workflow

This commit is contained in:
2026-06-23 17:56:50 +08:00
parent b73a89e576
commit 1d5138d118
2 changed files with 203 additions and 5 deletions

View File

@@ -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) : "-"}

View File

@@ -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");
}