feat: add neighborhood detail workspace
This commit is contained in:
@@ -10,8 +10,9 @@ use crate::models::{
|
|||||||
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
|
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
|
||||||
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
||||||
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
||||||
MonthQuery, Neighborhood, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
|
MonthQuery, Neighborhood, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
|
||||||
RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem,
|
NeighborhoodScore, NeighborhoodScoreQuery, 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;
|
||||||
@@ -454,7 +455,118 @@ pub async fn get_neighborhood(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(neighborhood_id): Path<String>,
|
Path(neighborhood_id): Path<String>,
|
||||||
) -> ApiResult<Json<Neighborhood>> {
|
) -> 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#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
n.neighborhood_id,
|
n.neighborhood_id,
|
||||||
@@ -474,10 +586,7 @@ pub async fn get_neighborhood(
|
|||||||
)
|
)
|
||||||
.bind(neighborhood_id)
|
.bind(neighborhood_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.await
|
||||||
.ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(neighborhood))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_neighborhood_scores(
|
pub async fn list_neighborhood_scores(
|
||||||
|
|||||||
@@ -245,6 +245,22 @@ pub struct Neighborhood {
|
|||||||
pub notes: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
pub struct NeighborhoodScore {
|
pub struct NeighborhoodScore {
|
||||||
pub neighborhood_id: String,
|
pub neighborhood_id: String,
|
||||||
@@ -271,6 +287,14 @@ pub struct NeighborhoodScore {
|
|||||||
pub available_units: i32,
|
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)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
pub struct DataSource {
|
pub struct DataSource {
|
||||||
pub source_id: i64,
|
pub source_id: i64,
|
||||||
|
|||||||
@@ -6,10 +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_detail, get_area_score_lineage, get_neighborhood, health, list_area_scores,
|
get_area_detail, get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health,
|
||||||
list_data_sources, list_ingestion_runs, list_neighborhood_scores, list_neighborhoods,
|
list_area_scores, list_data_sources, list_ingestion_runs, list_neighborhood_scores,
|
||||||
list_raw_artifacts, list_watchlist_items, market_overview, ready, update_watchlist_item,
|
list_neighborhoods, list_raw_artifacts, list_watchlist_items, market_overview, ready,
|
||||||
validate_import,
|
update_watchlist_item, validate_import,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
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("/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}/detail",
|
||||||
|
get(get_neighborhood_detail),
|
||||||
|
)
|
||||||
.route("/neighborhoods/{neighborhood_id}", get(get_neighborhood))
|
.route("/neighborhoods/{neighborhood_id}", get(get_neighborhood))
|
||||||
.route(
|
.route(
|
||||||
"/watchlist",
|
"/watchlist",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
fetchAreaDetail,
|
fetchAreaDetail,
|
||||||
fetchIngestionRuns,
|
fetchIngestionRuns,
|
||||||
fetchMarketOverview,
|
fetchMarketOverview,
|
||||||
|
fetchNeighborhoodDetail,
|
||||||
fetchNeighborhoodScores,
|
fetchNeighborhoodScores,
|
||||||
fetchRawArtifacts,
|
fetchRawArtifacts,
|
||||||
fetchWatchlistItems,
|
fetchWatchlistItems,
|
||||||
@@ -50,6 +51,7 @@ import type {
|
|||||||
CreateWatchlistItem,
|
CreateWatchlistItem,
|
||||||
DataSource,
|
DataSource,
|
||||||
IngestionRun,
|
IngestionRun,
|
||||||
|
NeighborhoodDetail,
|
||||||
NeighborhoodScore,
|
NeighborhoodScore,
|
||||||
RawArtifact,
|
RawArtifact,
|
||||||
WatchlistItem,
|
WatchlistItem,
|
||||||
@@ -73,6 +75,7 @@ export function MarketDashboard() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedAreaId, setSelectedAreaId] = useState("");
|
const [selectedAreaId, setSelectedAreaId] = useState("");
|
||||||
const [detailAreaId, setDetailAreaId] = useState("qiantan");
|
const [detailAreaId, setDetailAreaId] = useState("qiantan");
|
||||||
|
const [detailNeighborhoodId, setDetailNeighborhoodId] = useState("");
|
||||||
const overview = useQuery({
|
const overview = useQuery({
|
||||||
queryKey: ["market-overview", MONTH],
|
queryKey: ["market-overview", MONTH],
|
||||||
queryFn: () => fetchMarketOverview(MONTH),
|
queryFn: () => fetchMarketOverview(MONTH),
|
||||||
@@ -90,6 +93,11 @@ export function MarketDashboard() {
|
|||||||
queryKey: ["neighborhood-scores", MONTH],
|
queryKey: ["neighborhood-scores", MONTH],
|
||||||
queryFn: () => fetchNeighborhoodScores(MONTH),
|
queryFn: () => fetchNeighborhoodScores(MONTH),
|
||||||
});
|
});
|
||||||
|
const neighborhoodDetail = useQuery({
|
||||||
|
queryKey: ["neighborhood-detail", detailNeighborhoodId, MONTH],
|
||||||
|
queryFn: () => fetchNeighborhoodDetail(detailNeighborhoodId, MONTH),
|
||||||
|
enabled: Boolean(detailNeighborhoodId),
|
||||||
|
});
|
||||||
const watchlist = useQuery({
|
const watchlist = useQuery({
|
||||||
queryKey: ["watchlist"],
|
queryKey: ["watchlist"],
|
||||||
queryFn: fetchWatchlistItems,
|
queryFn: fetchWatchlistItems,
|
||||||
@@ -109,7 +117,10 @@ export function MarketDashboard() {
|
|||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: createWatchlistItem,
|
mutationFn: createWatchlistItem,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["watchlist"] });
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["watchlist"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["neighborhood-detail"] }),
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const archiveMutation = useMutation({
|
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 =
|
const isLoading =
|
||||||
overview.isLoading ||
|
overview.isLoading ||
|
||||||
scores.isLoading ||
|
scores.isLoading ||
|
||||||
areaDetail.isLoading ||
|
areaDetail.isLoading ||
|
||||||
|
neighborhoodDetail.isLoading ||
|
||||||
neighborhoodScores.isLoading ||
|
neighborhoodScores.isLoading ||
|
||||||
watchlist.isLoading ||
|
watchlist.isLoading ||
|
||||||
dataSources.isLoading ||
|
dataSources.isLoading ||
|
||||||
@@ -160,6 +178,7 @@ export function MarketDashboard() {
|
|||||||
overview.isError ||
|
overview.isError ||
|
||||||
scores.isError ||
|
scores.isError ||
|
||||||
areaDetail.isError ||
|
areaDetail.isError ||
|
||||||
|
neighborhoodDetail.isError ||
|
||||||
neighborhoodScores.isError ||
|
neighborhoodScores.isError ||
|
||||||
watchlist.isError ||
|
watchlist.isError ||
|
||||||
dataSources.isError ||
|
dataSources.isError ||
|
||||||
@@ -184,6 +203,7 @@ export function MarketDashboard() {
|
|||||||
void overview.refetch();
|
void overview.refetch();
|
||||||
void scores.refetch();
|
void scores.refetch();
|
||||||
void areaDetail.refetch();
|
void areaDetail.refetch();
|
||||||
|
void neighborhoodDetail.refetch();
|
||||||
void neighborhoodScores.refetch();
|
void neighborhoodScores.refetch();
|
||||||
void watchlist.refetch();
|
void watchlist.refetch();
|
||||||
void dataSources.refetch();
|
void dataSources.refetch();
|
||||||
@@ -295,18 +315,28 @@ export function MarketDashboard() {
|
|||||||
loading={areaDetail.isLoading}
|
loading={areaDetail.isLoading}
|
||||||
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
|
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
|
||||||
creating={createMutation.isPending}
|
creating={createMutation.isPending}
|
||||||
|
onSelectNeighborhood={setDetailNeighborhoodId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NeighborhoodRankingPanel
|
<NeighborhoodRankingPanel
|
||||||
scores={scores.data ?? []}
|
scores={scores.data ?? []}
|
||||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||||
selectedAreaId={selectedAreaId}
|
selectedAreaId={selectedAreaId}
|
||||||
|
selectedNeighborhoodId={detailNeighborhoodId}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
creating={createMutation.isPending}
|
creating={createMutation.isPending}
|
||||||
onSelectArea={setSelectedAreaId}
|
onSelectArea={setSelectedAreaId}
|
||||||
|
onSelectNeighborhood={setDetailNeighborhoodId}
|
||||||
onCreate={(payload) => createMutation.mutate(payload)}
|
onCreate={(payload) => createMutation.mutate(payload)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<NeighborhoodDetailPanel
|
||||||
|
detail={neighborhoodDetail.data ?? null}
|
||||||
|
loading={neighborhoodDetail.isLoading}
|
||||||
|
creating={createMutation.isPending}
|
||||||
|
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
|
||||||
|
/>
|
||||||
|
|
||||||
<WatchlistPanel
|
<WatchlistPanel
|
||||||
scores={scores.data ?? []}
|
scores={scores.data ?? []}
|
||||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||||
@@ -745,11 +775,13 @@ function AreaDetailPanel({
|
|||||||
loading,
|
loading,
|
||||||
creating,
|
creating,
|
||||||
onCreateWatchlist,
|
onCreateWatchlist,
|
||||||
|
onSelectNeighborhood,
|
||||||
}: {
|
}: {
|
||||||
detail: AreaDetail | null;
|
detail: AreaDetail | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
onCreateWatchlist: (payload: CreateWatchlistItem) => void;
|
onCreateWatchlist: (payload: CreateWatchlistItem) => void;
|
||||||
|
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="h-80 rounded-md bg-[var(--muted)]" />;
|
return <div className="h-80 rounded-md bg-[var(--muted)]" />;
|
||||||
@@ -826,6 +858,7 @@ function AreaDetailPanel({
|
|||||||
scores={detail.neighborhood_scores}
|
scores={detail.neighborhood_scores}
|
||||||
creating={creating}
|
creating={creating}
|
||||||
onCreate={onCreateWatchlist}
|
onCreate={onCreateWatchlist}
|
||||||
|
onSelectNeighborhood={onSelectNeighborhood}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{lineage ? (
|
{lineage ? (
|
||||||
@@ -930,10 +963,12 @@ function AreaNeighborhoodTable({
|
|||||||
scores,
|
scores,
|
||||||
creating,
|
creating,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onSelectNeighborhood,
|
||||||
}: {
|
}: {
|
||||||
scores: NeighborhoodScore[];
|
scores: NeighborhoodScore[];
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
onCreate: (payload: CreateWatchlistItem) => void;
|
onCreate: (payload: CreateWatchlistItem) => void;
|
||||||
|
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (scores.length === 0) {
|
if (scores.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -956,7 +991,11 @@ function AreaNeighborhoodTable({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{scores.slice(0, 6).map((score) => (
|
{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="font-medium">{score.name}</TableCell>
|
||||||
<TableCell className="text-right">{formatNumber(score.investment_score)}</TableCell>
|
<TableCell className="text-right">{formatNumber(score.investment_score)}</TableCell>
|
||||||
<TableCell className="text-right">{formatPrice(score.transaction_price_psm)}</TableCell>
|
<TableCell className="text-right">{formatPrice(score.transaction_price_psm)}</TableCell>
|
||||||
@@ -964,13 +1003,14 @@ function AreaNeighborhoodTable({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
onClick={() =>
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
onCreate({
|
onCreate({
|
||||||
neighborhood_id: score.neighborhood_id,
|
neighborhood_id: score.neighborhood_id,
|
||||||
target_price_psm: Math.round(score.transaction_price_psm * 0.96),
|
target_price_psm: Math.round(score.transaction_price_psm * 0.96),
|
||||||
notes: `${score.month} 板块详情:${score.recommendation}`,
|
notes: `${score.month} 板块详情:${score.recommendation}`,
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
aria-label="加入观察池"
|
aria-label="加入观察池"
|
||||||
title="加入观察池"
|
title="加入观察池"
|
||||||
>
|
>
|
||||||
@@ -989,17 +1029,21 @@ function NeighborhoodRankingPanel({
|
|||||||
scores,
|
scores,
|
||||||
neighborhoodScores,
|
neighborhoodScores,
|
||||||
selectedAreaId,
|
selectedAreaId,
|
||||||
|
selectedNeighborhoodId,
|
||||||
loading,
|
loading,
|
||||||
creating,
|
creating,
|
||||||
onSelectArea,
|
onSelectArea,
|
||||||
|
onSelectNeighborhood,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: {
|
}: {
|
||||||
scores: AreaScore[];
|
scores: AreaScore[];
|
||||||
neighborhoodScores: NeighborhoodScore[];
|
neighborhoodScores: NeighborhoodScore[];
|
||||||
selectedAreaId: string;
|
selectedAreaId: string;
|
||||||
|
selectedNeighborhoodId: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
onSelectArea: (areaId: string) => void;
|
onSelectArea: (areaId: string) => void;
|
||||||
|
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||||||
onCreate: (payload: CreateWatchlistItem) => void;
|
onCreate: (payload: CreateWatchlistItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const filteredScores = selectedAreaId
|
const filteredScores = selectedAreaId
|
||||||
@@ -1033,6 +1077,8 @@ function NeighborhoodRankingPanel({
|
|||||||
scores={filteredScores}
|
scores={filteredScores}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
creating={creating}
|
creating={creating}
|
||||||
|
selectedNeighborhoodId={selectedNeighborhoodId}
|
||||||
|
onSelectNeighborhood={onSelectNeighborhood}
|
||||||
onCreate={onCreate}
|
onCreate={onCreate}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1044,11 +1090,15 @@ function NeighborhoodScoreTable({
|
|||||||
scores,
|
scores,
|
||||||
loading,
|
loading,
|
||||||
creating,
|
creating,
|
||||||
|
selectedNeighborhoodId,
|
||||||
|
onSelectNeighborhood,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: {
|
}: {
|
||||||
scores: NeighborhoodScore[];
|
scores: NeighborhoodScore[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
|
selectedNeighborhoodId: string;
|
||||||
|
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||||||
onCreate: (payload: CreateWatchlistItem) => void;
|
onCreate: (payload: CreateWatchlistItem) => void;
|
||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -1076,7 +1126,15 @@ function NeighborhoodScoreTable({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{scores.map((score) => (
|
{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 className="font-medium">{score.name}</TableCell>
|
||||||
<TableCell>{score.area_name}</TableCell>
|
<TableCell>{score.area_name}</TableCell>
|
||||||
<TableCell className="text-right font-semibold">
|
<TableCell className="text-right font-semibold">
|
||||||
@@ -1097,13 +1155,14 @@ function NeighborhoodScoreTable({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
onClick={() =>
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
onCreate({
|
onCreate({
|
||||||
neighborhood_id: score.neighborhood_id,
|
neighborhood_id: score.neighborhood_id,
|
||||||
target_price_psm: Math.round(score.transaction_price_psm * 0.96),
|
target_price_psm: Math.round(score.transaction_price_psm * 0.96),
|
||||||
notes: `${score.month} 小区排行:${score.recommendation}`,
|
notes: `${score.month} 小区排行:${score.recommendation}`,
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
aria-label="加入观察池"
|
aria-label="加入观察池"
|
||||||
title="加入观察池"
|
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({
|
function WatchlistPanel({
|
||||||
scores,
|
scores,
|
||||||
neighborhoodScores,
|
neighborhoodScores,
|
||||||
|
|||||||
@@ -69,6 +69,34 @@ export type NeighborhoodScore = {
|
|||||||
available_units: number;
|
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 = {
|
export type WatchlistItem = {
|
||||||
watchlist_item_id: number;
|
watchlist_item_id: number;
|
||||||
neighborhood_id: string | null;
|
neighborhood_id: string | null;
|
||||||
@@ -253,6 +281,13 @@ export type AreaDetail = {
|
|||||||
neighborhood_scores: NeighborhoodScore[];
|
neighborhood_scores: NeighborhoodScore[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NeighborhoodDetail = {
|
||||||
|
profile: Neighborhood;
|
||||||
|
current_score: NeighborhoodScore | null;
|
||||||
|
monthly_metrics: NeighborhoodMonthlyMetric[];
|
||||||
|
watchlist_items: WatchlistItem[];
|
||||||
|
};
|
||||||
|
|
||||||
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";
|
||||||
|
|
||||||
@@ -274,6 +309,15 @@ export async function fetchNeighborhoodScores(month: string): Promise<Neighborho
|
|||||||
return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`);
|
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[]> {
|
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
|
||||||
return fetchJson("/api/v1/watchlist");
|
return fetchJson("/api/v1/watchlist");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
| 阶段 | 名称 | 目标 | 状态 |
|
| 阶段 | 名称 | 目标 | 状态 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| M0 | 工程与研究骨架 | API、数据库、前端、样例评分跑通 | 已完成 |
|
| M0 | 工程与研究骨架 | API、数据库、前端、样例评分跑通 | 已完成 |
|
||||||
| M1 | 数据底座 | 数据源、导入批次、原始数据留痕、导入中心 | 进行中 |
|
| M1 | 数据底座 | 数据源、导入批次、原始数据留痕、导入中心 | 已完成 |
|
||||||
| M2 | 资产研究工作台 | 板块详情、小区详情、对比、观察池增强 | 待开发 |
|
| M2 | 资产研究工作台 | 板块详情、小区详情、对比、观察池增强 | 进行中 |
|
||||||
| M3 | 模型与回测 | 评分模型版本化、历史分位、相似资产、情景推演 | 待开发 |
|
| M3 | 模型与回测 | 评分模型版本化、历史分位、相似资产、情景推演 | 待开发 |
|
||||||
| M4 | 报告与预警 | 周报/月报、专题报告、观察池触发器 | 待开发 |
|
| M4 | 报告与预警 | 周报/月报、专题报告、观察池触发器 | 待开发 |
|
||||||
| M5 | 产品化与运维 | 权限、部署、CI、监控、备份、安全 | 待开发 |
|
| M5 | 产品化与运维 | 权限、部署、CI、监控、备份、安全 | 待开发 |
|
||||||
@@ -190,20 +190,25 @@
|
|||||||
|
|
||||||
### M2.2 小区详情页
|
### M2.2 小区详情页
|
||||||
|
|
||||||
|
状态:已完成。
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
|
|
||||||
- 查看单个小区的资产画像、评分、历史指标和观察池状态。
|
- 查看单个小区的资产画像、评分、历史指标和观察池状态。
|
||||||
|
|
||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
- 小区详情 API。
|
- `GET /api/v1/neighborhoods/{neighborhood_id}/detail?month=YYYY-MM`。
|
||||||
- 小区详情前端视图。
|
- 小区详情前端视图,包含资产画像、评分拆解、月度趋势和指标历史。
|
||||||
- 合理买入价区间占位。
|
- 合理买入价区间占位,以观察价区间形式展示。
|
||||||
|
- 从小区排行和板块详情进入小区详情。
|
||||||
|
- 详情页可直接加入观察池,并展示当前观察池状态。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
- 小区详情能展示基础属性、月度指标、评分拆解。
|
- 小区详情能展示基础属性、月度指标、评分拆解。
|
||||||
- 可从详情页加入观察池。
|
- 可从详情页加入观察池。
|
||||||
|
- 小区历史指标可追溯到导入批次和原始文件。
|
||||||
|
|
||||||
### M2.3 对比工作台
|
### M2.3 对比工作台
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user