feat: add area detail workspace
This commit is contained in:
@@ -5,13 +5,13 @@ 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::{
|
||||||
AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse,
|
AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery,
|
||||||
AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
|
AreaScoreModelRunResponse, AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource,
|
||||||
CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun, ImportExecutionRequest,
|
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
|
||||||
ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun,
|
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
||||||
IngestionRunQuery, MarketOverview, ModelRun, MonthQuery, Neighborhood, NeighborhoodQuery,
|
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
||||||
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
|
MonthQuery, Neighborhood, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
|
||||||
WatchlistItem,
|
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;
|
||||||
@@ -233,7 +233,19 @@ pub async fn get_area_score_lineage(
|
|||||||
Query(query): Query<AreaScoreLineageQuery>,
|
Query(query): Query<AreaScoreLineageQuery>,
|
||||||
) -> ApiResult<Json<AreaScoreLineage>> {
|
) -> ApiResult<Json<AreaScoreLineage>> {
|
||||||
validate_month(&query.month)?;
|
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<Option<AreaScoreLineage>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, AreaScoreLineage>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
s.area_id,
|
s.area_id,
|
||||||
@@ -261,12 +273,151 @@ pub async fn get_area_score_lineage(
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(area_id)
|
.bind(area_id)
|
||||||
.bind(query.month)
|
.bind(month)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_area_detail(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(area_id): Path<String>,
|
||||||
|
Query(query): Query<MonthQuery>,
|
||||||
|
) -> ApiResult<Json<AreaDetail>> {
|
||||||
|
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)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.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<Vec<NeighborhoodScore>, 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(
|
pub async fn list_neighborhoods(
|
||||||
|
|||||||
@@ -194,6 +194,43 @@ pub struct AreaScoreLineage {
|
|||||||
pub score_computed_at: String,
|
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<i64>,
|
||||||
|
pub raw_artifact_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AreaDetail {
|
||||||
|
pub profile: AreaProfile,
|
||||||
|
pub current_score: Option<AreaScore>,
|
||||||
|
pub score_lineage: Option<AreaScoreLineage>,
|
||||||
|
pub monthly_metrics: Vec<AreaMonthlyMetric>,
|
||||||
|
pub neighborhood_scores: Vec<NeighborhoodScore>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
pub struct Neighborhood {
|
pub struct Neighborhood {
|
||||||
pub neighborhood_id: String,
|
pub neighborhood_id: String,
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ 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, create_area_score_model_run, create_data_source, create_ingestion_run,
|
||||||
create_raw_artifact, create_watchlist_item, execute_import, finish_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,
|
get_area_detail, get_area_score_lineage, get_neighborhood, health, list_area_scores,
|
||||||
list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts,
|
list_data_sources, list_ingestion_runs, list_neighborhood_scores, list_neighborhoods,
|
||||||
list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import,
|
list_raw_artifacts, list_watchlist_items, market_overview, ready, update_watchlist_item,
|
||||||
|
validate_import,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/areas/{area_id}/score-lineage",
|
"/areas/{area_id}/score-lineage",
|
||||||
get(get_area_score_lineage),
|
get(get_area_score_lineage),
|
||||||
)
|
)
|
||||||
|
.route("/areas/{area_id}/detail", get(get_area_detail))
|
||||||
.route("/neighborhoods", get(list_neighborhoods))
|
.route("/neighborhoods", get(list_neighborhoods))
|
||||||
.route("/neighborhoods/scores", get(list_neighborhood_scores))
|
.route("/neighborhoods/scores", get(list_neighborhood_scores))
|
||||||
.route("/neighborhoods/{neighborhood_id}", get(get_neighborhood))
|
.route("/neighborhoods/{neighborhood_id}", get(get_neighborhood))
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -31,6 +33,7 @@ import {
|
|||||||
createWatchlistItem,
|
createWatchlistItem,
|
||||||
fetchDataSources,
|
fetchDataSources,
|
||||||
fetchAreaScores,
|
fetchAreaScores,
|
||||||
|
fetchAreaDetail,
|
||||||
fetchIngestionRuns,
|
fetchIngestionRuns,
|
||||||
fetchMarketOverview,
|
fetchMarketOverview,
|
||||||
fetchNeighborhoodScores,
|
fetchNeighborhoodScores,
|
||||||
@@ -40,6 +43,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
AreaScore,
|
AreaScore,
|
||||||
|
AreaDetail,
|
||||||
CreateDataSource,
|
CreateDataSource,
|
||||||
CreateIngestionRun,
|
CreateIngestionRun,
|
||||||
CreateRawArtifact,
|
CreateRawArtifact,
|
||||||
@@ -68,6 +72,7 @@ const MONTH = "2026-05";
|
|||||||
export function MarketDashboard() {
|
export function MarketDashboard() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedAreaId, setSelectedAreaId] = useState("");
|
const [selectedAreaId, setSelectedAreaId] = useState("");
|
||||||
|
const [detailAreaId, setDetailAreaId] = useState("qiantan");
|
||||||
const overview = useQuery({
|
const overview = useQuery({
|
||||||
queryKey: ["market-overview", MONTH],
|
queryKey: ["market-overview", MONTH],
|
||||||
queryFn: () => fetchMarketOverview(MONTH),
|
queryFn: () => fetchMarketOverview(MONTH),
|
||||||
@@ -76,6 +81,11 @@ export function MarketDashboard() {
|
|||||||
queryKey: ["area-scores", MONTH],
|
queryKey: ["area-scores", MONTH],
|
||||||
queryFn: () => fetchAreaScores(MONTH),
|
queryFn: () => fetchAreaScores(MONTH),
|
||||||
});
|
});
|
||||||
|
const areaDetail = useQuery({
|
||||||
|
queryKey: ["area-detail", detailAreaId, MONTH],
|
||||||
|
queryFn: () => fetchAreaDetail(detailAreaId, MONTH),
|
||||||
|
enabled: Boolean(detailAreaId),
|
||||||
|
});
|
||||||
const neighborhoodScores = useQuery({
|
const neighborhoodScores = useQuery({
|
||||||
queryKey: ["neighborhood-scores", MONTH],
|
queryKey: ["neighborhood-scores", MONTH],
|
||||||
queryFn: () => fetchNeighborhoodScores(MONTH),
|
queryFn: () => fetchNeighborhoodScores(MONTH),
|
||||||
@@ -140,6 +150,7 @@ export function MarketDashboard() {
|
|||||||
const isLoading =
|
const isLoading =
|
||||||
overview.isLoading ||
|
overview.isLoading ||
|
||||||
scores.isLoading ||
|
scores.isLoading ||
|
||||||
|
areaDetail.isLoading ||
|
||||||
neighborhoodScores.isLoading ||
|
neighborhoodScores.isLoading ||
|
||||||
watchlist.isLoading ||
|
watchlist.isLoading ||
|
||||||
dataSources.isLoading ||
|
dataSources.isLoading ||
|
||||||
@@ -148,6 +159,7 @@ export function MarketDashboard() {
|
|||||||
const hasError =
|
const hasError =
|
||||||
overview.isError ||
|
overview.isError ||
|
||||||
scores.isError ||
|
scores.isError ||
|
||||||
|
areaDetail.isError ||
|
||||||
neighborhoodScores.isError ||
|
neighborhoodScores.isError ||
|
||||||
watchlist.isError ||
|
watchlist.isError ||
|
||||||
dataSources.isError ||
|
dataSources.isError ||
|
||||||
@@ -171,6 +183,7 @@ export function MarketDashboard() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
void overview.refetch();
|
void overview.refetch();
|
||||||
void scores.refetch();
|
void scores.refetch();
|
||||||
|
void areaDetail.refetch();
|
||||||
void neighborhoodScores.refetch();
|
void neighborhoodScores.refetch();
|
||||||
void watchlist.refetch();
|
void watchlist.refetch();
|
||||||
void dataSources.refetch();
|
void dataSources.refetch();
|
||||||
@@ -268,10 +281,22 @@ export function MarketDashboard() {
|
|||||||
<CardTitle>板块明细</CardTitle>
|
<CardTitle>板块明细</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="overflow-x-auto p-0">
|
<CardContent className="overflow-x-auto p-0">
|
||||||
<AreaScoreTable scores={scores.data ?? []} loading={isLoading} />
|
<AreaScoreTable
|
||||||
|
scores={scores.data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
selectedAreaId={detailAreaId}
|
||||||
|
onSelectArea={setDetailAreaId}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<AreaDetailPanel
|
||||||
|
detail={areaDetail.data ?? null}
|
||||||
|
loading={areaDetail.isLoading}
|
||||||
|
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
|
||||||
|
creating={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
<NeighborhoodRankingPanel
|
<NeighborhoodRankingPanel
|
||||||
scores={scores.data ?? []}
|
scores={scores.data ?? []}
|
||||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||||
@@ -715,6 +740,251 @@ function RawArtifactTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AreaDetailPanel({
|
||||||
|
detail,
|
||||||
|
loading,
|
||||||
|
creating,
|
||||||
|
onCreateWatchlist,
|
||||||
|
}: {
|
||||||
|
detail: AreaDetail | null;
|
||||||
|
loading: boolean;
|
||||||
|
creating: boolean;
|
||||||
|
onCreateWatchlist: (payload: CreateWatchlistItem) => void;
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="h-80 rounded-md bg-[var(--muted)]" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = detail.current_score;
|
||||||
|
const lineage = detail.score_lineage;
|
||||||
|
const latestMetric = detail.monthly_metrics[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{detail.profile.name}板块详情</CardTitle>
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||||
|
{detail.profile.district} · {detail.profile.segment}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{score ? <Badge variant="success">{score.recommendation}</Badge> : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={creating}
|
||||||
|
onClick={() =>
|
||||||
|
onCreateWatchlist({
|
||||||
|
area_id: detail.profile.area_id,
|
||||||
|
target_price_psm: score
|
||||||
|
? Math.round(score.transaction_price_psm * 0.96)
|
||||||
|
: undefined,
|
||||||
|
notes: `${MONTH} 板块详情:${score?.recommendation ?? "待评分"}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label="加入观察池"
|
||||||
|
title="加入观察池"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||||
|
<InsightRow
|
||||||
|
label="综合分"
|
||||||
|
value={score ? formatNumber(score.investment_score) : "-"}
|
||||||
|
detail={lineage?.model_version ?? "gold"}
|
||||||
|
/>
|
||||||
|
<InsightRow
|
||||||
|
label="成交均价/㎡"
|
||||||
|
value={latestMetric ? formatPrice(latestMetric.transaction_price_psm) : "-"}
|
||||||
|
detail={latestMetric?.month ?? MONTH}
|
||||||
|
/>
|
||||||
|
<InsightRow
|
||||||
|
label="挂牌压力"
|
||||||
|
value={score ? formatNumber(score.listing_pressure_ratio, 2) : "-"}
|
||||||
|
detail={latestMetric ? `${latestMetric.listing_count} 套` : "-"}
|
||||||
|
/>
|
||||||
|
<InsightRow
|
||||||
|
label="数据追溯"
|
||||||
|
value={lineage?.source_name ?? "-"}
|
||||||
|
detail={lineage?.raw_artifact_id ? `#${lineage.raw_artifact_id}` : "-"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="h-72 rounded-md border border-[var(--border)] p-3">
|
||||||
|
<AreaMetricTrendChart metrics={detail.monthly_metrics} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<AreaMetricHistoryTable metrics={detail.monthly_metrics} />
|
||||||
|
<AreaNeighborhoodTable
|
||||||
|
scores={detail.neighborhood_scores}
|
||||||
|
creating={creating}
|
||||||
|
onCreate={onCreateWatchlist}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{lineage ? (
|
||||||
|
<div className="rounded-md border border-[var(--border)] px-3 py-2 text-xs text-[var(--muted-foreground)]">
|
||||||
|
SHA-256:<span className="font-mono">{lineage.sha256 ?? "-"}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AreaMetricTrendChart({ metrics }: { metrics: AreaDetail["monthly_metrics"] }) {
|
||||||
|
const data = [...metrics].reverse();
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center text-sm text-[var(--muted-foreground)]">
|
||||||
|
暂无月度指标
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
|
||||||
|
<CartesianGrid stroke="#e2e8f0" vertical={false} />
|
||||||
|
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis yAxisId="price" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis
|
||||||
|
yAxisId="volume"
|
||||||
|
orientation="right"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value, name) => {
|
||||||
|
if (name === "transaction_price_psm") {
|
||||||
|
return [formatPrice(Number(value)), "成交均价/㎡"];
|
||||||
|
}
|
||||||
|
return [formatNumber(Number(value)), "成交套数"];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="price"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="transaction_price_psm"
|
||||||
|
stroke="#0f766e"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="volume"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="transaction_count"
|
||||||
|
stroke="#b45309"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AreaMetricHistoryTable({ metrics }: { metrics: AreaDetail["monthly_metrics"] }) {
|
||||||
|
if (metrics.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="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>月份</TableHead>
|
||||||
|
<TableHead className="text-right">成交</TableHead>
|
||||||
|
<TableHead className="text-right">均价/㎡</TableHead>
|
||||||
|
<TableHead className="text-right">挂牌</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{metrics.slice(0, 6).map((metric) => (
|
||||||
|
<TableRow key={`${metric.area_id}-${metric.month}`}>
|
||||||
|
<TableCell className="font-medium">{metric.month}</TableCell>
|
||||||
|
<TableCell className="text-right">{metric.transaction_count}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatPrice(metric.transaction_price_psm)}</TableCell>
|
||||||
|
<TableCell className="text-right">{metric.listing_count}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AreaNeighborhoodTable({
|
||||||
|
scores,
|
||||||
|
creating,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
scores: NeighborhoodScore[];
|
||||||
|
creating: boolean;
|
||||||
|
onCreate: (payload: CreateWatchlistItem) => void;
|
||||||
|
}) {
|
||||||
|
if (scores.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="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>小区</TableHead>
|
||||||
|
<TableHead className="text-right">分数</TableHead>
|
||||||
|
<TableHead className="text-right">均价/㎡</TableHead>
|
||||||
|
<TableHead className="w-14" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{scores.slice(0, 6).map((score) => (
|
||||||
|
<TableRow key={score.neighborhood_id}>
|
||||||
|
<TableCell className="font-medium">{score.name}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatNumber(score.investment_score)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatPrice(score.transaction_price_psm)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={creating}
|
||||||
|
onClick={() =>
|
||||||
|
onCreate({
|
||||||
|
neighborhood_id: score.neighborhood_id,
|
||||||
|
target_price_psm: Math.round(score.transaction_price_psm * 0.96),
|
||||||
|
notes: `${score.month} 板块详情:${score.recommendation}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label="加入观察池"
|
||||||
|
title="加入观察池"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NeighborhoodRankingPanel({
|
function NeighborhoodRankingPanel({
|
||||||
scores,
|
scores,
|
||||||
neighborhoodScores,
|
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) {
|
if (loading) {
|
||||||
return <div className="h-48 bg-[var(--muted)]" />;
|
return <div className="h-48 bg-[var(--muted)]" />;
|
||||||
}
|
}
|
||||||
@@ -1113,7 +1393,15 @@ function AreaScoreTable({ scores, loading }: { scores: AreaScore[]; loading: boo
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{scores.map((score) => (
|
{scores.map((score) => (
|
||||||
<TableRow key={score.area_id}>
|
<TableRow
|
||||||
|
key={score.area_id}
|
||||||
|
className={
|
||||||
|
selectedAreaId === score.area_id
|
||||||
|
? "cursor-pointer bg-teal-50/60"
|
||||||
|
: "cursor-pointer hover:bg-slate-50"
|
||||||
|
}
|
||||||
|
onClick={() => onSelectArea(score.area_id)}
|
||||||
|
>
|
||||||
<TableCell className="font-medium">{score.name}</TableCell>
|
<TableCell className="font-medium">{score.name}</TableCell>
|
||||||
<TableCell>{score.district}</TableCell>
|
<TableCell>{score.district}</TableCell>
|
||||||
<TableCell className="text-right font-semibold">
|
<TableCell className="text-right font-semibold">
|
||||||
|
|||||||
@@ -219,6 +219,40 @@ export type AreaScoreLineage = {
|
|||||||
score_computed_at: string;
|
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 =
|
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";
|
||||||
|
|
||||||
@@ -230,6 +264,12 @@ export async function fetchAreaScores(month: string): Promise<AreaScore[]> {
|
|||||||
return fetchJson(`/api/v1/areas/scores?month=${encodeURIComponent(month)}`);
|
return fetchJson(`/api/v1/areas/scores?month=${encodeURIComponent(month)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAreaDetail(areaId: string, month: string): Promise<AreaDetail> {
|
||||||
|
return fetchJson(
|
||||||
|
`/api/v1/areas/${encodeURIComponent(areaId)}/detail?month=${encodeURIComponent(month)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchNeighborhoodScores(month: string): Promise<NeighborhoodScore[]> {
|
export async function fetchNeighborhoodScores(month: string): Promise<NeighborhoodScore[]> {
|
||||||
return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`);
|
return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,8 @@
|
|||||||
|
|
||||||
### M2.1 板块详情页
|
### M2.1 板块详情页
|
||||||
|
|
||||||
|
状态:已完成。
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
|
|
||||||
- 从市场总览进入单个板块,查看成交、挂牌、租金、供应、小区排行。
|
- 从市场总览进入单个板块,查看成交、挂牌、租金、供应、小区排行。
|
||||||
@@ -179,6 +181,7 @@
|
|||||||
- 板块详情 API。
|
- 板块详情 API。
|
||||||
- 板块详情前端视图。
|
- 板块详情前端视图。
|
||||||
- 板块内小区排行和观察池入口。
|
- 板块内小区排行和观察池入口。
|
||||||
|
- 评分数据追溯展示。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user