feat: add neighborhood detail workspace
This commit is contained in:
@@ -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<AppState>,
|
||||
Path(neighborhood_id): Path<String>,
|
||||
) -> ApiResult<Json<Neighborhood>> {
|
||||
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<AppState>,
|
||||
Path(neighborhood_id): Path<String>,
|
||||
Query(query): Query<MonthQuery>,
|
||||
) -> ApiResult<Json<NeighborhoodDetail>> {
|
||||
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<Option<Neighborhood>, 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(
|
||||
|
||||
@@ -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<i64>,
|
||||
pub raw_artifact_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[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<NeighborhoodScore>,
|
||||
pub monthly_metrics: Vec<NeighborhoodMonthlyMetric>,
|
||||
pub watchlist_items: Vec<WatchlistItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct DataSource {
|
||||
pub source_id: i64,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<NeighborhoodRankingPanel
|
||||
scores={scores.data ?? []}
|
||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||
selectedAreaId={selectedAreaId}
|
||||
selectedNeighborhoodId={detailNeighborhoodId}
|
||||
loading={isLoading}
|
||||
creating={createMutation.isPending}
|
||||
onSelectArea={setSelectedAreaId}
|
||||
onSelectNeighborhood={setDetailNeighborhoodId}
|
||||
onCreate={(payload) => createMutation.mutate(payload)}
|
||||
/>
|
||||
|
||||
<NeighborhoodDetailPanel
|
||||
detail={neighborhoodDetail.data ?? null}
|
||||
loading={neighborhoodDetail.isLoading}
|
||||
creating={createMutation.isPending}
|
||||
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
|
||||
/>
|
||||
|
||||
<WatchlistPanel
|
||||
scores={scores.data ?? []}
|
||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||
@@ -745,11 +775,13 @@ function AreaDetailPanel({
|
||||
loading,
|
||||
creating,
|
||||
onCreateWatchlist,
|
||||
onSelectNeighborhood,
|
||||
}: {
|
||||
detail: AreaDetail | null;
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
onCreateWatchlist: (payload: CreateWatchlistItem) => void;
|
||||
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
return <div className="h-80 rounded-md bg-[var(--muted)]" />;
|
||||
@@ -826,6 +858,7 @@ function AreaDetailPanel({
|
||||
scores={detail.neighborhood_scores}
|
||||
creating={creating}
|
||||
onCreate={onCreateWatchlist}
|
||||
onSelectNeighborhood={onSelectNeighborhood}
|
||||
/>
|
||||
</div>
|
||||
{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({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{scores.slice(0, 6).map((score) => (
|
||||
<TableRow key={score.neighborhood_id}>
|
||||
<TableRow
|
||||
key={score.neighborhood_id}
|
||||
className="cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => onSelectNeighborhood(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>
|
||||
@@ -964,13 +1003,14 @@ function AreaNeighborhoodTable({
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={creating}
|
||||
onClick={() =>
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
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="加入观察池"
|
||||
>
|
||||
@@ -989,17 +1029,21 @@ function NeighborhoodRankingPanel({
|
||||
scores,
|
||||
neighborhoodScores,
|
||||
selectedAreaId,
|
||||
selectedNeighborhoodId,
|
||||
loading,
|
||||
creating,
|
||||
onSelectArea,
|
||||
onSelectNeighborhood,
|
||||
onCreate,
|
||||
}: {
|
||||
scores: AreaScore[];
|
||||
neighborhoodScores: NeighborhoodScore[];
|
||||
selectedAreaId: string;
|
||||
selectedNeighborhoodId: string;
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
onSelectArea: (areaId: string) => void;
|
||||
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||||
onCreate: (payload: CreateWatchlistItem) => void;
|
||||
}) {
|
||||
const filteredScores = selectedAreaId
|
||||
@@ -1033,6 +1077,8 @@ function NeighborhoodRankingPanel({
|
||||
scores={filteredScores}
|
||||
loading={loading}
|
||||
creating={creating}
|
||||
selectedNeighborhoodId={selectedNeighborhoodId}
|
||||
onSelectNeighborhood={onSelectNeighborhood}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -1044,11 +1090,15 @@ function NeighborhoodScoreTable({
|
||||
scores,
|
||||
loading,
|
||||
creating,
|
||||
selectedNeighborhoodId,
|
||||
onSelectNeighborhood,
|
||||
onCreate,
|
||||
}: {
|
||||
scores: NeighborhoodScore[];
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
selectedNeighborhoodId: string;
|
||||
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||||
onCreate: (payload: CreateWatchlistItem) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
@@ -1076,7 +1126,15 @@ function NeighborhoodScoreTable({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{scores.map((score) => (
|
||||
<TableRow key={score.neighborhood_id}>
|
||||
<TableRow
|
||||
key={score.neighborhood_id}
|
||||
className={
|
||||
selectedNeighborhoodId === score.neighborhood_id
|
||||
? "cursor-pointer bg-teal-50/60"
|
||||
: "cursor-pointer hover:bg-slate-50"
|
||||
}
|
||||
onClick={() => onSelectNeighborhood(score.neighborhood_id)}
|
||||
>
|
||||
<TableCell className="font-medium">{score.name}</TableCell>
|
||||
<TableCell>{score.area_name}</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
@@ -1097,13 +1155,14 @@ function NeighborhoodScoreTable({
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={creating}
|
||||
onClick={() =>
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
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="加入观察池"
|
||||
>
|
||||
@@ -1117,6 +1176,286 @@ function NeighborhoodScoreTable({
|
||||
);
|
||||
}
|
||||
|
||||
function NeighborhoodDetailPanel({
|
||||
detail,
|
||||
loading,
|
||||
creating,
|
||||
onCreateWatchlist,
|
||||
}: {
|
||||
detail: NeighborhoodDetail | 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 latestMetric = detail.monthly_metrics[0];
|
||||
const referencePrice = score?.transaction_price_psm ?? latestMetric?.transaction_price_psm ?? 0;
|
||||
const lowerWatchPrice = referencePrice > 0 ? Math.round(referencePrice * 0.92) : null;
|
||||
const upperWatchPrice = referencePrice > 0 ? Math.round(referencePrice * 0.96) : null;
|
||||
const activeWatchlistItem = detail.watchlist_items[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.area_name} · {detail.profile.property_type}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{score ? <Badge variant="success">{score.recommendation}</Badge> : null}
|
||||
{activeWatchlistItem ? <Badge variant="muted">{activeWatchlistItem.status}</Badge> : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={creating}
|
||||
onClick={() =>
|
||||
onCreateWatchlist({
|
||||
neighborhood_id: detail.profile.neighborhood_id,
|
||||
target_price_psm: upperWatchPrice ?? 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={score?.month ?? MONTH}
|
||||
/>
|
||||
<InsightRow
|
||||
label="观察价区间/㎡"
|
||||
value={
|
||||
lowerWatchPrice && upperWatchPrice
|
||||
? `${formatPrice(lowerWatchPrice)} - ${formatPrice(upperWatchPrice)}`
|
||||
: "-"
|
||||
}
|
||||
detail="占位"
|
||||
/>
|
||||
<InsightRow
|
||||
label="建成年份"
|
||||
value={detail.profile.built_year ? `${detail.profile.built_year}` : "-"}
|
||||
detail={score ? `${formatNumber(score.building_age_score)} 分` : "-"}
|
||||
/>
|
||||
<InsightRow
|
||||
label="地铁距离"
|
||||
value={
|
||||
detail.profile.metro_distance_m ? `${detail.profile.metro_distance_m} 米` : "-"
|
||||
}
|
||||
detail={detail.profile.school_quality ?? "-"}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="h-72 rounded-md border border-[var(--border)] p-3">
|
||||
<NeighborhoodMetricTrendChart metrics={detail.monthly_metrics} />
|
||||
</div>
|
||||
<NeighborhoodScoreBreakdown score={score} />
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<NeighborhoodMetricHistoryTable metrics={detail.monthly_metrics} />
|
||||
<NeighborhoodProfileTable detail={detail} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function NeighborhoodMetricTrendChart({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: NeighborhoodDetail["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)), "成交均价/㎡"];
|
||||
}
|
||||
if (name === "listing_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="price"
|
||||
type="monotone"
|
||||
dataKey="listing_price_psm"
|
||||
stroke="#2563eb"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="volume"
|
||||
type="monotone"
|
||||
dataKey="transaction_count"
|
||||
stroke="#b45309"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||
暂无评分拆解
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--border)] p-4">
|
||||
<p className="text-sm font-semibold text-slate-950">评分拆解</p>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{items.map(([label, value]) => (
|
||||
<div key={label} className="grid gap-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-[var(--muted-foreground)]">{label}</span>
|
||||
<span className="font-medium text-slate-950">{formatNumber(Number(value))}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[var(--muted)]">
|
||||
<div
|
||||
className="h-2 rounded-full bg-[var(--primary)]"
|
||||
style={{ width: `${Math.max(0, Math.min(100, Number(value)))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NeighborhoodMetricHistoryTable({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: NeighborhoodDetail["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.neighborhood_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.available_units}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{rows.map(([label, value]) => (
|
||||
<TableRow key={label}>
|
||||
<TableCell className="text-[var(--muted-foreground)]">{label}</TableCell>
|
||||
<TableCell className="text-right font-medium">{value}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchlistPanel({
|
||||
scores,
|
||||
neighborhoodScores,
|
||||
|
||||
@@ -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<Neighborho
|
||||
return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`);
|
||||
}
|
||||
|
||||
export async function fetchNeighborhoodDetail(
|
||||
neighborhoodId: string,
|
||||
month: string,
|
||||
): Promise<NeighborhoodDetail> {
|
||||
return fetchJson(
|
||||
`/api/v1/neighborhoods/${encodeURIComponent(neighborhoodId)}/detail?month=${encodeURIComponent(month)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
|
||||
return fetchJson("/api/v1/watchlist");
|
||||
}
|
||||
|
||||
@@ -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 对比工作台
|
||||
|
||||
|
||||
Reference in New Issue
Block a user