diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index a7d0c59..cdf82d0 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -10,8 +10,9 @@ use crate::models::{ CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun, - MonthQuery, Neighborhood, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery, - RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem, + MonthQuery, Neighborhood, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, + NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, + WatchlistItem, }; use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore}; use crate::state::AppState; @@ -454,7 +455,118 @@ pub async fn get_neighborhood( State(state): State, Path(neighborhood_id): Path, ) -> ApiResult> { - let neighborhood = sqlx::query_as::<_, Neighborhood>( + let neighborhood = fetch_neighborhood_profile(&state, &neighborhood_id) + .await? + .ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?; + + Ok(Json(neighborhood)) +} + +pub async fn get_neighborhood_detail( + State(state): State, + Path(neighborhood_id): Path, + Query(query): Query, +) -> ApiResult> { + validate_month(&query.month)?; + + let profile = fetch_neighborhood_profile(&state, &neighborhood_id) + .await? + .ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?; + + let current_score = 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 neighborhood_id = $1 + AND month = $2 + "#, + ) + .bind(&neighborhood_id) + .bind(&query.month) + .fetch_optional(&state.pool) + .await?; + + let monthly_metrics = sqlx::query_as::<_, NeighborhoodMonthlyMetric>( + r#" + SELECT + neighborhood_id, + month, + transaction_count, + transaction_price_psm, + listing_count, + listing_price_psm, + rent_price_psm, + median_days_on_market, + available_units, + source, + ingestion_run_id, + raw_artifact_id + FROM silver.neighborhood_monthly_metrics + WHERE neighborhood_id = $1 + AND month <= $2 + ORDER BY month DESC + LIMIT 12 + "#, + ) + .bind(&neighborhood_id) + .bind(&query.month) + .fetch_all(&state.pool) + .await?; + + let watchlist_items = sqlx::query_as::<_, WatchlistItem>( + r#" + SELECT + watchlist_item_id, + neighborhood_id, + area_id, + target_price_psm, + status, + notes + FROM app.watchlist_items + WHERE neighborhood_id = $1 + AND status <> 'archived' + ORDER BY updated_at DESC, watchlist_item_id DESC + "#, + ) + .bind(&neighborhood_id) + .fetch_all(&state.pool) + .await?; + + Ok(Json(NeighborhoodDetail { + profile, + current_score, + monthly_metrics, + watchlist_items, + })) +} + +async fn fetch_neighborhood_profile( + state: &AppState, + neighborhood_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Neighborhood>( r#" SELECT n.neighborhood_id, @@ -474,10 +586,7 @@ pub async fn get_neighborhood( ) .bind(neighborhood_id) .fetch_optional(&state.pool) - .await? - .ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?; - - Ok(Json(neighborhood)) + .await } pub async fn list_neighborhood_scores( diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 4213e33..9d3cfdb 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -245,6 +245,22 @@ pub struct Neighborhood { pub notes: String, } +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct NeighborhoodMonthlyMetric { + pub neighborhood_id: String, + pub month: String, + 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 available_units: i32, + pub source: String, + pub ingestion_run_id: Option, + pub raw_artifact_id: Option, +} + #[derive(Debug, Clone, Serialize, FromRow)] pub struct NeighborhoodScore { pub neighborhood_id: String, @@ -271,6 +287,14 @@ pub struct NeighborhoodScore { pub available_units: i32, } +#[derive(Debug, Serialize)] +pub struct NeighborhoodDetail { + pub profile: Neighborhood, + pub current_score: Option, + pub monthly_metrics: Vec, + pub watchlist_items: Vec, +} + #[derive(Debug, Clone, Serialize, FromRow)] pub struct DataSource { pub source_id: i64, diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index ed6462c..205f348 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -6,10 +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_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, + 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; @@ -51,6 +51,10 @@ pub fn build_router(state: AppState) -> Router { .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}/detail", + get(get_neighborhood_detail), + ) .route("/neighborhoods/{neighborhood_id}", get(get_neighborhood)) .route( "/watchlist", diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index 7bc2caa..2b35d5c 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -12,7 +12,7 @@ import { RefreshCw, TrendingUp, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Bar, BarChart, @@ -36,6 +36,7 @@ import { fetchAreaDetail, fetchIngestionRuns, fetchMarketOverview, + fetchNeighborhoodDetail, fetchNeighborhoodScores, fetchRawArtifacts, fetchWatchlistItems, @@ -50,6 +51,7 @@ import type { CreateWatchlistItem, DataSource, IngestionRun, + NeighborhoodDetail, NeighborhoodScore, RawArtifact, WatchlistItem, @@ -73,6 +75,7 @@ export function MarketDashboard() { const queryClient = useQueryClient(); const [selectedAreaId, setSelectedAreaId] = useState(""); const [detailAreaId, setDetailAreaId] = useState("qiantan"); + const [detailNeighborhoodId, setDetailNeighborhoodId] = useState(""); const overview = useQuery({ queryKey: ["market-overview", MONTH], queryFn: () => fetchMarketOverview(MONTH), @@ -90,6 +93,11 @@ export function MarketDashboard() { queryKey: ["neighborhood-scores", MONTH], queryFn: () => fetchNeighborhoodScores(MONTH), }); + const neighborhoodDetail = useQuery({ + queryKey: ["neighborhood-detail", detailNeighborhoodId, MONTH], + queryFn: () => fetchNeighborhoodDetail(detailNeighborhoodId, MONTH), + enabled: Boolean(detailNeighborhoodId), + }); const watchlist = useQuery({ queryKey: ["watchlist"], queryFn: fetchWatchlistItems, @@ -109,7 +117,10 @@ export function MarketDashboard() { const createMutation = useMutation({ mutationFn: createWatchlistItem, onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["watchlist"] }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["watchlist"] }), + queryClient.invalidateQueries({ queryKey: ["neighborhood-detail"] }), + ]); }, }); const archiveMutation = useMutation({ @@ -147,10 +158,17 @@ export function MarketDashboard() { }, }); + useEffect(() => { + if (!detailNeighborhoodId && neighborhoodScores.data?.[0]) { + setDetailNeighborhoodId(neighborhoodScores.data[0].neighborhood_id); + } + }, [detailNeighborhoodId, neighborhoodScores.data]); + const isLoading = overview.isLoading || scores.isLoading || areaDetail.isLoading || + neighborhoodDetail.isLoading || neighborhoodScores.isLoading || watchlist.isLoading || dataSources.isLoading || @@ -160,6 +178,7 @@ export function MarketDashboard() { overview.isError || scores.isError || areaDetail.isError || + neighborhoodDetail.isError || neighborhoodScores.isError || watchlist.isError || dataSources.isError || @@ -184,6 +203,7 @@ export function MarketDashboard() { void overview.refetch(); void scores.refetch(); void areaDetail.refetch(); + void neighborhoodDetail.refetch(); void neighborhoodScores.refetch(); void watchlist.refetch(); void dataSources.refetch(); @@ -295,18 +315,28 @@ export function MarketDashboard() { loading={areaDetail.isLoading} onCreateWatchlist={(payload) => createMutation.mutate(payload)} creating={createMutation.isPending} + onSelectNeighborhood={setDetailNeighborhoodId} /> createMutation.mutate(payload)} /> + createMutation.mutate(payload)} + /> + void; + onSelectNeighborhood: (neighborhoodId: string) => void; }) { if (loading) { return
; @@ -826,6 +858,7 @@ function AreaDetailPanel({ scores={detail.neighborhood_scores} creating={creating} onCreate={onCreateWatchlist} + onSelectNeighborhood={onSelectNeighborhood} />
{lineage ? ( @@ -930,10 +963,12 @@ function AreaNeighborhoodTable({ scores, creating, onCreate, + onSelectNeighborhood, }: { scores: NeighborhoodScore[]; creating: boolean; onCreate: (payload: CreateWatchlistItem) => void; + onSelectNeighborhood: (neighborhoodId: string) => void; }) { if (scores.length === 0) { return ( @@ -956,7 +991,11 @@ function AreaNeighborhoodTable({ {scores.slice(0, 6).map((score) => ( - + onSelectNeighborhood(score.neighborhood_id)} + > {score.name} {formatNumber(score.investment_score)} {formatPrice(score.transaction_price_psm)} @@ -964,13 +1003,14 @@ function AreaNeighborhoodTable({ + + + +
+ + + + +
+
+
+
+ +
+ +
+
+ + +
+
+
+ + ); +} + +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 WatchlistPanel({ scores, neighborhoodScores, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 483492e..0fa0055 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -69,6 +69,34 @@ export type NeighborhoodScore = { available_units: number; }; +export type Neighborhood = { + neighborhood_id: string; + area_id: string; + area_name: string; + district: string; + name: string; + built_year: number | null; + property_type: string; + metro_distance_m: number | null; + school_quality: string | null; + notes: string; +}; + +export type NeighborhoodMonthlyMetric = { + neighborhood_id: string; + month: string; + transaction_count: number; + transaction_price_psm: number; + listing_count: number; + listing_price_psm: number; + rent_price_psm: number; + median_days_on_market: number; + available_units: number; + source: string; + ingestion_run_id: number | null; + raw_artifact_id: number | null; +}; + export type WatchlistItem = { watchlist_item_id: number; neighborhood_id: string | null; @@ -253,6 +281,13 @@ export type AreaDetail = { neighborhood_scores: NeighborhoodScore[]; }; +export type NeighborhoodDetail = { + profile: Neighborhood; + current_score: NeighborhoodScore | null; + monthly_metrics: NeighborhoodMonthlyMetric[]; + watchlist_items: WatchlistItem[]; +}; + const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080"; @@ -274,6 +309,15 @@ export async function fetchNeighborhoodScores(month: string): Promise { + return fetchJson( + `/api/v1/neighborhoods/${encodeURIComponent(neighborhoodId)}/detail?month=${encodeURIComponent(month)}`, + ); +} + export async function fetchWatchlistItems(): Promise { return fetchJson("/api/v1/watchlist"); } diff --git a/docs/product_roadmap.md b/docs/product_roadmap.md index 31f2c30..73ca017 100644 --- a/docs/product_roadmap.md +++ b/docs/product_roadmap.md @@ -22,8 +22,8 @@ | 阶段 | 名称 | 目标 | 状态 | | --- | --- | --- | --- | | M0 | 工程与研究骨架 | API、数据库、前端、样例评分跑通 | 已完成 | -| M1 | 数据底座 | 数据源、导入批次、原始数据留痕、导入中心 | 进行中 | -| M2 | 资产研究工作台 | 板块详情、小区详情、对比、观察池增强 | 待开发 | +| M1 | 数据底座 | 数据源、导入批次、原始数据留痕、导入中心 | 已完成 | +| M2 | 资产研究工作台 | 板块详情、小区详情、对比、观察池增强 | 进行中 | | M3 | 模型与回测 | 评分模型版本化、历史分位、相似资产、情景推演 | 待开发 | | M4 | 报告与预警 | 周报/月报、专题报告、观察池触发器 | 待开发 | | M5 | 产品化与运维 | 权限、部署、CI、监控、备份、安全 | 待开发 | @@ -190,20 +190,25 @@ ### M2.2 小区详情页 +状态:已完成。 + 目标: - 查看单个小区的资产画像、评分、历史指标和观察池状态。 交付物: -- 小区详情 API。 -- 小区详情前端视图。 -- 合理买入价区间占位。 +- `GET /api/v1/neighborhoods/{neighborhood_id}/detail?month=YYYY-MM`。 +- 小区详情前端视图,包含资产画像、评分拆解、月度趋势和指标历史。 +- 合理买入价区间占位,以观察价区间形式展示。 +- 从小区排行和板块详情进入小区详情。 +- 详情页可直接加入观察池,并展示当前观察池状态。 验收标准: - 小区详情能展示基础属性、月度指标、评分拆解。 - 可从详情页加入观察池。 +- 小区历史指标可追溯到导入批次和原始文件。 ### M2.3 对比工作台