feat: add area detail workspace
This commit is contained in:
@@ -5,13 +5,13 @@ use serde::Serialize;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::import_templates::{execute_import_payload, validate_import_payload};
|
||||
use crate::models::{
|
||||
AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse,
|
||||
AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
|
||||
CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun, ImportExecutionRequest,
|
||||
ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun,
|
||||
IngestionRunQuery, MarketOverview, ModelRun, MonthQuery, Neighborhood, NeighborhoodQuery,
|
||||
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
|
||||
WatchlistItem,
|
||||
AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery,
|
||||
AreaScoreModelRunResponse, AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource,
|
||||
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
|
||||
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
||||
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
||||
MonthQuery, Neighborhood, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
|
||||
RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem,
|
||||
};
|
||||
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
||||
use crate::state::AppState;
|
||||
@@ -233,7 +233,19 @@ pub async fn get_area_score_lineage(
|
||||
Query(query): Query<AreaScoreLineageQuery>,
|
||||
) -> ApiResult<Json<AreaScoreLineage>> {
|
||||
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#"
|
||||
SELECT
|
||||
s.area_id,
|
||||
@@ -261,12 +273,151 @@ pub async fn get_area_score_lineage(
|
||||
"#,
|
||||
)
|
||||
.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)
|
||||
.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(
|
||||
|
||||
@@ -194,6 +194,43 @@ pub struct AreaScoreLineage {
|
||||
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)]
|
||||
pub struct Neighborhood {
|
||||
pub neighborhood_id: String,
|
||||
|
||||
@@ -6,9 +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_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, 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;
|
||||
|
||||
@@ -47,6 +48,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/areas/{area_id}/score-lineage",
|
||||
get(get_area_score_lineage),
|
||||
)
|
||||
.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}", get(get_neighborhood))
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
createWatchlistItem,
|
||||
fetchDataSources,
|
||||
fetchAreaScores,
|
||||
fetchAreaDetail,
|
||||
fetchIngestionRuns,
|
||||
fetchMarketOverview,
|
||||
fetchNeighborhoodScores,
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
AreaScore,
|
||||
AreaDetail,
|
||||
CreateDataSource,
|
||||
CreateIngestionRun,
|
||||
CreateRawArtifact,
|
||||
@@ -68,6 +72,7 @@ const MONTH = "2026-05";
|
||||
export function MarketDashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedAreaId, setSelectedAreaId] = useState("");
|
||||
const [detailAreaId, setDetailAreaId] = useState("qiantan");
|
||||
const overview = useQuery({
|
||||
queryKey: ["market-overview", MONTH],
|
||||
queryFn: () => fetchMarketOverview(MONTH),
|
||||
@@ -76,6 +81,11 @@ export function MarketDashboard() {
|
||||
queryKey: ["area-scores", MONTH],
|
||||
queryFn: () => fetchAreaScores(MONTH),
|
||||
});
|
||||
const areaDetail = useQuery({
|
||||
queryKey: ["area-detail", detailAreaId, MONTH],
|
||||
queryFn: () => fetchAreaDetail(detailAreaId, MONTH),
|
||||
enabled: Boolean(detailAreaId),
|
||||
});
|
||||
const neighborhoodScores = useQuery({
|
||||
queryKey: ["neighborhood-scores", MONTH],
|
||||
queryFn: () => fetchNeighborhoodScores(MONTH),
|
||||
@@ -140,6 +150,7 @@ export function MarketDashboard() {
|
||||
const isLoading =
|
||||
overview.isLoading ||
|
||||
scores.isLoading ||
|
||||
areaDetail.isLoading ||
|
||||
neighborhoodScores.isLoading ||
|
||||
watchlist.isLoading ||
|
||||
dataSources.isLoading ||
|
||||
@@ -148,6 +159,7 @@ export function MarketDashboard() {
|
||||
const hasError =
|
||||
overview.isError ||
|
||||
scores.isError ||
|
||||
areaDetail.isError ||
|
||||
neighborhoodScores.isError ||
|
||||
watchlist.isError ||
|
||||
dataSources.isError ||
|
||||
@@ -171,6 +183,7 @@ export function MarketDashboard() {
|
||||
onClick={() => {
|
||||
void overview.refetch();
|
||||
void scores.refetch();
|
||||
void areaDetail.refetch();
|
||||
void neighborhoodScores.refetch();
|
||||
void watchlist.refetch();
|
||||
void dataSources.refetch();
|
||||
@@ -268,10 +281,22 @@ export function MarketDashboard() {
|
||||
<CardTitle>板块明细</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto p-0">
|
||||
<AreaScoreTable scores={scores.data ?? []} loading={isLoading} />
|
||||
<AreaScoreTable
|
||||
scores={scores.data ?? []}
|
||||
loading={isLoading}
|
||||
selectedAreaId={detailAreaId}
|
||||
onSelectArea={setDetailAreaId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AreaDetailPanel
|
||||
detail={areaDetail.data ?? null}
|
||||
loading={areaDetail.isLoading}
|
||||
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
|
||||
creating={createMutation.isPending}
|
||||
/>
|
||||
|
||||
<NeighborhoodRankingPanel
|
||||
scores={scores.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({
|
||||
scores,
|
||||
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) {
|
||||
return <div className="h-48 bg-[var(--muted)]" />;
|
||||
}
|
||||
@@ -1113,7 +1393,15 @@ function AreaScoreTable({ scores, loading }: { scores: AreaScore[]; loading: boo
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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>{score.district}</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
|
||||
@@ -219,6 +219,40 @@ export type AreaScoreLineage = {
|
||||
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 =
|
||||
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)}`);
|
||||
}
|
||||
|
||||
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[]> {
|
||||
return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user