feat: add comparison workspace
This commit is contained in:
@@ -5,14 +5,15 @@ use serde::Serialize;
|
|||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::import_templates::{execute_import_payload, validate_import_payload};
|
use crate::import_templates::{execute_import_payload, validate_import_payload};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery,
|
AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage,
|
||||||
AreaScoreModelRunResponse, AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource,
|
AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreSummary, CompareQuery,
|
||||||
|
ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource,
|
||||||
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
|
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
|
||||||
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
||||||
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
||||||
MonthQuery, Neighborhood, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
|
MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem,
|
||||||
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
|
NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore,
|
||||||
WatchlistItem,
|
NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem,
|
||||||
};
|
};
|
||||||
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -133,6 +134,122 @@ pub async fn market_overview(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn compare_areas(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<CompareQuery>,
|
||||||
|
) -> ApiResult<Json<AreaComparison>> {
|
||||||
|
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<AppState>,
|
||||||
|
Query(query): Query<CompareQuery>,
|
||||||
|
) -> ApiResult<Json<NeighborhoodComparison>> {
|
||||||
|
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(
|
pub async fn create_area_score_model_run(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<CreateAreaScoreModelRun>,
|
Json(payload): Json<CreateAreaScoreModelRun>,
|
||||||
@@ -1377,6 +1494,231 @@ fn average(values: impl Iterator<Item = f64>) -> f64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_compare_ids(ids: &str) -> ApiResult<Vec<String>> {
|
||||||
|
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<T, F>(mut items: Vec<T>, requested_ids: &[String], id_fn: F) -> Vec<T>
|
||||||
|
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<T, F>(requested_ids: &[String], items: &[T], id_fn: F) -> Vec<String>
|
||||||
|
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<ComparisonMetric> {
|
||||||
|
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<ComparisonMetric> {
|
||||||
|
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<T, F>(
|
||||||
|
key: &str,
|
||||||
|
label: &str,
|
||||||
|
unit: &str,
|
||||||
|
direction: &str,
|
||||||
|
items: &[T],
|
||||||
|
value_fn: F,
|
||||||
|
) -> ComparisonMetric
|
||||||
|
where
|
||||||
|
F: Fn(&T) -> Option<f64>,
|
||||||
|
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<()> {
|
fn validate_month(month: &str) -> ApiResult<()> {
|
||||||
let valid = month.len() == 7
|
let valid = month.len() == 7
|
||||||
&& month.as_bytes()[4] == b'-'
|
&& month.as_bytes()[4] == b'-'
|
||||||
@@ -1426,7 +1768,7 @@ mod tests {
|
|||||||
FinishIngestionRun, UpdateWatchlistItem,
|
FinishIngestionRun, UpdateWatchlistItem,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{validate_month, validate_sha256};
|
use super::{parse_compare_ids, validate_month, validate_sha256};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_valid_month() {
|
fn accepts_valid_month() {
|
||||||
@@ -1602,4 +1944,15 @@ mod tests {
|
|||||||
assert!(validate_sha256(&"G".repeat(64)).is_err());
|
assert!(validate_sha256(&"G".repeat(64)).is_err());
|
||||||
assert!(validate_sha256("short").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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ pub struct NeighborhoodScoreQuery {
|
|||||||
pub area_id: Option<String>,
|
pub area_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CompareQuery {
|
||||||
|
pub month: String,
|
||||||
|
pub ids: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct IngestionRunQuery {
|
pub struct IngestionRunQuery {
|
||||||
pub source_id: Option<i64>,
|
pub source_id: Option<i64>,
|
||||||
@@ -287,6 +293,69 @@ pub struct NeighborhoodScore {
|
|||||||
pub available_units: i32,
|
pub available_units: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ComparisonMetricValue {
|
||||||
|
pub id: String,
|
||||||
|
pub value: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ComparisonMetric {
|
||||||
|
pub key: String,
|
||||||
|
pub label: String,
|
||||||
|
pub unit: String,
|
||||||
|
pub direction: String,
|
||||||
|
pub values: Vec<ComparisonMetricValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AreaComparison {
|
||||||
|
pub month: String,
|
||||||
|
pub requested_ids: Vec<String>,
|
||||||
|
pub missing_ids: Vec<String>,
|
||||||
|
pub items: Vec<AreaScore>,
|
||||||
|
pub metrics: Vec<ComparisonMetric>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<i32>,
|
||||||
|
pub property_type: String,
|
||||||
|
pub metro_distance_m: Option<i32>,
|
||||||
|
pub school_quality: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
pub missing_ids: Vec<String>,
|
||||||
|
pub items: Vec<NeighborhoodComparisonItem>,
|
||||||
|
pub metrics: Vec<ComparisonMetric>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct NeighborhoodDetail {
|
pub struct NeighborhoodDetail {
|
||||||
pub profile: Neighborhood,
|
pub profile: Neighborhood,
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ use tower_http::cors::CorsLayer;
|
|||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
use crate::handlers::{
|
use crate::handlers::{
|
||||||
archive_watchlist_item, create_area_score_model_run, create_data_source, create_ingestion_run,
|
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
|
||||||
create_raw_artifact, create_watchlist_item, execute_import, finish_ingestion_run,
|
create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_item,
|
||||||
get_area_detail, get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health,
|
execute_import, finish_ingestion_run, get_area_detail, get_area_score_lineage,
|
||||||
list_area_scores, list_data_sources, list_ingestion_runs, list_neighborhood_scores,
|
get_neighborhood, get_neighborhood_detail, health, list_area_scores, list_data_sources,
|
||||||
list_neighborhoods, list_raw_artifacts, list_watchlist_items, market_overview, ready,
|
list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts,
|
||||||
update_watchlist_item, validate_import,
|
list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
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/validate", axum::routing::post(validate_import))
|
||||||
.route("/imports/execute", axum::routing::post(execute_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("/market/overview", get(market_overview))
|
||||||
.route(
|
.route(
|
||||||
"/model-runs/area-scores",
|
"/model-runs/area-scores",
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import {
|
|||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
|
PolarAngleAxis,
|
||||||
|
PolarGrid,
|
||||||
|
PolarRadiusAxis,
|
||||||
|
Radar,
|
||||||
|
RadarChart,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -33,9 +38,11 @@ import {
|
|||||||
createWatchlistItem,
|
createWatchlistItem,
|
||||||
fetchDataSources,
|
fetchDataSources,
|
||||||
fetchAreaScores,
|
fetchAreaScores,
|
||||||
|
fetchAreaComparison,
|
||||||
fetchAreaDetail,
|
fetchAreaDetail,
|
||||||
fetchIngestionRuns,
|
fetchIngestionRuns,
|
||||||
fetchMarketOverview,
|
fetchMarketOverview,
|
||||||
|
fetchNeighborhoodComparison,
|
||||||
fetchNeighborhoodDetail,
|
fetchNeighborhoodDetail,
|
||||||
fetchNeighborhoodScores,
|
fetchNeighborhoodScores,
|
||||||
fetchRawArtifacts,
|
fetchRawArtifacts,
|
||||||
@@ -44,13 +51,17 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
AreaScore,
|
AreaScore,
|
||||||
|
AreaComparison,
|
||||||
AreaDetail,
|
AreaDetail,
|
||||||
|
ComparisonMetric,
|
||||||
CreateDataSource,
|
CreateDataSource,
|
||||||
CreateIngestionRun,
|
CreateIngestionRun,
|
||||||
CreateRawArtifact,
|
CreateRawArtifact,
|
||||||
CreateWatchlistItem,
|
CreateWatchlistItem,
|
||||||
DataSource,
|
DataSource,
|
||||||
IngestionRun,
|
IngestionRun,
|
||||||
|
NeighborhoodComparison,
|
||||||
|
NeighborhoodComparisonItem,
|
||||||
NeighborhoodDetail,
|
NeighborhoodDetail,
|
||||||
NeighborhoodScore,
|
NeighborhoodScore,
|
||||||
RawArtifact,
|
RawArtifact,
|
||||||
@@ -76,6 +87,8 @@ export function MarketDashboard() {
|
|||||||
const [selectedAreaId, setSelectedAreaId] = useState("");
|
const [selectedAreaId, setSelectedAreaId] = useState("");
|
||||||
const [detailAreaId, setDetailAreaId] = useState("qiantan");
|
const [detailAreaId, setDetailAreaId] = useState("qiantan");
|
||||||
const [detailNeighborhoodId, setDetailNeighborhoodId] = useState("");
|
const [detailNeighborhoodId, setDetailNeighborhoodId] = useState("");
|
||||||
|
const [comparisonAreaIds, setComparisonAreaIds] = useState<string[]>([]);
|
||||||
|
const [comparisonNeighborhoodIds, setComparisonNeighborhoodIds] = useState<string[]>([]);
|
||||||
const overview = useQuery({
|
const overview = useQuery({
|
||||||
queryKey: ["market-overview", MONTH],
|
queryKey: ["market-overview", MONTH],
|
||||||
queryFn: () => fetchMarketOverview(MONTH),
|
queryFn: () => fetchMarketOverview(MONTH),
|
||||||
@@ -98,6 +111,16 @@ export function MarketDashboard() {
|
|||||||
queryFn: () => fetchNeighborhoodDetail(detailNeighborhoodId, MONTH),
|
queryFn: () => fetchNeighborhoodDetail(detailNeighborhoodId, MONTH),
|
||||||
enabled: Boolean(detailNeighborhoodId),
|
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({
|
const watchlist = useQuery({
|
||||||
queryKey: ["watchlist"],
|
queryKey: ["watchlist"],
|
||||||
queryFn: fetchWatchlistItems,
|
queryFn: fetchWatchlistItems,
|
||||||
@@ -164,11 +187,31 @@ export function MarketDashboard() {
|
|||||||
}
|
}
|
||||||
}, [detailNeighborhoodId, neighborhoodScores.data]);
|
}, [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 =
|
const isLoading =
|
||||||
overview.isLoading ||
|
overview.isLoading ||
|
||||||
scores.isLoading ||
|
scores.isLoading ||
|
||||||
areaDetail.isLoading ||
|
areaDetail.isLoading ||
|
||||||
neighborhoodDetail.isLoading ||
|
neighborhoodDetail.isLoading ||
|
||||||
|
areaComparison.isLoading ||
|
||||||
|
neighborhoodComparison.isLoading ||
|
||||||
neighborhoodScores.isLoading ||
|
neighborhoodScores.isLoading ||
|
||||||
watchlist.isLoading ||
|
watchlist.isLoading ||
|
||||||
dataSources.isLoading ||
|
dataSources.isLoading ||
|
||||||
@@ -179,6 +222,8 @@ export function MarketDashboard() {
|
|||||||
scores.isError ||
|
scores.isError ||
|
||||||
areaDetail.isError ||
|
areaDetail.isError ||
|
||||||
neighborhoodDetail.isError ||
|
neighborhoodDetail.isError ||
|
||||||
|
areaComparison.isError ||
|
||||||
|
neighborhoodComparison.isError ||
|
||||||
neighborhoodScores.isError ||
|
neighborhoodScores.isError ||
|
||||||
watchlist.isError ||
|
watchlist.isError ||
|
||||||
dataSources.isError ||
|
dataSources.isError ||
|
||||||
@@ -204,6 +249,8 @@ export function MarketDashboard() {
|
|||||||
void scores.refetch();
|
void scores.refetch();
|
||||||
void areaDetail.refetch();
|
void areaDetail.refetch();
|
||||||
void neighborhoodDetail.refetch();
|
void neighborhoodDetail.refetch();
|
||||||
|
void areaComparison.refetch();
|
||||||
|
void neighborhoodComparison.refetch();
|
||||||
void neighborhoodScores.refetch();
|
void neighborhoodScores.refetch();
|
||||||
void watchlist.refetch();
|
void watchlist.refetch();
|
||||||
void dataSources.refetch();
|
void dataSources.refetch();
|
||||||
@@ -337,6 +384,18 @@ export function MarketDashboard() {
|
|||||||
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
|
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ComparisonWorkspace
|
||||||
|
areaScores={scores.data ?? []}
|
||||||
|
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||||
|
areaComparison={areaComparison.data ?? null}
|
||||||
|
neighborhoodComparison={neighborhoodComparison.data ?? null}
|
||||||
|
selectedAreaIds={comparisonAreaIds}
|
||||||
|
selectedNeighborhoodIds={comparisonNeighborhoodIds}
|
||||||
|
loading={areaComparison.isLoading || neighborhoodComparison.isLoading}
|
||||||
|
onAreaIdsChange={setComparisonAreaIds}
|
||||||
|
onNeighborhoodIdsChange={setComparisonNeighborhoodIds}
|
||||||
|
/>
|
||||||
|
|
||||||
<WatchlistPanel
|
<WatchlistPanel
|
||||||
scores={scores.data ?? []}
|
scores={scores.data ?? []}
|
||||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||||
@@ -1456,6 +1515,320 @@ function NeighborhoodProfileTable({ detail }: { detail: NeighborhoodDetail }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>对比工作台</CardTitle>
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||||
|
板块与小区并排比较
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid w-full gap-3 xl:w-[640px] xl:grid-cols-2">
|
||||||
|
<ComparisonSelector
|
||||||
|
label="板块"
|
||||||
|
options={areaScores.map((score) => ({
|
||||||
|
id: score.area_id,
|
||||||
|
label: score.name,
|
||||||
|
detail: score.district,
|
||||||
|
}))}
|
||||||
|
selectedIds={selectedAreaIds}
|
||||||
|
onChange={onAreaIdsChange}
|
||||||
|
/>
|
||||||
|
<ComparisonSelector
|
||||||
|
label="小区"
|
||||||
|
options={neighborhoodScores.map((score) => ({
|
||||||
|
id: score.neighborhood_id,
|
||||||
|
label: score.name,
|
||||||
|
detail: score.area_name,
|
||||||
|
}))}
|
||||||
|
selectedIds={selectedNeighborhoodIds}
|
||||||
|
onChange={onNeighborhoodIdsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
{loading ? <div className="h-72 rounded-md bg-[var(--muted)]" /> : null}
|
||||||
|
{!loading ? (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
|
<ComparisonMatrix
|
||||||
|
title="板块矩阵"
|
||||||
|
comparison={areaComparison}
|
||||||
|
getId={(item) => item.area_id}
|
||||||
|
getName={(item) => item.name}
|
||||||
|
getMeta={(item) => `${item.district} · ${item.segment}`}
|
||||||
|
/>
|
||||||
|
<ComparisonRadar
|
||||||
|
title="板块雷达"
|
||||||
|
metrics={areaComparison?.metrics ?? []}
|
||||||
|
items={areaComparison?.items ?? []}
|
||||||
|
getId={(item) => item.area_id}
|
||||||
|
getName={(item) => item.name}
|
||||||
|
/>
|
||||||
|
<ComparisonMatrix
|
||||||
|
title="小区矩阵"
|
||||||
|
comparison={neighborhoodComparison}
|
||||||
|
getId={(item) => item.neighborhood_id}
|
||||||
|
getName={(item) => item.name}
|
||||||
|
getMeta={(item) =>
|
||||||
|
`${item.area_name} · ${item.built_year ?? "-"} · ${item.property_type}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ComparisonRadar
|
||||||
|
title="小区雷达"
|
||||||
|
metrics={neighborhoodComparison?.metrics ?? []}
|
||||||
|
items={neighborhoodComparison?.items ?? []}
|
||||||
|
getId={(item) => item.neighborhood_id}
|
||||||
|
getName={(item) => item.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded-md border border-[var(--border)] p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-slate-950">{label}</p>
|
||||||
|
<Badge variant="muted">{selectedIds.length}/4</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid max-h-44 gap-2 overflow-y-auto pr-1">
|
||||||
|
{options.map((option) => {
|
||||||
|
const selected = selectedIds.includes(option.id);
|
||||||
|
const locked = selected && selectedIds.length <= 2;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={option.id}
|
||||||
|
className={
|
||||||
|
selected
|
||||||
|
? "flex cursor-pointer items-center gap-2 rounded-md border border-teal-200 bg-teal-50 px-2 py-2"
|
||||||
|
: "flex cursor-pointer items-center gap-2 rounded-md border border-[var(--border)] px-2 py-2 hover:bg-slate-50"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
disabled={locked}
|
||||||
|
onChange={() => toggle(option.id)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-medium text-slate-950">
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-[var(--muted-foreground)]">
|
||||||
|
{option.detail}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComparisonMatrix<T>({
|
||||||
|
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 (
|
||||||
|
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||||
|
暂无对比数据
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{title}</TableHead>
|
||||||
|
{comparison.items.map((item) => (
|
||||||
|
<TableHead key={getId(item)} className="min-w-36 text-right">
|
||||||
|
<span className="block text-slate-950">{getName(item)}</span>
|
||||||
|
<span className="block font-normal text-[var(--muted-foreground)]">
|
||||||
|
{metaById.get(getId(item))}
|
||||||
|
</span>
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{comparison.metrics.map((metric) => (
|
||||||
|
<TableRow key={metric.key}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{metric.label}
|
||||||
|
<span className="ml-1 text-xs font-normal text-[var(--muted-foreground)]">
|
||||||
|
{metric.unit}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
{comparison.items.map((item) => {
|
||||||
|
const id = getId(item);
|
||||||
|
const value = metric.values.find((metricValue) => metricValue.id === id)?.value;
|
||||||
|
return (
|
||||||
|
<TableCell key={`${metric.key}-${id}`} className="text-right">
|
||||||
|
{formatMetricValue(value, metric.unit)}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{comparison.missing_ids.length > 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="text-[var(--muted-foreground)]">缺失</TableCell>
|
||||||
|
<TableCell
|
||||||
|
colSpan={comparison.items.length}
|
||||||
|
className="text-right text-[var(--muted-foreground)]"
|
||||||
|
>
|
||||||
|
{comparison.missing_ids.map((id) => nameById.get(id) ?? id).join(", ")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComparisonRadar<T>({
|
||||||
|
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<string, string | number> = { 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 (
|
||||||
|
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||||
|
暂无雷达图
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-80 rounded-md border border-[var(--border)] p-3">
|
||||||
|
<p className="mb-2 text-sm font-semibold text-slate-950">{title}</p>
|
||||||
|
<ResponsiveContainer width="100%" height="90%">
|
||||||
|
<RadarChart data={data}>
|
||||||
|
<PolarGrid stroke="#dbe1ea" />
|
||||||
|
<PolarAngleAxis dataKey="metric" tick={{ fontSize: 12 }} />
|
||||||
|
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
|
||||||
|
<Tooltip formatter={(value) => [formatNumber(Number(value)), "标准化"]} />
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Radar
|
||||||
|
key={getId(item)}
|
||||||
|
name={getName(item)}
|
||||||
|
dataKey={getId(item)}
|
||||||
|
stroke={colors[index % colors.length]}
|
||||||
|
fill={colors[index % colors.length]}
|
||||||
|
fillOpacity={0.14}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</RadarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
function WatchlistPanel({
|
||||||
scores,
|
scores,
|
||||||
neighborhoodScores,
|
neighborhoodScores,
|
||||||
|
|||||||
@@ -288,6 +288,42 @@ export type NeighborhoodDetail = {
|
|||||||
watchlist_items: WatchlistItem[];
|
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 =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080";
|
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<AreaComparison> {
|
||||||
|
return fetchJson(
|
||||||
|
`/api/v1/compare/areas?month=${encodeURIComponent(month)}&ids=${encodeURIComponent(areaIds.join(","))}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNeighborhoodComparison(
|
||||||
|
neighborhoodIds: string[],
|
||||||
|
month: string,
|
||||||
|
): Promise<NeighborhoodComparison> {
|
||||||
|
return fetchJson(
|
||||||
|
`/api/v1/compare/neighborhoods?month=${encodeURIComponent(month)}&ids=${encodeURIComponent(neighborhoodIds.join(","))}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
|
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
|
||||||
return fetchJson("/api/v1/watchlist");
|
return fetchJson("/api/v1/watchlist");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,20 +212,25 @@
|
|||||||
|
|
||||||
### M2.3 对比工作台
|
### 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 个小区并排比较。
|
- 至少支持 2-4 个小区并排比较。
|
||||||
- 关键指标包括价格、流动性、租金、楼龄、地铁距离、供应压力。
|
- 关键指标包括价格、流动性、租金、楼龄、地铁距离、供应压力。
|
||||||
|
- API 返回 requested、missing、items 和 metrics,前端按统一口径展示。
|
||||||
|
|
||||||
### M2.4 观察池增强
|
### M2.4 观察池增强
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user