feat: add area detail workspace

This commit is contained in:
2026-06-24 12:03:17 +08:00
parent db09615004
commit 6ca67e7555
6 changed files with 538 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -170,6 +170,8 @@
### M2.1 板块详情页 ### M2.1 板块详情页
状态:已完成。
目标: 目标:
- 从市场总览进入单个板块,查看成交、挂牌、租金、供应、小区排行。 - 从市场总览进入单个板块,查看成交、挂牌、租金、供应、小区排行。
@@ -179,6 +181,7 @@
- 板块详情 API。 - 板块详情 API。
- 板块详情前端视图。 - 板块详情前端视图。
- 板块内小区排行和观察池入口。 - 板块内小区排行和观察池入口。
- 评分数据追溯展示。
验收标准: 验收标准: