"use client"; import { useQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, AlertTriangle, Archive, Check, MapPinned, Plus, RefreshCw, TrendingUp, } from "lucide-react"; import { Fragment, useEffect, useMemo, useState } from "react"; import { Bar, BarChart, CartesianGrid, Line, LineChart, PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { archiveWatchlistItem, createDataSource, createIngestionRun, createRawArtifact, createAreaScoreModelRun, createWatchlistEvent, createWatchlistItem, fetchDataSources, fetchAreaScores, fetchAreaComparison, fetchAreaDetail, fetchAreaScoreRunDiff, fetchIngestionRuns, fetchMarketOverview, fetchModelRuns, fetchNeighborhoodComparison, fetchNeighborhoodDetail, fetchNeighborhoodScores, fetchRawArtifacts, fetchWatchlistEvents, fetchWatchlistItems, finishIngestionRun, updateWatchlistItem, } from "@/lib/api"; import type { AreaScore, AreaComparison, AreaDetail, AreaScoreRunDiff, ComparisonMetric, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateWatchlistEvent, CreateWatchlistItem, DataSource, IngestionRun, ModelRun, NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodScore, RawArtifact, SimilarNeighborhood, UpdateWatchlistItem, WatchlistEvent, WatchlistItem, } from "@/lib/api"; import { formatNumber, formatPrice } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; const MONTH = "2026-05"; export function MarketDashboard() { const queryClient = useQueryClient(); const [selectedAreaId, setSelectedAreaId] = useState(""); const [detailAreaId, setDetailAreaId] = useState("qiantan"); const [detailNeighborhoodId, setDetailNeighborhoodId] = useState(""); const [comparisonAreaIds, setComparisonAreaIds] = useState([]); const [comparisonNeighborhoodIds, setComparisonNeighborhoodIds] = useState([]); const [watchlistStatus, setWatchlistStatus] = useState("active"); const [watchlistAreaId, setWatchlistAreaId] = useState(""); const [watchlistLabel, setWatchlistLabel] = useState(""); const [expandedWatchlistItemId, setExpandedWatchlistItemId] = useState(null); const [baseModelRunId, setBaseModelRunId] = useState(null); const [candidateModelRunId, setCandidateModelRunId] = useState(null); const overview = useQuery({ queryKey: ["market-overview", MONTH], queryFn: () => fetchMarketOverview(MONTH), }); const scores = useQuery({ queryKey: ["area-scores", MONTH], queryFn: () => fetchAreaScores(MONTH), }); const areaDetail = useQuery({ queryKey: ["area-detail", detailAreaId, MONTH], queryFn: () => fetchAreaDetail(detailAreaId, MONTH), enabled: Boolean(detailAreaId), }); const neighborhoodScores = useQuery({ queryKey: ["neighborhood-scores", MONTH], queryFn: () => fetchNeighborhoodScores(MONTH), }); const neighborhoodDetail = useQuery({ queryKey: ["neighborhood-detail", detailNeighborhoodId, MONTH], queryFn: () => fetchNeighborhoodDetail(detailNeighborhoodId, MONTH), enabled: Boolean(detailNeighborhoodId), }); const areaComparison = useQuery({ queryKey: ["area-comparison", comparisonAreaIds, MONTH], queryFn: () => fetchAreaComparison(comparisonAreaIds, MONTH), enabled: comparisonAreaIds.length >= 2, }); const neighborhoodComparison = useQuery({ queryKey: ["neighborhood-comparison", comparisonNeighborhoodIds, MONTH], queryFn: () => fetchNeighborhoodComparison(comparisonNeighborhoodIds, MONTH), enabled: comparisonNeighborhoodIds.length >= 2, }); const watchlist = useQuery({ queryKey: ["watchlist", watchlistStatus, watchlistAreaId, watchlistLabel], queryFn: () => fetchWatchlistItems({ status: watchlistStatus, area_id: watchlistAreaId, label: watchlistLabel, }), }); const watchlistEvents = useQuery({ queryKey: ["watchlist-events", expandedWatchlistItemId], queryFn: () => fetchWatchlistEvents(expandedWatchlistItemId ?? 0), enabled: expandedWatchlistItemId !== null, }); const modelRuns = useQuery({ queryKey: ["model-runs", "area_scores"], queryFn: () => fetchModelRuns({ model_name: "area_scores" }), }); const areaScoreRunDiff = useQuery({ queryKey: ["area-score-run-diff", baseModelRunId, candidateModelRunId], queryFn: () => fetchAreaScoreRunDiff(baseModelRunId ?? 0, candidateModelRunId ?? 0), enabled: baseModelRunId !== null && candidateModelRunId !== null && baseModelRunId !== candidateModelRunId, }); const dataSources = useQuery({ queryKey: ["data-sources"], queryFn: fetchDataSources, }); const ingestionRuns = useQuery({ queryKey: ["ingestion-runs"], queryFn: fetchIngestionRuns, }); const rawArtifacts = useQuery({ queryKey: ["raw-artifacts"], queryFn: fetchRawArtifacts, }); const createMutation = useMutation({ mutationFn: createWatchlistItem, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["watchlist"] }), queryClient.invalidateQueries({ queryKey: ["neighborhood-detail"] }), ]); }, }); const archiveMutation = useMutation({ mutationFn: archiveWatchlistItem, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["watchlist"] }); }, }); const updateWatchlistMutation = useMutation({ mutationFn: ({ id, payload }: { id: number; payload: UpdateWatchlistItem }) => updateWatchlistItem(id, payload), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["watchlist"] }), queryClient.invalidateQueries({ queryKey: ["watchlist-events"] }), queryClient.invalidateQueries({ queryKey: ["neighborhood-detail"] }), ]); }, }); const createWatchlistEventMutation = useMutation({ mutationFn: ({ id, payload }: { id: number; payload: CreateWatchlistEvent }) => createWatchlistEvent(id, payload), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["watchlist-events"] }); }, }); const createAreaScoreModelRunMutation = useMutation({ mutationFn: createAreaScoreModelRun, onSuccess: async (response) => { setCandidateModelRunId(response.model_run.model_run_id); if (baseModelRunId === null && modelRuns.data?.[0]) { setBaseModelRunId(modelRuns.data[0].model_run_id); } await Promise.all([ queryClient.invalidateQueries({ queryKey: ["model-runs"] }), queryClient.invalidateQueries({ queryKey: ["area-scores"] }), queryClient.invalidateQueries({ queryKey: ["market-overview"] }), queryClient.invalidateQueries({ queryKey: ["area-detail"] }), queryClient.invalidateQueries({ queryKey: ["area-score-run-diff"] }), ]); }, }); const createDataSourceMutation = useMutation({ mutationFn: createDataSource, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["data-sources"] }); }, }); const createRunMutation = useMutation({ mutationFn: createIngestionRun, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] }); }, }); const createRawArtifactMutation = useMutation({ mutationFn: createRawArtifact, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["raw-artifacts"] }), queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] }), ]); }, }); const finishRunMutation = useMutation({ mutationFn: ({ id, rowCount }: { id: number; rowCount?: number }) => finishIngestionRun(id, { status: "succeeded", row_count: rowCount }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] }); }, }); useEffect(() => { if (!detailNeighborhoodId && neighborhoodScores.data?.[0]) { setDetailNeighborhoodId(neighborhoodScores.data[0].neighborhood_id); } }, [detailNeighborhoodId, neighborhoodScores.data]); useEffect(() => { if (comparisonAreaIds.length === 0 && scores.data && scores.data.length >= 2) { setComparisonAreaIds(scores.data.slice(0, 3).map((score) => score.area_id)); } }, [comparisonAreaIds.length, scores.data]); useEffect(() => { if ( comparisonNeighborhoodIds.length === 0 && neighborhoodScores.data && neighborhoodScores.data.length >= 2 ) { setComparisonNeighborhoodIds( neighborhoodScores.data.slice(0, 3).map((score) => score.neighborhood_id), ); } }, [comparisonNeighborhoodIds.length, neighborhoodScores.data]); useEffect(() => { if (modelRuns.data && modelRuns.data.length >= 2) { if (candidateModelRunId === null) { setCandidateModelRunId(modelRuns.data[0].model_run_id); } if (baseModelRunId === null) { setBaseModelRunId(modelRuns.data[1].model_run_id); } } }, [baseModelRunId, candidateModelRunId, modelRuns.data]); const isLoading = overview.isLoading || scores.isLoading || areaDetail.isLoading || neighborhoodDetail.isLoading || areaComparison.isLoading || neighborhoodComparison.isLoading || neighborhoodScores.isLoading || watchlist.isLoading || watchlistEvents.isLoading || modelRuns.isLoading || areaScoreRunDiff.isLoading || dataSources.isLoading || ingestionRuns.isLoading; const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading; const hasError = overview.isError || scores.isError || areaDetail.isError || neighborhoodDetail.isError || areaComparison.isError || neighborhoodComparison.isError || neighborhoodScores.isError || watchlist.isError || watchlistEvents.isError || modelRuns.isError || areaScoreRunDiff.isError || dataSources.isError || ingestionRuns.isError || rawArtifacts.isError; return (

上海房市投资研究系统

市场总览

{MONTH}
{hasError ? : null}
} loading={isLoading} /> } loading={isLoading} /> } loading={isLoading} /> } loading={isLoading} tone="warning" />
板块投资观察评分 {overview.data?.top_area ? ( 首位:{overview.data.top_area.name} ) : null} 核心结论
板块明细 createMutation.mutate(payload)} creating={createMutation.isPending} onSelectNeighborhood={setDetailNeighborhoodId} /> createMutation.mutate(payload)} /> createMutation.mutate(payload)} onSelectNeighborhood={setDetailNeighborhoodId} /> setExpandedWatchlistItemId((current) => (current === id ? null : id)) } onCreate={(payload) => createMutation.mutate(payload)} onUpdate={(id, payload) => updateWatchlistMutation.mutate({ id, payload })} onCreateEvent={(id, payload) => createWatchlistEventMutation.mutate({ id, payload })} onArchive={(id) => archiveMutation.mutate(id)} /> createAreaScoreModelRunMutation.mutate(payload)} /> createDataSourceMutation.mutate(payload)} onCreateRun={(payload) => createRunMutation.mutate(payload)} onCreateArtifact={(payload) => createRawArtifactMutation.mutate(payload)} onFinishRun={(id, rowCount) => finishRunMutation.mutate({ id, rowCount })} />
); } function ImportCenterPanel({ sources, runs, artifacts, loading, importLoading, creatingSource, creatingRun, creatingArtifact, finishingRunId, onCreateSource, onCreateRun, onCreateArtifact, onFinishRun, }: { sources: DataSource[]; runs: IngestionRun[]; artifacts: RawArtifact[]; loading: boolean; importLoading: boolean; creatingSource: boolean; creatingRun: boolean; creatingArtifact: boolean; finishingRunId: number | null; onCreateSource: (payload: CreateDataSource) => void; onCreateRun: (payload: CreateIngestionRun) => void; onCreateArtifact: (payload: CreateRawArtifact) => void; onFinishRun: (id: number, rowCount?: number) => void; }) { const [sourceName, setSourceName] = useState(""); const [sourceType, setSourceType] = useState("manual"); const [sourceUrl, setSourceUrl] = useState(""); const [sourceId, setSourceId] = useState(""); const [rawUri, setRawUri] = useState(""); const [rowCount, setRowCount] = useState(""); const [artifactRunId, setArtifactRunId] = useState(""); const [artifactSourceId, setArtifactSourceId] = useState(""); const [artifactFilename, setArtifactFilename] = useState(""); const [artifactRawUri, setArtifactRawUri] = useState(""); const [artifactSha256, setArtifactSha256] = useState(""); const [artifactSizeBytes, setArtifactSizeBytes] = useState(""); function submitSource(event: React.FormEvent) { event.preventDefault(); if (!sourceName.trim()) { return; } onCreateSource({ name: sourceName.trim(), source_type: sourceType, url: sourceUrl.trim() || undefined, cadence: "ad hoc", reliability: sourceType === "official" ? "high" : "medium", notes: "front-end registered source", }); setSourceName(""); setSourceUrl(""); } function submitRun(event: React.FormEvent) { event.preventDefault(); const parsedSourceId = Number(sourceId); if (!Number.isFinite(parsedSourceId) || parsedSourceId <= 0) { return; } onCreateRun({ source_id: parsedSourceId, raw_uri: rawUri.trim() || undefined, status: "running", }); setRawUri(""); } function submitArtifact(event: React.FormEvent) { event.preventDefault(); const parsedRunId = Number(artifactRunId); const parsedSourceId = Number(artifactSourceId); const parsedSizeBytes = Number(artifactSizeBytes); if ( !artifactFilename.trim() || !artifactRawUri.trim() || artifactSha256.trim().length !== 64 || !Number.isFinite(parsedSizeBytes) || parsedSizeBytes < 0 ) { return; } onCreateArtifact({ run_id: Number.isFinite(parsedRunId) && parsedRunId > 0 ? parsedRunId : undefined, source_id: Number.isFinite(parsedSourceId) && parsedSourceId > 0 ? parsedSourceId : undefined, original_filename: artifactFilename.trim(), raw_uri: artifactRawUri.trim(), sha256: artifactSha256.trim(), size_bytes: parsedSizeBytes, uploaded_by: "dashboard", }); setArtifactFilename(""); setArtifactRawUri(""); setArtifactSha256(""); setArtifactSizeBytes(""); } return (
导入中心

数据源登记与导入批次审计

setSourceName(event.target.value)} /> setSourceUrl(event.target.value)} />
setRawUri(event.target.value)} />
{ const parsedRowCount = Number(rowCount); onFinishRun(id, Number.isFinite(parsedRowCount) && parsedRowCount >= 0 ? parsedRowCount : undefined); setRowCount(""); }} />
setArtifactFilename(event.target.value)} /> setArtifactRawUri(event.target.value)} /> setArtifactSha256(event.target.value)} /> setArtifactSizeBytes(event.target.value)} />
); } function DataSourceTable({ sources, loading }: { sources: DataSource[]; loading: boolean }) { if (loading) { return
; } return (
数据源 类型 可靠性 {sources.map((source) => ( {source.name} {source.source_type} {source.reliability} ))}
); } function IngestionRunTable({ runs, loading, rowCount, onRowCountChange, finishingRunId, onFinishRun, }: { runs: IngestionRun[]; loading: boolean; rowCount: string; onRowCountChange: (value: string) => void; finishingRunId: number | null; onFinishRun: (id: number) => void; }) { if (loading) { return
; } return (
批次 数据源 状态 raw 路径 行数 {runs.map((run) => ( #{run.run_id} {run.source_name ?? "-"} {run.status} {run.raw_uri ?? "-"} {run.row_count ?? "-"} {run.status === "running" ? (
onRowCountChange(event.target.value)} />
) : null}
))}
); } function RawArtifactTable({ artifacts, loading, }: { artifacts: RawArtifact[]; loading: boolean; }) { if (loading) { return
; } return (
原始文件 数据源 批次 SHA-256 大小 {artifacts.map((artifact) => ( {artifact.original_filename} {artifact.source_name ?? "-"} {artifact.run_id ? `#${artifact.run_id}` : "-"} {artifact.sha256} {formatNumber(artifact.size_bytes)} ))}
); } function AreaDetailPanel({ detail, loading, creating, onCreateWatchlist, onSelectNeighborhood, }: { detail: AreaDetail | null; loading: boolean; creating: boolean; onCreateWatchlist: (payload: CreateWatchlistItem) => void; onSelectNeighborhood: (neighborhoodId: string) => void; }) { if (loading) { return
; } if (!detail) { return null; } const score = detail.current_score; const lineage = detail.score_lineage; const latestMetric = detail.monthly_metrics[0]; return (
{detail.profile.name}板块详情

{detail.profile.district} · {detail.profile.segment}

{score ? {score.recommendation} : null}
{lineage ? (
SHA-256:{lineage.sha256 ?? "-"}
) : null}
); } function AreaDiagnosticsPanel({ diagnostics }: { diagnostics: AreaDetail["diagnostics"] }) { if (!diagnostics) { return (
暂无历史分位
); } return (

历史分位与异常

0 ? "warning" : "success"}> {diagnostics.anomaly_count} 项异常
{diagnostics.metrics.map((metric) => (
{metric.label} {metric.severity}

{formatMetricValue(metric.current_value, metric.unit)}

P{formatNumber(metric.percentile, 1)} {formatMetricValue(metric.historical_min, metric.unit)}- {formatMetricValue(metric.historical_max, metric.unit)}
))}
); } function AreaMetricTrendChart({ metrics }: { metrics: AreaDetail["monthly_metrics"] }) { const data = [...metrics].reverse(); if (data.length === 0) { return (
暂无月度指标
); } return ( { if (name === "transaction_price_psm") { return [formatPrice(Number(value)), "成交均价/㎡"]; } return [formatNumber(Number(value)), "成交套数"]; }} /> ); } function AreaMetricHistoryTable({ metrics }: { metrics: AreaDetail["monthly_metrics"] }) { if (metrics.length === 0) { return (
暂无月度指标
); } return (
月份 成交 均价/㎡ 挂牌 {metrics.slice(0, 6).map((metric) => ( {metric.month} {metric.transaction_count} {formatPrice(metric.transaction_price_psm)} {metric.listing_count} ))}
); } function AreaNeighborhoodTable({ scores, creating, onCreate, onSelectNeighborhood, }: { scores: NeighborhoodScore[]; creating: boolean; onCreate: (payload: CreateWatchlistItem) => void; onSelectNeighborhood: (neighborhoodId: string) => void; }) { if (scores.length === 0) { return (
暂无小区评分
); } return (
小区 分数 均价/㎡ {scores.slice(0, 6).map((score) => ( onSelectNeighborhood(score.neighborhood_id)} > {score.name} {formatNumber(score.investment_score)} {formatPrice(score.transaction_price_psm)} ))}
); } function NeighborhoodRankingPanel({ scores, neighborhoodScores, selectedAreaId, selectedNeighborhoodId, loading, creating, onSelectArea, onSelectNeighborhood, onCreate, }: { scores: AreaScore[]; neighborhoodScores: NeighborhoodScore[]; selectedAreaId: string; selectedNeighborhoodId: string; loading: boolean; creating: boolean; onSelectArea: (areaId: string) => void; onSelectNeighborhood: (neighborhoodId: string) => void; onCreate: (payload: CreateWatchlistItem) => void; }) { const filteredScores = selectedAreaId ? neighborhoodScores.filter((score) => score.area_id === selectedAreaId) : neighborhoodScores; const topNeighborhood = filteredScores[0]; return (
小区排行 {topNeighborhood ? 首位:{topNeighborhood.name} : null}
); } function NeighborhoodScoreTable({ scores, loading, creating, selectedNeighborhoodId, onSelectNeighborhood, onCreate, }: { scores: NeighborhoodScore[]; loading: boolean; creating: boolean; selectedNeighborhoodId: string; onSelectNeighborhood: (neighborhoodId: string) => void; onCreate: (payload: CreateWatchlistItem) => void; }) { if (loading) { return
; } if (scores.length === 0) { return
暂无小区评分
; } return ( 小区 板块 综合分 建议 成交均价/㎡ 租金收益率 流动性 位置 {scores.map((score) => ( onSelectNeighborhood(score.neighborhood_id)} > {score.name} {score.area_name} {formatNumber(score.investment_score)} {score.recommendation} {formatPrice(score.transaction_price_psm)} {formatNumber(score.annual_rent_yield_pct, 2)}% {formatNumber(score.liquidity_score)} {formatNumber(score.location_score)} ))}
); } function NeighborhoodDetailPanel({ detail, loading, creating, onCreateWatchlist, onSelectNeighborhood, }: { detail: NeighborhoodDetail | null; loading: boolean; creating: boolean; onCreateWatchlist: (payload: CreateWatchlistItem) => void; onSelectNeighborhood: (neighborhoodId: string) => void; }) { if (loading) { return
; } if (!detail) { return null; } const score = detail.current_score; const latestMetric = detail.monthly_metrics[0]; const referencePrice = score?.transaction_price_psm ?? latestMetric?.transaction_price_psm ?? 0; const lowerWatchPrice = referencePrice > 0 ? Math.round(referencePrice * 0.92) : null; const upperWatchPrice = referencePrice > 0 ? Math.round(referencePrice * 0.96) : null; const activeWatchlistItem = detail.watchlist_items[0]; return (
{detail.profile.name}小区详情

{detail.profile.district} · {detail.profile.area_name} · {detail.profile.property_type}

{score ? {score.recommendation} : null} {activeWatchlistItem ? {activeWatchlistItem.status} : null}
); } function NeighborhoodMetricTrendChart({ metrics, }: { metrics: NeighborhoodDetail["monthly_metrics"]; }) { const data = [...metrics].reverse(); if (data.length === 0) { return (
暂无月度指标
); } return ( { if (name === "transaction_price_psm") { return [formatPrice(Number(value)), "成交均价/㎡"]; } if (name === "listing_price_psm") { return [formatPrice(Number(value)), "挂牌均价/㎡"]; } return [formatNumber(Number(value)), "成交套数"]; }} /> ); } function NeighborhoodScoreBreakdown({ score }: { score: NeighborhoodScore | null }) { const items = score ? [ ["流动性", score.liquidity_score], ["租金支撑", score.rent_support_score], ["价格安全", score.price_safety_score], ["位置", score.location_score], ["楼龄", score.building_age_score], ] : []; if (!score) { return (
暂无评分拆解
); } return (

评分拆解

{items.map(([label, value]) => (
{label} {formatNumber(Number(value))}
))}
); } function NeighborhoodMetricHistoryTable({ metrics, }: { metrics: NeighborhoodDetail["monthly_metrics"]; }) { if (metrics.length === 0) { return (
暂无月度指标
); } return (
月份 成交 成交均价/㎡ 可售 {metrics.slice(0, 6).map((metric) => ( {metric.month} {metric.transaction_count} {formatPrice(metric.transaction_price_psm)} {metric.available_units} ))}
); } function NeighborhoodProfileTable({ detail }: { detail: NeighborhoodDetail }) { const latestMetric = detail.monthly_metrics[0]; const activeWatchlistItem = detail.watchlist_items[0]; const rows = [ ["挂牌均价/㎡", latestMetric ? formatPrice(latestMetric.listing_price_psm) : "-"], ["租金/㎡", latestMetric ? formatPrice(latestMetric.rent_price_psm) : "-"], ["去化天数", latestMetric ? formatNumber(latestMetric.median_days_on_market) : "-"], ["数据批次", latestMetric?.ingestion_run_id ? `#${latestMetric.ingestion_run_id}` : "-"], ["原始文件", latestMetric?.raw_artifact_id ? `#${latestMetric.raw_artifact_id}` : "-"], [ "观察池目标/㎡", activeWatchlistItem?.target_price_psm ? formatPrice(activeWatchlistItem.target_price_psm) : "-", ], ]; return (
{rows.map(([label, value]) => ( {label} {value} ))}
); } function SimilarNeighborhoodTable({ items, onSelectNeighborhood, }: { items: SimilarNeighborhood[]; onSelectNeighborhood: (neighborhoodId: string) => void; }) { if (items.length === 0) { return (
暂无相似资产
); } return (
相似资产 板块 相似度 相对价格 成交均价/㎡ 综合分 匹配原因 查看 {items.map((item) => ( {item.name} {item.property_type} · {item.built_year ?? "-"} {item.area_name} {item.district} {formatNumber(item.similarity_score)} {formatRelativePrice(item.relative_price_pct)} {formatPrice(item.transaction_price_psm)} {formatNumber(item.investment_score)} {item.recommendation}
{item.reasons.slice(0, 5).map((reason) => ( {formatSimilarityReason(reason)} ))}
))}
); } function ComparisonWorkspace({ areaScores, neighborhoodScores, areaComparison, neighborhoodComparison, selectedAreaIds, selectedNeighborhoodIds, loading, onAreaIdsChange, onNeighborhoodIdsChange, }: { areaScores: AreaScore[]; neighborhoodScores: NeighborhoodScore[]; areaComparison: AreaComparison | null; neighborhoodComparison: NeighborhoodComparison | null; selectedAreaIds: string[]; selectedNeighborhoodIds: string[]; loading: boolean; onAreaIdsChange: (ids: string[]) => void; onNeighborhoodIdsChange: (ids: string[]) => void; }) { return (
对比工作台

板块与小区并排比较

({ id: score.area_id, label: score.name, detail: score.district, }))} selectedIds={selectedAreaIds} onChange={onAreaIdsChange} /> ({ id: score.neighborhood_id, label: score.name, detail: score.area_name, }))} selectedIds={selectedNeighborhoodIds} onChange={onNeighborhoodIdsChange} />
{loading ?
: null} {!loading ? (
item.area_id} getName={(item) => item.name} getMeta={(item) => `${item.district} · ${item.segment}`} /> item.area_id} getName={(item) => item.name} /> item.neighborhood_id} getName={(item) => item.name} getMeta={(item) => `${item.area_name} · ${item.built_year ?? "-"} · ${item.property_type}` } /> item.neighborhood_id} getName={(item) => item.name} />
) : null} ); } function ComparisonSelector({ label, options, selectedIds, onChange, }: { label: string; options: Array<{ id: string; label: string; detail: string }>; selectedIds: string[]; onChange: (ids: string[]) => void; }) { function toggle(id: string) { if (selectedIds.includes(id)) { if (selectedIds.length > 2) { onChange(selectedIds.filter((selectedId) => selectedId !== id)); } return; } if (selectedIds.length < 4) { onChange([...selectedIds, id]); } } return (

{label}

{selectedIds.length}/4
{options.map((option) => { const selected = selectedIds.includes(option.id); const locked = selected && selectedIds.length <= 2; return ( ); })}
); } function ComparisonMatrix({ title, comparison, getId, getName, getMeta, }: { title: string; comparison: { items: T[]; metrics: ComparisonMetric[]; missing_ids: string[] } | null; getId: (item: T) => string; getName: (item: T) => string; getMeta: (item: T) => string; }) { if (!comparison || comparison.items.length === 0) { return (
暂无对比数据
); } const nameById = new Map(comparison.items.map((item) => [getId(item), getName(item)])); const metaById = new Map(comparison.items.map((item) => [getId(item), getMeta(item)])); return (
{title} {comparison.items.map((item) => ( {getName(item)} {metaById.get(getId(item))} ))} {comparison.metrics.map((metric) => ( {metric.label} {metric.unit} {comparison.items.map((item) => { const id = getId(item); const value = metric.values.find((metricValue) => metricValue.id === id)?.value; return ( {formatMetricValue(value, metric.unit)} ); })} ))} {comparison.missing_ids.length > 0 ? ( 缺失 {comparison.missing_ids.map((id) => nameById.get(id) ?? id).join(", ")} ) : null}
); } function ComparisonRadar({ title, metrics, items, getId, getName, }: { title: string; metrics: ComparisonMetric[]; items: T[]; getId: (item: T) => string; getName: (item: T) => string; }) { const radarMetrics = metrics.filter( (metric) => metric.unit === "分" || metric.key === "annual_rent_yield_pct" || metric.key === "transaction_count", ); const data = radarMetrics.map((metric) => { const row: Record = { metric: metric.label }; for (const item of items) { const id = getId(item); const value = metric.values.find((metricValue) => metricValue.id === id)?.value ?? 0; row[id] = metric.direction === "lower" ? 100 - value : value; } return row; }); const colors = ["#0f766e", "#2563eb", "#b45309", "#be123c"]; if (data.length === 0 || items.length === 0) { return (
暂无雷达图
); } return (

{title}

[formatNumber(Number(value)), "标准化"]} /> {items.map((item, index) => ( ))}
); } function formatMetricValue(value: number | null | undefined, unit: string) { if (value === null || value === undefined) { return "-"; } if (unit === "元/㎡" || unit === "米") { return formatPrice(value); } if (unit === "%") { return `${formatNumber(value, 2)}%`; } if (unit === "套") { return formatNumber(value, 0); } return formatNumber(value); } function formatSignedNumber(value: number) { const sign = value > 0 ? "+" : ""; 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, items, events, loading, creating, archivingId, updatingId, creatingEvent, statusFilter, areaFilter, labelFilter, expandedItemId, onStatusFilterChange, onAreaFilterChange, onLabelFilterChange, onToggleEvents, onCreate, onUpdate, onCreateEvent, onArchive, }: { scores: AreaScore[]; neighborhoodScores: NeighborhoodScore[]; items: WatchlistItem[]; events: WatchlistEvent[]; loading: boolean; creating: boolean; archivingId: number | null; updatingId: number | null; creatingEvent: boolean; statusFilter: string; areaFilter: string; labelFilter: string; expandedItemId: number | null; onStatusFilterChange: (value: string) => void; onAreaFilterChange: (value: string) => void; onLabelFilterChange: (value: string) => void; onToggleEvents: (id: number) => void; onCreate: (payload: CreateWatchlistItem) => void; onUpdate: (id: number, payload: UpdateWatchlistItem) => void; onCreateEvent: (id: number, payload: CreateWatchlistEvent) => void; onArchive: (id: number) => void; }) { const [targetType, setTargetType] = useState<"area" | "neighborhood">("area"); const [areaId, setAreaId] = useState(""); const [neighborhoodId, setNeighborhoodId] = useState(""); const [targetPrice, setTargetPrice] = useState(""); const [triggerPrice, setTriggerPrice] = useState(""); const [triggerOperator, setTriggerOperator] = useState<"lte" | "gte">("lte"); const [label, setLabel] = useState("核心"); const [priority, setPriority] = useState<"low" | "medium" | "high">("medium"); const [notes, setNotes] = useState(""); 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) { event.preventDefault(); if (targetType === "area" && !areaId) { return; } if (targetType === "neighborhood" && !neighborhoodId) { return; } const parsedTarget = Number(targetPrice); const parsedTrigger = Number(triggerPrice); onCreate({ area_id: targetType === "area" ? areaId : undefined, neighborhood_id: targetType === "neighborhood" ? neighborhoodId : undefined, target_price_psm: Number.isFinite(parsedTarget) && parsedTarget > 0 ? parsedTarget : undefined, trigger_price_psm: Number.isFinite(parsedTrigger) && parsedTrigger > 0 ? parsedTrigger : undefined, trigger_operator: triggerOperator, label: label.trim() || undefined, priority, notes: notes.trim() || undefined, }); setAreaId(""); setNeighborhoodId(""); setTargetPrice(""); setTriggerPrice(""); setNotes(""); } return (
观察池
onLabelFilterChange(event.target.value)} />
setTargetPrice(event.target.value)} /> setTriggerPrice(event.target.value)} /> setLabel(event.target.value)} /> setNotes(event.target.value)} />
); } function WatchlistTable({ items, areaNameById, neighborhoodNameById, loading, archivingId, updatingId, expandedItemId, events, creatingEvent, onUpdate, onCreateEvent, onArchive, onToggleEvents, }: { items: WatchlistItem[]; areaNameById: Map; neighborhoodNameById: Map; loading: boolean; archivingId: number | null; updatingId: number | null; expandedItemId: number | null; events: WatchlistEvent[]; creatingEvent: boolean; onUpdate: (id: number, payload: UpdateWatchlistItem) => void; onCreateEvent: (id: number, payload: CreateWatchlistEvent) => void; onArchive: (id: number) => void; onToggleEvents: (id: number) => void; }) { const [eventSummary, setEventSummary] = useState(""); if (loading) { return
; } if (items.length === 0) { return
暂无观察标的
; } return ( 标的 目标价/㎡ 触发价/㎡ 标签 优先级 状态 复核 备注 {items.map((item) => { const targetName = item.neighborhood_id ? neighborhoodNameById.get(item.neighborhood_id) ?? item.neighborhood_id : item.area_id ? areaNameById.get(item.area_id) ?? item.area_id : "-"; const expanded = expandedItemId === item.watchlist_item_id; return ( {targetName} {item.target_price_psm ? formatPrice(item.target_price_psm) : "-"} {item.trigger_price_psm ? `${item.trigger_operator === "lte" ? "≤" : "≥"} ${formatPrice(item.trigger_price_psm)}` : "-"} {item.label} {item.priority} {item.last_reviewed_at ? item.last_reviewed_at.slice(0, 10) : "-"} {item.notes || "-"}
{expanded ? (
{events.length === 0 ? (

暂无历史

) : ( events.map((event) => (
{event.event_type} {event.created_at.slice(0, 16)}

{event.summary}

)) )}
{ event.preventDefault(); if (!eventSummary.trim()) { return; } onCreateEvent(item.watchlist_item_id, { event_type: "note", summary: eventSummary.trim(), }); setEventSummary(""); }} > setEventSummary(event.target.value)} />
) : null}
); })}
); } function ModelRunPanel({ runs, diff, baseRunId, candidateRunId, loading, creating, onBaseRunChange, onCandidateRunChange, onCreateRun, }: { runs: ModelRun[]; diff: AreaScoreRunDiff | null; baseRunId: number | null; candidateRunId: number | null; loading: boolean; creating: boolean; onBaseRunChange: (id: number | null) => void; onCandidateRunChange: (id: number | null) => void; onCreateRun: (payload: CreateAreaScoreModelRun) => void; }) { const [modelVersion, setModelVersion] = useState("area-score-rust-v1"); function submit(event: React.FormEvent) { event.preventDefault(); onCreateRun({ month: MONTH, model_version: modelVersion.trim() || undefined, }); } return (
模型运行中心

板块评分批次与版本差异

setModelVersion(event.target.value)} />
); } function ModelRunTable({ runs, loading }: { runs: ModelRun[]; loading: boolean }) { if (loading) { return
; } if (runs.length === 0) { return (
暂无模型运行
); } return (
批次 版本 月份 状态 行数 {runs.slice(0, 8).map((run) => ( #{run.model_run_id} {run.model_version} {run.target_month} {run.status} {run.row_count ?? "-"} ))}
); } function ModelRunSelect({ label, runs, value, onChange, }: { label: string; runs: ModelRun[]; value: number | null; onChange: (id: number | null) => void; }) { return ( ); } function AreaScoreRunDiffTable({ diff, loading, }: { diff: AreaScoreRunDiff | null; loading: boolean; }) { if (loading) { return
; } if (!diff || diff.items.length === 0) { return (
暂无评分差异
); } return (
板块 行政区 基准 候选 差异 建议变化 {diff.items.map((item) => ( {item.name} {item.district} {item.base_score === null ? "-" : formatNumber(item.base_score)} {item.candidate_score === null ? "-" : formatNumber(item.candidate_score)} {item.score_delta === null ? "-" : formatSignedNumber(item.score_delta)} {item.base_recommendation === item.candidate_recommendation ? ( {item.candidate_recommendation ?? "-"} ) : ( {item.base_recommendation ?? "-"} → {item.candidate_recommendation ?? "-"} )} ))}
); } function MetricCard({ title, value, textValue, suffix, icon, loading, tone = "default", }: { title: string; value?: number; textValue?: string; suffix?: string; icon: React.ReactNode; loading: boolean; tone?: "default" | "warning"; }) { return (

{title}

{loading ? "-" : textValue ?? `${formatNumber(value ?? 0)}${suffix ?? ""}`}

{icon}
); } function ScoreChart({ scores, loading }: { scores: AreaScore[]; loading: boolean }) { if (loading) { return
; } return ( [`${formatNumber(Number(value))} 分`, "综合分"]} labelStyle={{ color: "#172033" }} /> ); } function InsightRow({ label, value, detail }: { label: string; value: string; detail: string }) { return (

{label}

{value}

{detail}
); } function AreaScoreTable({ scores, loading, selectedAreaId, onSelectArea, }: { scores: AreaScore[]; loading: boolean; selectedAreaId: string; onSelectArea: (areaId: string) => void; }) { if (loading) { return
; } return ( 板块 行政区 综合分 建议 成交均价/㎡ 成交套数 租金收益率 供应风险 {scores.map((score) => ( onSelectArea(score.area_id)} > {score.name} {score.district} {formatNumber(score.investment_score)} {score.recommendation} {formatPrice(score.transaction_price_psm)} {score.transaction_count} {formatNumber(score.annual_rent_yield_pct, 2)}% {formatNumber(score.supply_risk_score)} ))}
); } function ErrorState() { return (
API 暂时不可用
); }