feat: add area detail workspace
This commit is contained in:
@@ -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