From 6ca67e75551d2ba9d577e709aa036feb8778c283 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Wed, 24 Jun 2026 12:03:17 +0800 Subject: [PATCH] feat: add area detail workspace --- apps/api/src/handlers.rs | 173 ++++++++++- apps/api/src/models.rs | 37 +++ apps/api/src/routes.rs | 8 +- .../components/dashboard/market-dashboard.tsx | 294 +++++++++++++++++- apps/web/src/lib/api.ts | 40 +++ docs/product_roadmap.md | 3 + 6 files changed, 538 insertions(+), 17 deletions(-) diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index b1ff170..a7d0c59 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -5,13 +5,13 @@ use serde::Serialize; use crate::error::{ApiError, ApiResult}; use crate::import_templates::{execute_import_payload, validate_import_payload}; use crate::models::{ - AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse, - AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, - CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun, ImportExecutionRequest, - ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun, - IngestionRunQuery, MarketOverview, ModelRun, MonthQuery, Neighborhood, NeighborhoodQuery, - NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, - WatchlistItem, + AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, + AreaScoreModelRunResponse, AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource, + CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun, + ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest, + ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun, + MonthQuery, Neighborhood, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery, + RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem, }; use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore}; use crate::state::AppState; @@ -233,7 +233,19 @@ pub async fn get_area_score_lineage( Query(query): Query, ) -> ApiResult> { validate_month(&query.month)?; - let lineage = sqlx::query_as::<_, AreaScoreLineage>( + let lineage = fetch_area_score_lineage(&state, &area_id, &query.month) + .await? + .ok_or_else(|| ApiError::BadRequest("area score lineage not found".to_string()))?; + + Ok(Json(lineage)) +} + +async fn fetch_area_score_lineage( + state: &AppState, + area_id: &str, + month: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, AreaScoreLineage>( r#" SELECT s.area_id, @@ -261,12 +273,151 @@ pub async fn get_area_score_lineage( "#, ) .bind(area_id) - .bind(query.month) + .bind(month) + .fetch_optional(&state.pool) + .await +} + +pub async fn get_area_detail( + State(state): State, + Path(area_id): Path, + Query(query): Query, +) -> ApiResult> { + validate_month(&query.month)?; + + let profile = sqlx::query_as::<_, AreaProfile>( + r#" + SELECT area_id, name, district, segment, notes + FROM silver.areas + WHERE area_id = $1 + "#, + ) + .bind(&area_id) .fetch_optional(&state.pool) .await? - .ok_or_else(|| ApiError::BadRequest("area score lineage not found".to_string()))?; + .ok_or_else(|| ApiError::BadRequest("area not found".to_string()))?; - Ok(Json(lineage)) + let current_score = 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 area_id = $1 + AND month = $2 + "#, + ) + .bind(&area_id) + .bind(&query.month) + .fetch_optional(&state.pool) + .await?; + + let score_lineage = fetch_area_score_lineage(&state, &area_id, &query.month).await?; + let monthly_metrics = sqlx::query_as::<_, AreaMonthlyMetric>( + r#" + SELECT + area_id, + month, + transaction_count, + transaction_price_psm, + listing_count, + listing_price_psm, + median_days_on_market, + rent_price_psm, + new_supply_units, + land_residential_gfa_sqm, + mortgage_rate_pct, + policy_signal, + source, + ingestion_run_id, + raw_artifact_id + FROM silver.area_monthly_metrics + WHERE area_id = $1 + AND month <= $2 + ORDER BY month DESC + LIMIT 12 + "#, + ) + .bind(&area_id) + .bind(&query.month) + .fetch_all(&state.pool) + .await?; + + let neighborhood_scores = + fetch_neighborhood_scores_for_area(&state, &area_id, &query.month).await?; + + Ok(Json(AreaDetail { + profile, + current_score, + score_lineage, + monthly_metrics, + neighborhood_scores, + })) +} + +async fn fetch_neighborhood_scores_for_area( + state: &AppState, + area_id: &str, + month: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, NeighborhoodScore>( + r#" + SELECT + neighborhood_id, + area_id, + name, + area_name, + district, + month, + investment_score, + recommendation, + liquidity_score, + rent_support_score, + price_safety_score, + location_score, + building_age_score, + transaction_count, + transaction_price_psm, + listing_count, + listing_price_psm, + rent_price_psm, + median_days_on_market, + annual_rent_yield_pct, + discount_pct, + available_units + FROM gold.neighborhood_scores + WHERE area_id = $1 + AND month = $2 + ORDER BY investment_score DESC, neighborhood_id ASC + "#, + ) + .bind(area_id) + .bind(month) + .fetch_all(&state.pool) + .await } pub async fn list_neighborhoods( diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index bdd8c42..4213e33 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -194,6 +194,43 @@ pub struct AreaScoreLineage { pub score_computed_at: String, } +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct AreaProfile { + pub area_id: String, + pub name: String, + pub district: String, + pub segment: String, + pub notes: String, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct AreaMonthlyMetric { + pub area_id: String, + pub month: String, + pub transaction_count: i32, + pub transaction_price_psm: f64, + pub listing_count: i32, + pub listing_price_psm: f64, + pub median_days_on_market: f64, + pub rent_price_psm: f64, + pub new_supply_units: i32, + pub land_residential_gfa_sqm: f64, + pub mortgage_rate_pct: f64, + pub policy_signal: i32, + pub source: String, + pub ingestion_run_id: Option, + pub raw_artifact_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct AreaDetail { + pub profile: AreaProfile, + pub current_score: Option, + pub score_lineage: Option, + pub monthly_metrics: Vec, + pub neighborhood_scores: Vec, +} + #[derive(Debug, Clone, Serialize, FromRow)] pub struct Neighborhood { pub neighborhood_id: String, diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index c5f7a27..ed6462c 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -6,9 +6,10 @@ 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_score_lineage, get_neighborhood, 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, + get_area_detail, get_area_score_lineage, get_neighborhood, 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; @@ -47,6 +48,7 @@ pub fn build_router(state: AppState) -> Router { "/areas/{area_id}/score-lineage", get(get_area_score_lineage), ) + .route("/areas/{area_id}/detail", get(get_area_detail)) .route("/neighborhoods", get(list_neighborhoods)) .route("/neighborhoods/scores", get(list_neighborhood_scores)) .route("/neighborhoods/{neighborhood_id}", get(get_neighborhood)) diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index 4e11c36..7bc2caa 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -17,6 +17,8 @@ import { Bar, BarChart, CartesianGrid, + Line, + LineChart, ResponsiveContainer, Tooltip, XAxis, @@ -31,6 +33,7 @@ import { createWatchlistItem, fetchDataSources, fetchAreaScores, + fetchAreaDetail, fetchIngestionRuns, fetchMarketOverview, fetchNeighborhoodScores, @@ -40,6 +43,7 @@ import { } from "@/lib/api"; import type { AreaScore, + AreaDetail, CreateDataSource, CreateIngestionRun, CreateRawArtifact, @@ -68,6 +72,7 @@ const MONTH = "2026-05"; export function MarketDashboard() { const queryClient = useQueryClient(); const [selectedAreaId, setSelectedAreaId] = useState(""); + const [detailAreaId, setDetailAreaId] = useState("qiantan"); const overview = useQuery({ queryKey: ["market-overview", MONTH], queryFn: () => fetchMarketOverview(MONTH), @@ -76,6 +81,11 @@ export function MarketDashboard() { 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), @@ -140,6 +150,7 @@ export function MarketDashboard() { const isLoading = overview.isLoading || scores.isLoading || + areaDetail.isLoading || neighborhoodScores.isLoading || watchlist.isLoading || dataSources.isLoading || @@ -148,6 +159,7 @@ export function MarketDashboard() { const hasError = overview.isError || scores.isError || + areaDetail.isError || neighborhoodScores.isError || watchlist.isError || dataSources.isError || @@ -171,6 +183,7 @@ export function MarketDashboard() { onClick={() => { void overview.refetch(); void scores.refetch(); + void areaDetail.refetch(); void neighborhoodScores.refetch(); void watchlist.refetch(); void dataSources.refetch(); @@ -268,10 +281,22 @@ export function MarketDashboard() { 板块明细 - + + createMutation.mutate(payload)} + creating={createMutation.isPending} + /> + 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 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, +}: { + scores: NeighborhoodScore[]; + creating: boolean; + onCreate: (payload: CreateWatchlistItem) => void; +}) { + if (scores.length === 0) { + return ( +
+ 暂无小区评分 +
+ ); + } + + return ( +
+ + + + 小区 + 分数 + 均价/㎡ + + + + + {scores.slice(0, 6).map((score) => ( + + {score.name} + {formatNumber(score.investment_score)} + {formatPrice(score.transaction_price_psm)} + + + + + ))} + +
+
+ ); +} + function NeighborhoodRankingPanel({ scores, neighborhoodScores, @@ -1092,7 +1362,17 @@ function InsightRow({ label, value, detail }: { label: string; value: string; de ); } -function AreaScoreTable({ scores, loading }: { scores: AreaScore[]; loading: boolean }) { +function AreaScoreTable({ + scores, + loading, + selectedAreaId, + onSelectArea, +}: { + scores: AreaScore[]; + loading: boolean; + selectedAreaId: string; + onSelectArea: (areaId: string) => void; +}) { if (loading) { return
; } @@ -1113,7 +1393,15 @@ function AreaScoreTable({ scores, loading }: { scores: AreaScore[]; loading: boo {scores.map((score) => ( - + onSelectArea(score.area_id)} + > {score.name} {score.district} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0a68c33..483492e 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -219,6 +219,40 @@ export type AreaScoreLineage = { score_computed_at: string; }; +export type AreaProfile = { + area_id: string; + name: string; + district: string; + segment: string; + notes: string; +}; + +export type AreaMonthlyMetric = { + area_id: string; + month: string; + transaction_count: number; + transaction_price_psm: number; + listing_count: number; + listing_price_psm: number; + median_days_on_market: number; + rent_price_psm: number; + new_supply_units: number; + land_residential_gfa_sqm: number; + mortgage_rate_pct: number; + policy_signal: number; + source: string; + ingestion_run_id: number | null; + raw_artifact_id: number | null; +}; + +export type AreaDetail = { + profile: AreaProfile; + current_score: AreaScore | null; + score_lineage: AreaScoreLineage | null; + monthly_metrics: AreaMonthlyMetric[]; + neighborhood_scores: NeighborhoodScore[]; +}; + const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080"; @@ -230,6 +264,12 @@ export async function fetchAreaScores(month: string): Promise { return fetchJson(`/api/v1/areas/scores?month=${encodeURIComponent(month)}`); } +export async function fetchAreaDetail(areaId: string, month: string): Promise { + return fetchJson( + `/api/v1/areas/${encodeURIComponent(areaId)}/detail?month=${encodeURIComponent(month)}`, + ); +} + export async function fetchNeighborhoodScores(month: string): Promise { return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`); } diff --git a/docs/product_roadmap.md b/docs/product_roadmap.md index f6dec1d..31f2c30 100644 --- a/docs/product_roadmap.md +++ b/docs/product_roadmap.md @@ -170,6 +170,8 @@ ### M2.1 板块详情页 +状态:已完成。 + 目标: - 从市场总览进入单个板块,查看成交、挂牌、租金、供应、小区排行。 @@ -179,6 +181,7 @@ - 板块详情 API。 - 板块详情前端视图。 - 板块内小区排行和观察池入口。 +- 评分数据追溯展示。 验收标准: