feat: add similar neighborhood analysis

This commit is contained in:
2026-06-24 16:20:36 +08:00
parent e6f26152d1
commit b559df50d8
6 changed files with 474 additions and 11 deletions

View File

@@ -75,6 +75,7 @@ import type {
NeighborhoodDetail,
NeighborhoodScore,
RawArtifact,
SimilarNeighborhood,
UpdateWatchlistItem,
WatchlistEvent,
WatchlistItem,
@@ -475,6 +476,7 @@ export function MarketDashboard() {
loading={neighborhoodDetail.isLoading}
creating={createMutation.isPending}
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
onSelectNeighborhood={setDetailNeighborhoodId}
/>
<ComparisonWorkspace
@@ -1412,11 +1414,13 @@ function NeighborhoodDetailPanel({
loading,
creating,
onCreateWatchlist,
onSelectNeighborhood,
}: {
detail: NeighborhoodDetail | 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)]" />;
@@ -1476,7 +1480,7 @@ function NeighborhoodDetailPanel({
? `${formatPrice(lowerWatchPrice)} - ${formatPrice(upperWatchPrice)}`
: "-"
}
detail="占位"
detail="当前价 92%-96%"
/>
<InsightRow
label="建成年份"
@@ -1502,6 +1506,10 @@ function NeighborhoodDetailPanel({
<NeighborhoodMetricHistoryTable metrics={detail.monthly_metrics} />
<NeighborhoodProfileTable detail={detail} />
</div>
<SimilarNeighborhoodTable
items={detail.similar_neighborhoods}
onSelectNeighborhood={onSelectNeighborhood}
/>
</div>
</CardContent>
</Card>
@@ -1687,6 +1695,94 @@ function NeighborhoodProfileTable({ detail }: { detail: NeighborhoodDetail }) {
);
}
function SimilarNeighborhoodTable({
items,
onSelectNeighborhood,
}: {
items: SimilarNeighborhood[];
onSelectNeighborhood: (neighborhoodId: string) => void;
}) {
if (items.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></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="w-12 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.neighborhood_id}>
<TableCell className="min-w-40">
<span className="block font-medium text-slate-950">{item.name}</span>
<span className="block text-xs text-[var(--muted-foreground)]">
{item.property_type} · {item.built_year ?? "-"}
</span>
</TableCell>
<TableCell className="min-w-32">
<span className="block">{item.area_name}</span>
<span className="block text-xs text-[var(--muted-foreground)]">
{item.district}
</span>
</TableCell>
<TableCell className="text-right font-medium">
{formatNumber(item.similarity_score)}
</TableCell>
<TableCell className="text-right">
<Badge variant={relativePriceVariant(item.relative_price_pct)}>
{formatRelativePrice(item.relative_price_pct)}
</Badge>
</TableCell>
<TableCell className="text-right">{formatPrice(item.transaction_price_psm)}</TableCell>
<TableCell className="text-right">
<span className="block font-medium">{formatNumber(item.investment_score)}</span>
<span className="block text-xs text-[var(--muted-foreground)]">
{item.recommendation}
</span>
</TableCell>
<TableCell className="min-w-64">
<div className="flex flex-wrap gap-1">
{item.reasons.slice(0, 5).map((reason) => (
<Badge key={reason} variant="muted" className="h-5">
{formatSimilarityReason(reason)}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onSelectNeighborhood(item.neighborhood_id)}
aria-label={`查看${item.name}`}
title="查看小区详情"
>
<MapPinned className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function ComparisonWorkspace({
areaScores,
neighborhoodScores,
@@ -2006,6 +2102,47 @@ function formatSignedNumber(value: number) {
return `${sign}${formatNumber(value)}`;
}
function formatRelativePrice(value: number) {
if (value === 0) {
return "持平";
}
const label = value > 0 ? "溢价" : "折价";
return `${label} ${formatSignedNumber(value)}%`;
}
function relativePriceVariant(value: number): "success" | "warning" | "muted" {
if (value <= -3) {
return "success";
}
if (value >= 3) {
return "warning";
}
return "muted";
}
function formatSimilarityReason(reason: string) {
if (reason === "property_type_match") {
return "物业类型一致";
}
if (reason === "same_area") {
return "同板块";
}
if (reason === "same_district") {
return "同行政区";
}
const [key, rawValue] = reason.split(":");
if (key === "price_gap_pct" && rawValue) {
return `价差 ${rawValue}%`;
}
if (key === "building_age_gap_years" && rawValue) {
return `楼龄差 ${rawValue}`;
}
if (key === "metro_gap_m" && rawValue) {
return `地铁差 ${rawValue}`;
}
return reason;
}
function WatchlistPanel({
scores,
neighborhoodScores,

View File

@@ -366,9 +366,55 @@ export type NeighborhoodDetail = {
profile: Neighborhood;
current_score: NeighborhoodScore | null;
monthly_metrics: NeighborhoodMonthlyMetric[];
similar_neighborhoods: SimilarNeighborhood[];
watchlist_items: WatchlistItem[];
};
export type SimilarNeighborhoodCandidate = {
neighborhood_id: string;
area_id: string;
name: string;
area_name: string;
district: string;
built_year: number | null;
property_type: string;
metro_distance_m: number | null;
school_quality: string | null;
month: string;
investment_score: number;
recommendation: string;
transaction_price_psm: number;
annual_rent_yield_pct: number;
liquidity_score: number;
location_score: number;
building_age_score: number;
};
export type SimilarNeighborhood = {
neighborhood_id: string;
area_id: string;
name: string;
area_name: string;
district: string;
built_year: number | null;
property_type: string;
metro_distance_m: number | null;
school_quality: string | null;
investment_score: number;
recommendation: string;
transaction_price_psm: number;
annual_rent_yield_pct: number;
similarity_score: number;
relative_price_pct: number;
reasons: string[];
};
export type SimilarNeighborhoodResponse = {
target: SimilarNeighborhoodCandidate;
month: string;
items: SimilarNeighborhood[];
};
export type ComparisonMetricValue = {
id: string;
value: number | null;
@@ -444,6 +490,16 @@ export async function fetchNeighborhoodDetail(
);
}
export async function fetchSimilarNeighborhoods(
neighborhoodId: string,
month: string,
limit = 8,
): Promise<SimilarNeighborhoodResponse> {
return fetchJson(
`/api/v1/neighborhoods/${encodeURIComponent(neighborhoodId)}/similar?month=${encodeURIComponent(month)}&limit=${limit}`,
);
}
export async function fetchAreaComparison(
areaIds: string[],
month: string,