feat: add neighborhood detail workspace

This commit is contained in:
2026-06-24 13:40:24 +08:00
parent 6ca67e7555
commit ff6e41a6e5
6 changed files with 551 additions and 26 deletions

View File

@@ -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(

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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");
}

View File

@@ -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 对比工作台