feat: add neighborhood ranking workflow
This commit is contained in:
@@ -27,9 +27,10 @@ import {
|
|||||||
createWatchlistItem,
|
createWatchlistItem,
|
||||||
fetchAreaScores,
|
fetchAreaScores,
|
||||||
fetchMarketOverview,
|
fetchMarketOverview,
|
||||||
|
fetchNeighborhoodScores,
|
||||||
fetchWatchlistItems,
|
fetchWatchlistItems,
|
||||||
} from "@/lib/api";
|
} 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 { formatNumber, formatPrice } from "@/lib/utils";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -47,6 +48,7 @@ const MONTH = "2026-05";
|
|||||||
|
|
||||||
export function MarketDashboard() {
|
export function MarketDashboard() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedAreaId, setSelectedAreaId] = useState("");
|
||||||
const overview = useQuery({
|
const overview = useQuery({
|
||||||
queryKey: ["market-overview", MONTH],
|
queryKey: ["market-overview", MONTH],
|
||||||
queryFn: () => fetchMarketOverview(MONTH),
|
queryFn: () => fetchMarketOverview(MONTH),
|
||||||
@@ -55,6 +57,10 @@ export function MarketDashboard() {
|
|||||||
queryKey: ["area-scores", MONTH],
|
queryKey: ["area-scores", MONTH],
|
||||||
queryFn: () => fetchAreaScores(MONTH),
|
queryFn: () => fetchAreaScores(MONTH),
|
||||||
});
|
});
|
||||||
|
const neighborhoodScores = useQuery({
|
||||||
|
queryKey: ["neighborhood-scores", MONTH],
|
||||||
|
queryFn: () => fetchNeighborhoodScores(MONTH),
|
||||||
|
});
|
||||||
const watchlist = useQuery({
|
const watchlist = useQuery({
|
||||||
queryKey: ["watchlist"],
|
queryKey: ["watchlist"],
|
||||||
queryFn: fetchWatchlistItems,
|
queryFn: fetchWatchlistItems,
|
||||||
@@ -72,8 +78,10 @@ export function MarketDashboard() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoading = overview.isLoading || scores.isLoading || watchlist.isLoading;
|
const isLoading =
|
||||||
const hasError = overview.isError || scores.isError || watchlist.isError;
|
overview.isLoading || scores.isLoading || neighborhoodScores.isLoading || watchlist.isLoading;
|
||||||
|
const hasError =
|
||||||
|
overview.isError || scores.isError || neighborhoodScores.isError || watchlist.isError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen px-4 py-5 sm:px-6 lg:px-8">
|
<main className="min-h-screen px-4 py-5 sm:px-6 lg:px-8">
|
||||||
@@ -92,6 +100,7 @@ export function MarketDashboard() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
void overview.refetch();
|
void overview.refetch();
|
||||||
void scores.refetch();
|
void scores.refetch();
|
||||||
|
void neighborhoodScores.refetch();
|
||||||
void watchlist.refetch();
|
void watchlist.refetch();
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -189,8 +198,19 @@ export function MarketDashboard() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<NeighborhoodRankingPanel
|
||||||
|
scores={scores.data ?? []}
|
||||||
|
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||||
|
selectedAreaId={selectedAreaId}
|
||||||
|
loading={isLoading}
|
||||||
|
creating={createMutation.isPending}
|
||||||
|
onSelectArea={setSelectedAreaId}
|
||||||
|
onCreate={(payload) => createMutation.mutate(payload)}
|
||||||
|
/>
|
||||||
|
|
||||||
<WatchlistPanel
|
<WatchlistPanel
|
||||||
scores={scores.data ?? []}
|
scores={scores.data ?? []}
|
||||||
|
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||||
items={watchlist.data ?? []}
|
items={watchlist.data ?? []}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
creating={createMutation.isPending}
|
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({
|
function WatchlistPanel({
|
||||||
scores,
|
scores,
|
||||||
|
neighborhoodScores,
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
creating,
|
creating,
|
||||||
@@ -213,11 +366,12 @@ function WatchlistPanel({
|
|||||||
onArchive,
|
onArchive,
|
||||||
}: {
|
}: {
|
||||||
scores: AreaScore[];
|
scores: AreaScore[];
|
||||||
|
neighborhoodScores: NeighborhoodScore[];
|
||||||
items: WatchlistItem[];
|
items: WatchlistItem[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
archivingId: number | null;
|
archivingId: number | null;
|
||||||
onCreate: (payload: { area_id: string; target_price_psm?: number; notes?: string }) => void;
|
onCreate: (payload: CreateWatchlistItem) => void;
|
||||||
onArchive: (id: number) => void;
|
onArchive: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [areaId, setAreaId] = useState("");
|
const [areaId, setAreaId] = useState("");
|
||||||
@@ -227,6 +381,14 @@ function WatchlistPanel({
|
|||||||
const areaNameById = useMemo(() => {
|
const areaNameById = useMemo(() => {
|
||||||
return new Map(scores.map((score) => [score.area_id, score.name]));
|
return new Map(scores.map((score) => [score.area_id, score.name]));
|
||||||
}, [scores]);
|
}, [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>) {
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -283,6 +445,7 @@ function WatchlistPanel({
|
|||||||
<WatchlistTable
|
<WatchlistTable
|
||||||
items={items}
|
items={items}
|
||||||
areaNameById={areaNameById}
|
areaNameById={areaNameById}
|
||||||
|
neighborhoodNameById={neighborhoodNameById}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
archivingId={archivingId}
|
archivingId={archivingId}
|
||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
@@ -295,12 +458,14 @@ function WatchlistPanel({
|
|||||||
function WatchlistTable({
|
function WatchlistTable({
|
||||||
items,
|
items,
|
||||||
areaNameById,
|
areaNameById,
|
||||||
|
neighborhoodNameById,
|
||||||
loading,
|
loading,
|
||||||
archivingId,
|
archivingId,
|
||||||
onArchive,
|
onArchive,
|
||||||
}: {
|
}: {
|
||||||
items: WatchlistItem[];
|
items: WatchlistItem[];
|
||||||
areaNameById: Map<string, string>;
|
areaNameById: Map<string, string>;
|
||||||
|
neighborhoodNameById: Map<string, string>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
archivingId: number | null;
|
archivingId: number | null;
|
||||||
onArchive: (id: number) => void;
|
onArchive: (id: number) => void;
|
||||||
@@ -328,7 +493,11 @@ function WatchlistTable({
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<TableRow key={item.watchlist_item_id}>
|
<TableRow key={item.watchlist_item_id}>
|
||||||
<TableCell className="font-medium">
|
<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>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{item.target_price_psm ? formatPrice(item.target_price_psm) : "-"}
|
{item.target_price_psm ? formatPrice(item.target_price_psm) : "-"}
|
||||||
|
|||||||
@@ -44,6 +44,31 @@ export type MarketOverview = {
|
|||||||
highest_supply_risk_area: AreaScoreSummary | null;
|
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 = {
|
export type WatchlistItem = {
|
||||||
watchlist_item_id: number;
|
watchlist_item_id: number;
|
||||||
neighborhood_id: string | null;
|
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)}`);
|
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[]> {
|
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
|
||||||
return fetchJson("/api/v1/watchlist");
|
return fetchJson("/api/v1/watchlist");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user