diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index cdf82d0..eb7efa2 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -5,14 +5,15 @@ use serde::Serialize; use crate::error::{ApiError, ApiResult}; use crate::import_templates::{execute_import_payload, validate_import_payload}; use crate::models::{ - AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, - AreaScoreModelRunResponse, AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource, + AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, + AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreSummary, CompareQuery, + ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun, - MonthQuery, Neighborhood, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, - NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, - WatchlistItem, + MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem, + NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore, + NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem, }; use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore}; use crate::state::AppState; @@ -133,6 +134,122 @@ pub async fn market_overview( })) } +pub async fn compare_areas( + State(state): State, + Query(query): Query, +) -> ApiResult> { + validate_month(&query.month)?; + let requested_ids = parse_compare_ids(&query.ids)?; + let scores = sqlx::query_as::<_, AreaScore>( + r#" + SELECT + area_id, + name, + district, + segment, + month, + investment_score, + recommendation, + liquidity_score, + momentum_score, + rent_support_score, + safety_margin_score, + credit_support_score, + supply_risk_score, + valuation_pressure_score, + transaction_count, + transaction_price_psm, + listing_count, + listing_price_psm, + rent_price_psm, + median_days_on_market, + annual_rent_yield_pct, + listing_pressure_ratio, + discount_pct, + price_momentum_pct, + volume_momentum_pct + FROM gold.area_scores + WHERE month = $1 + AND area_id = ANY($2) + "#, + ) + .bind(&query.month) + .bind(&requested_ids) + .fetch_all(&state.pool) + .await?; + + let items = sort_by_requested_order(scores, &requested_ids, |score| &score.area_id); + let missing_ids = missing_ids(&requested_ids, &items, |score| &score.area_id); + let metrics = area_comparison_metrics(&items); + + Ok(Json(AreaComparison { + month: query.month, + requested_ids, + missing_ids, + items, + metrics, + })) +} + +pub async fn compare_neighborhoods( + State(state): State, + Query(query): Query, +) -> ApiResult> { + validate_month(&query.month)?; + let requested_ids = parse_compare_ids(&query.ids)?; + let scores = sqlx::query_as::<_, NeighborhoodComparisonItem>( + r#" + SELECT + s.neighborhood_id, + s.area_id, + s.name, + s.area_name, + s.district, + n.built_year, + n.property_type, + n.metro_distance_m, + n.school_quality, + s.month, + s.investment_score, + s.recommendation, + s.liquidity_score, + s.rent_support_score, + s.price_safety_score, + s.location_score, + s.building_age_score, + s.transaction_count, + s.transaction_price_psm, + s.listing_count, + s.listing_price_psm, + s.rent_price_psm, + s.median_days_on_market, + s.annual_rent_yield_pct, + s.discount_pct, + s.available_units + FROM gold.neighborhood_scores s + JOIN silver.neighborhoods n ON n.neighborhood_id = s.neighborhood_id + WHERE s.month = $1 + AND s.neighborhood_id = ANY($2) + "#, + ) + .bind(&query.month) + .bind(&requested_ids) + .fetch_all(&state.pool) + .await?; + + let items = sort_by_requested_order(scores, &requested_ids, |score| &score.neighborhood_id); + let missing_ids = missing_ids(&requested_ids, &items, |score| &score.neighborhood_id); + let metrics = neighborhood_comparison_metrics(&items); + + Ok(Json(NeighborhoodComparison { + month: query.month, + requested_ids, + missing_ids, + items, + metrics, + })) +} + pub async fn create_area_score_model_run( State(state): State, Json(payload): Json, @@ -1377,6 +1494,231 @@ fn average(values: impl Iterator) -> f64 { } } +fn parse_compare_ids(ids: &str) -> ApiResult> { + let mut parsed = Vec::new(); + for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) { + if has_sensitive_text(id) || id.len() > 80 { + return Err(ApiError::BadRequest("invalid comparison id".to_string())); + } + if !parsed.iter().any(|existing| existing == id) { + parsed.push(id.to_string()); + } + } + + if !(2..=4).contains(&parsed.len()) { + return Err(ApiError::BadRequest( + "ids must include 2 to 4 unique values".to_string(), + )); + } + + Ok(parsed) +} + +fn sort_by_requested_order(mut items: Vec, requested_ids: &[String], id_fn: F) -> Vec +where + F: Fn(&T) -> &str, +{ + items.sort_by_key(|item| { + requested_ids + .iter() + .position(|id| id == id_fn(item)) + .unwrap_or(usize::MAX) + }); + items +} + +fn missing_ids(requested_ids: &[String], items: &[T], id_fn: F) -> Vec +where + F: Fn(&T) -> &str, +{ + requested_ids + .iter() + .filter(|id| !items.iter().any(|item| id_fn(item) == id.as_str())) + .cloned() + .collect() +} + +fn area_comparison_metrics(items: &[AreaScore]) -> Vec { + vec![ + comparison_metric( + "investment_score", + "综合分", + "分", + "higher", + items, + |item| Some(item.investment_score), + ), + comparison_metric( + "transaction_price_psm", + "成交均价/㎡", + "元/㎡", + "neutral", + items, + |item| Some(item.transaction_price_psm), + ), + comparison_metric( + "transaction_count", + "成交套数", + "套", + "higher", + items, + |item| Some(item.transaction_count as f64), + ), + comparison_metric( + "liquidity_score", + "流动性", + "分", + "higher", + items, + |item| Some(item.liquidity_score), + ), + comparison_metric( + "rent_support_score", + "租金支撑", + "分", + "higher", + items, + |item| Some(item.rent_support_score), + ), + comparison_metric( + "annual_rent_yield_pct", + "租金收益率", + "%", + "higher", + items, + |item| Some(item.annual_rent_yield_pct), + ), + comparison_metric( + "supply_risk_score", + "供应风险", + "分", + "lower", + items, + |item| Some(item.supply_risk_score), + ), + comparison_metric( + "valuation_pressure_score", + "估值压力", + "分", + "lower", + items, + |item| Some(item.valuation_pressure_score), + ), + ] +} + +fn neighborhood_comparison_metrics(items: &[NeighborhoodComparisonItem]) -> Vec { + vec![ + comparison_metric( + "investment_score", + "综合分", + "分", + "higher", + items, + |item| Some(item.investment_score), + ), + comparison_metric( + "transaction_price_psm", + "成交均价/㎡", + "元/㎡", + "neutral", + items, + |item| Some(item.transaction_price_psm), + ), + comparison_metric( + "transaction_count", + "成交套数", + "套", + "higher", + items, + |item| Some(item.transaction_count as f64), + ), + comparison_metric( + "liquidity_score", + "流动性", + "分", + "higher", + items, + |item| Some(item.liquidity_score), + ), + comparison_metric( + "rent_support_score", + "租金支撑", + "分", + "higher", + items, + |item| Some(item.rent_support_score), + ), + comparison_metric( + "building_age_score", + "楼龄", + "分", + "higher", + items, + |item| Some(item.building_age_score), + ), + comparison_metric( + "metro_distance_m", + "地铁距离", + "米", + "lower", + items, + |item| item.metro_distance_m.map(|value| value as f64), + ), + comparison_metric( + "available_units", + "可售套数", + "套", + "neutral", + items, + |item| Some(item.available_units as f64), + ), + ] +} + +fn comparison_metric( + key: &str, + label: &str, + unit: &str, + direction: &str, + items: &[T], + value_fn: F, +) -> ComparisonMetric +where + F: Fn(&T) -> Option, + T: ComparisonId, +{ + ComparisonMetric { + key: key.to_string(), + label: label.to_string(), + unit: unit.to_string(), + direction: direction.to_string(), + values: items + .iter() + .map(|item| ComparisonMetricValue { + id: item.comparison_id().to_string(), + value: value_fn(item), + }) + .collect(), + } +} + +trait ComparisonId { + fn comparison_id(&self) -> &str; +} + +impl ComparisonId for AreaScore { + fn comparison_id(&self) -> &str { + &self.area_id + } +} + +impl ComparisonId for NeighborhoodComparisonItem { + fn comparison_id(&self) -> &str { + &self.neighborhood_id + } +} + fn validate_month(month: &str) -> ApiResult<()> { let valid = month.len() == 7 && month.as_bytes()[4] == b'-' @@ -1426,7 +1768,7 @@ mod tests { FinishIngestionRun, UpdateWatchlistItem, }; - use super::{validate_month, validate_sha256}; + use super::{parse_compare_ids, validate_month, validate_sha256}; #[test] fn accepts_valid_month() { @@ -1602,4 +1944,15 @@ mod tests { assert!(validate_sha256(&"G".repeat(64)).is_err()); assert!(validate_sha256("short").is_err()); } + + #[test] + fn validates_compare_ids() { + assert_eq!( + parse_compare_ids("qiantan, zhangjiang,qiantan").unwrap(), + vec!["qiantan".to_string(), "zhangjiang".to_string()] + ); + assert!(parse_compare_ids("qiantan").is_err()); + assert!(parse_compare_ids("a,b,c,d,e").is_err()); + assert!(parse_compare_ids("a,password,b").is_err()); + } } diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 9d3cfdb..abd4392 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -25,6 +25,12 @@ pub struct NeighborhoodScoreQuery { pub area_id: Option, } +#[derive(Debug, Deserialize)] +pub struct CompareQuery { + pub month: String, + pub ids: String, +} + #[derive(Debug, Deserialize)] pub struct IngestionRunQuery { pub source_id: Option, @@ -287,6 +293,69 @@ pub struct NeighborhoodScore { pub available_units: i32, } +#[derive(Debug, Serialize)] +pub struct ComparisonMetricValue { + pub id: String, + pub value: Option, +} + +#[derive(Debug, Serialize)] +pub struct ComparisonMetric { + pub key: String, + pub label: String, + pub unit: String, + pub direction: String, + pub values: Vec, +} + +#[derive(Debug, Serialize)] +pub struct AreaComparison { + pub month: String, + pub requested_ids: Vec, + pub missing_ids: Vec, + pub items: Vec, + pub metrics: Vec, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct NeighborhoodComparisonItem { + pub neighborhood_id: String, + pub area_id: String, + pub name: String, + pub area_name: String, + pub district: String, + pub built_year: Option, + pub property_type: String, + pub metro_distance_m: Option, + pub school_quality: Option, + pub month: String, + pub investment_score: f64, + pub recommendation: String, + pub liquidity_score: f64, + pub rent_support_score: f64, + pub price_safety_score: f64, + pub location_score: f64, + pub building_age_score: f64, + pub transaction_count: i32, + pub transaction_price_psm: f64, + pub listing_count: i32, + pub listing_price_psm: f64, + pub rent_price_psm: f64, + pub median_days_on_market: f64, + pub annual_rent_yield_pct: f64, + pub discount_pct: f64, + pub available_units: i32, +} + +#[derive(Debug, Serialize)] +pub struct NeighborhoodComparison { + pub month: String, + pub requested_ids: Vec, + pub missing_ids: Vec, + pub items: Vec, + pub metrics: Vec, +} + #[derive(Debug, Serialize)] pub struct NeighborhoodDetail { pub profile: Neighborhood, diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index 205f348..308bdc8 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -4,12 +4,12 @@ use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use crate::handlers::{ - archive_watchlist_item, create_area_score_model_run, create_data_source, create_ingestion_run, - create_raw_artifact, create_watchlist_item, execute_import, finish_ingestion_run, - get_area_detail, get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health, - list_area_scores, list_data_sources, list_ingestion_runs, list_neighborhood_scores, - list_neighborhoods, list_raw_artifacts, list_watchlist_items, market_overview, ready, - update_watchlist_item, validate_import, + archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run, + create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_item, + execute_import, finish_ingestion_run, get_area_detail, get_area_score_lineage, + get_neighborhood, get_neighborhood_detail, health, list_area_scores, list_data_sources, + list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts, + list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import, }; use crate::state::AppState; @@ -39,6 +39,8 @@ pub fn build_router(state: AppState) -> Router { ) .route("/imports/validate", axum::routing::post(validate_import)) .route("/imports/execute", axum::routing::post(execute_import)) + .route("/compare/areas", get(compare_areas)) + .route("/compare/neighborhoods", get(compare_neighborhoods)) .route("/market/overview", get(market_overview)) .route( "/model-runs/area-scores", diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index 2b35d5c..79ab6e2 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -19,6 +19,11 @@ import { CartesianGrid, Line, LineChart, + PolarAngleAxis, + PolarGrid, + PolarRadiusAxis, + Radar, + RadarChart, ResponsiveContainer, Tooltip, XAxis, @@ -33,9 +38,11 @@ import { createWatchlistItem, fetchDataSources, fetchAreaScores, + fetchAreaComparison, fetchAreaDetail, fetchIngestionRuns, fetchMarketOverview, + fetchNeighborhoodComparison, fetchNeighborhoodDetail, fetchNeighborhoodScores, fetchRawArtifacts, @@ -44,13 +51,17 @@ import { } from "@/lib/api"; import type { AreaScore, + AreaComparison, AreaDetail, + ComparisonMetric, CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, IngestionRun, + NeighborhoodComparison, + NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodScore, RawArtifact, @@ -76,6 +87,8 @@ export function MarketDashboard() { const [selectedAreaId, setSelectedAreaId] = useState(""); const [detailAreaId, setDetailAreaId] = useState("qiantan"); const [detailNeighborhoodId, setDetailNeighborhoodId] = useState(""); + const [comparisonAreaIds, setComparisonAreaIds] = useState([]); + const [comparisonNeighborhoodIds, setComparisonNeighborhoodIds] = useState([]); const overview = useQuery({ queryKey: ["market-overview", MONTH], queryFn: () => fetchMarketOverview(MONTH), @@ -98,6 +111,16 @@ export function MarketDashboard() { 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"], queryFn: fetchWatchlistItems, @@ -164,11 +187,31 @@ export function MarketDashboard() { } }, [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]); + const isLoading = overview.isLoading || scores.isLoading || areaDetail.isLoading || neighborhoodDetail.isLoading || + areaComparison.isLoading || + neighborhoodComparison.isLoading || neighborhoodScores.isLoading || watchlist.isLoading || dataSources.isLoading || @@ -179,6 +222,8 @@ export function MarketDashboard() { scores.isError || areaDetail.isError || neighborhoodDetail.isError || + areaComparison.isError || + neighborhoodComparison.isError || neighborhoodScores.isError || watchlist.isError || dataSources.isError || @@ -204,6 +249,8 @@ export function MarketDashboard() { void scores.refetch(); void areaDetail.refetch(); void neighborhoodDetail.refetch(); + void areaComparison.refetch(); + void neighborhoodComparison.refetch(); void neighborhoodScores.refetch(); void watchlist.refetch(); void dataSources.refetch(); @@ -337,6 +384,18 @@ export function MarketDashboard() { onCreateWatchlist={(payload) => createMutation.mutate(payload)} /> + + 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 WatchlistPanel({ scores, neighborhoodScores, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0fa0055..a168af9 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -288,6 +288,42 @@ export type NeighborhoodDetail = { watchlist_items: WatchlistItem[]; }; +export type ComparisonMetricValue = { + id: string; + value: number | null; +}; + +export type ComparisonMetric = { + key: string; + label: string; + unit: string; + direction: "higher" | "lower" | "neutral"; + values: ComparisonMetricValue[]; +}; + +export type AreaComparison = { + month: string; + requested_ids: string[]; + missing_ids: string[]; + items: AreaScore[]; + metrics: ComparisonMetric[]; +}; + +export type NeighborhoodComparisonItem = NeighborhoodScore & { + built_year: number | null; + property_type: string; + metro_distance_m: number | null; + school_quality: string | null; +}; + +export type NeighborhoodComparison = { + month: string; + requested_ids: string[]; + missing_ids: string[]; + items: NeighborhoodComparisonItem[]; + metrics: ComparisonMetric[]; +}; + const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080"; @@ -318,6 +354,24 @@ export async function fetchNeighborhoodDetail( ); } +export async function fetchAreaComparison( + areaIds: string[], + month: string, +): Promise { + return fetchJson( + `/api/v1/compare/areas?month=${encodeURIComponent(month)}&ids=${encodeURIComponent(areaIds.join(","))}`, + ); +} + +export async function fetchNeighborhoodComparison( + neighborhoodIds: string[], + month: string, +): Promise { + return fetchJson( + `/api/v1/compare/neighborhoods?month=${encodeURIComponent(month)}&ids=${encodeURIComponent(neighborhoodIds.join(","))}`, + ); +} + export async function fetchWatchlistItems(): Promise { return fetchJson("/api/v1/watchlist"); } diff --git a/docs/product_roadmap.md b/docs/product_roadmap.md index 73ca017..f5143b9 100644 --- a/docs/product_roadmap.md +++ b/docs/product_roadmap.md @@ -212,20 +212,25 @@ ### M2.3 对比工作台 +状态:已完成。 + 目标: - 支持板块对比、小区对比。 交付物: -- 对比选择器。 -- 对比表格。 -- 雷达图或指标矩阵。 +- `GET /api/v1/compare/areas?month=YYYY-MM&ids=a,b`。 +- `GET /api/v1/compare/neighborhoods?month=YYYY-MM&ids=a,b`。 +- 板块与小区对比选择器,支持 2-4 个标的。 +- 对比矩阵与雷达图。 +- 缺失标的提示。 验收标准: - 至少支持 2-4 个小区并排比较。 - 关键指标包括价格、流动性、租金、楼龄、地铁距离、供应压力。 +- API 返回 requested、missing、items 和 metrics,前端按统一口径展示。 ### M2.4 观察池增强