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

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

View File

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