feat: add similar neighborhood analysis
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user