2957 lines
102 KiB
TypeScript
2957 lines
102 KiB
TypeScript
"use client";
|
||
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
Activity,
|
||
AlertTriangle,
|
||
Archive,
|
||
Check,
|
||
MapPinned,
|
||
Plus,
|
||
RefreshCw,
|
||
TrendingUp,
|
||
} from "lucide-react";
|
||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||
import {
|
||
Bar,
|
||
BarChart,
|
||
CartesianGrid,
|
||
Line,
|
||
LineChart,
|
||
PolarAngleAxis,
|
||
PolarGrid,
|
||
PolarRadiusAxis,
|
||
Radar,
|
||
RadarChart,
|
||
ResponsiveContainer,
|
||
Tooltip,
|
||
XAxis,
|
||
YAxis,
|
||
} from "recharts";
|
||
|
||
import {
|
||
archiveWatchlistItem,
|
||
createDataSource,
|
||
createIngestionRun,
|
||
createRawArtifact,
|
||
createAreaScoreModelRun,
|
||
createWatchlistEvent,
|
||
createWatchlistItem,
|
||
fetchDataSources,
|
||
fetchAreaScores,
|
||
fetchAreaComparison,
|
||
fetchAreaDetail,
|
||
fetchAreaScoreRunDiff,
|
||
fetchIngestionRuns,
|
||
fetchMarketOverview,
|
||
fetchModelRuns,
|
||
fetchNeighborhoodComparison,
|
||
fetchNeighborhoodDetail,
|
||
fetchNeighborhoodScores,
|
||
fetchRawArtifacts,
|
||
fetchWatchlistEvents,
|
||
fetchWatchlistItems,
|
||
finishIngestionRun,
|
||
updateWatchlistItem,
|
||
} from "@/lib/api";
|
||
import type {
|
||
AreaScore,
|
||
AreaComparison,
|
||
AreaDetail,
|
||
AreaScoreRunDiff,
|
||
ComparisonMetric,
|
||
CreateAreaScoreModelRun,
|
||
CreateDataSource,
|
||
CreateIngestionRun,
|
||
CreateRawArtifact,
|
||
CreateWatchlistEvent,
|
||
CreateWatchlistItem,
|
||
DataSource,
|
||
IngestionRun,
|
||
ModelRun,
|
||
NeighborhoodComparison,
|
||
NeighborhoodComparisonItem,
|
||
NeighborhoodDetail,
|
||
NeighborhoodScore,
|
||
RawArtifact,
|
||
SimilarNeighborhood,
|
||
UpdateWatchlistItem,
|
||
WatchlistEvent,
|
||
WatchlistItem,
|
||
} from "@/lib/api";
|
||
import { formatNumber, formatPrice } from "@/lib/utils";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
|
||
const MONTH = "2026-05";
|
||
|
||
export function MarketDashboard() {
|
||
const queryClient = useQueryClient();
|
||
const [selectedAreaId, setSelectedAreaId] = useState("");
|
||
const [detailAreaId, setDetailAreaId] = useState("qiantan");
|
||
const [detailNeighborhoodId, setDetailNeighborhoodId] = useState("");
|
||
const [comparisonAreaIds, setComparisonAreaIds] = useState<string[]>([]);
|
||
const [comparisonNeighborhoodIds, setComparisonNeighborhoodIds] = useState<string[]>([]);
|
||
const [watchlistStatus, setWatchlistStatus] = useState("active");
|
||
const [watchlistAreaId, setWatchlistAreaId] = useState("");
|
||
const [watchlistLabel, setWatchlistLabel] = useState("");
|
||
const [expandedWatchlistItemId, setExpandedWatchlistItemId] = useState<number | null>(null);
|
||
const [baseModelRunId, setBaseModelRunId] = useState<number | null>(null);
|
||
const [candidateModelRunId, setCandidateModelRunId] = useState<number | null>(null);
|
||
const overview = useQuery({
|
||
queryKey: ["market-overview", MONTH],
|
||
queryFn: () => fetchMarketOverview(MONTH),
|
||
});
|
||
const scores = useQuery({
|
||
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),
|
||
});
|
||
const neighborhoodDetail = useQuery({
|
||
queryKey: ["neighborhood-detail", detailNeighborhoodId, MONTH],
|
||
queryFn: () => fetchNeighborhoodDetail(detailNeighborhoodId, MONTH),
|
||
enabled: Boolean(detailNeighborhoodId),
|
||
});
|
||
const areaComparison = useQuery({
|
||
queryKey: ["area-comparison", comparisonAreaIds, MONTH],
|
||
queryFn: () => fetchAreaComparison(comparisonAreaIds, MONTH),
|
||
enabled: comparisonAreaIds.length >= 2,
|
||
});
|
||
const neighborhoodComparison = useQuery({
|
||
queryKey: ["neighborhood-comparison", comparisonNeighborhoodIds, MONTH],
|
||
queryFn: () => fetchNeighborhoodComparison(comparisonNeighborhoodIds, MONTH),
|
||
enabled: comparisonNeighborhoodIds.length >= 2,
|
||
});
|
||
const watchlist = useQuery({
|
||
queryKey: ["watchlist", watchlistStatus, watchlistAreaId, watchlistLabel],
|
||
queryFn: () =>
|
||
fetchWatchlistItems({
|
||
status: watchlistStatus,
|
||
area_id: watchlistAreaId,
|
||
label: watchlistLabel,
|
||
}),
|
||
});
|
||
const watchlistEvents = useQuery({
|
||
queryKey: ["watchlist-events", expandedWatchlistItemId],
|
||
queryFn: () => fetchWatchlistEvents(expandedWatchlistItemId ?? 0),
|
||
enabled: expandedWatchlistItemId !== null,
|
||
});
|
||
const modelRuns = useQuery({
|
||
queryKey: ["model-runs", "area_scores"],
|
||
queryFn: () => fetchModelRuns({ model_name: "area_scores" }),
|
||
});
|
||
const areaScoreRunDiff = useQuery({
|
||
queryKey: ["area-score-run-diff", baseModelRunId, candidateModelRunId],
|
||
queryFn: () => fetchAreaScoreRunDiff(baseModelRunId ?? 0, candidateModelRunId ?? 0),
|
||
enabled:
|
||
baseModelRunId !== null &&
|
||
candidateModelRunId !== null &&
|
||
baseModelRunId !== candidateModelRunId,
|
||
});
|
||
const dataSources = useQuery({
|
||
queryKey: ["data-sources"],
|
||
queryFn: fetchDataSources,
|
||
});
|
||
const ingestionRuns = useQuery({
|
||
queryKey: ["ingestion-runs"],
|
||
queryFn: fetchIngestionRuns,
|
||
});
|
||
const rawArtifacts = useQuery({
|
||
queryKey: ["raw-artifacts"],
|
||
queryFn: fetchRawArtifacts,
|
||
});
|
||
const createMutation = useMutation({
|
||
mutationFn: createWatchlistItem,
|
||
onSuccess: async () => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ["watchlist"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["neighborhood-detail"] }),
|
||
]);
|
||
},
|
||
});
|
||
const archiveMutation = useMutation({
|
||
mutationFn: archiveWatchlistItem,
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["watchlist"] });
|
||
},
|
||
});
|
||
const updateWatchlistMutation = useMutation({
|
||
mutationFn: ({ id, payload }: { id: number; payload: UpdateWatchlistItem }) =>
|
||
updateWatchlistItem(id, payload),
|
||
onSuccess: async () => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ["watchlist"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["watchlist-events"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["neighborhood-detail"] }),
|
||
]);
|
||
},
|
||
});
|
||
const createWatchlistEventMutation = useMutation({
|
||
mutationFn: ({ id, payload }: { id: number; payload: CreateWatchlistEvent }) =>
|
||
createWatchlistEvent(id, payload),
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["watchlist-events"] });
|
||
},
|
||
});
|
||
const createAreaScoreModelRunMutation = useMutation({
|
||
mutationFn: createAreaScoreModelRun,
|
||
onSuccess: async (response) => {
|
||
setCandidateModelRunId(response.model_run.model_run_id);
|
||
if (baseModelRunId === null && modelRuns.data?.[0]) {
|
||
setBaseModelRunId(modelRuns.data[0].model_run_id);
|
||
}
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ["model-runs"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["area-scores"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["market-overview"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["area-detail"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["area-score-run-diff"] }),
|
||
]);
|
||
},
|
||
});
|
||
const createDataSourceMutation = useMutation({
|
||
mutationFn: createDataSource,
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["data-sources"] });
|
||
},
|
||
});
|
||
const createRunMutation = useMutation({
|
||
mutationFn: createIngestionRun,
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] });
|
||
},
|
||
});
|
||
const createRawArtifactMutation = useMutation({
|
||
mutationFn: createRawArtifact,
|
||
onSuccess: async () => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ["raw-artifacts"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] }),
|
||
]);
|
||
},
|
||
});
|
||
const finishRunMutation = useMutation({
|
||
mutationFn: ({ id, rowCount }: { id: number; rowCount?: number }) =>
|
||
finishIngestionRun(id, { status: "succeeded", row_count: rowCount }),
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] });
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!detailNeighborhoodId && neighborhoodScores.data?.[0]) {
|
||
setDetailNeighborhoodId(neighborhoodScores.data[0].neighborhood_id);
|
||
}
|
||
}, [detailNeighborhoodId, neighborhoodScores.data]);
|
||
|
||
useEffect(() => {
|
||
if (comparisonAreaIds.length === 0 && scores.data && scores.data.length >= 2) {
|
||
setComparisonAreaIds(scores.data.slice(0, 3).map((score) => score.area_id));
|
||
}
|
||
}, [comparisonAreaIds.length, scores.data]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
comparisonNeighborhoodIds.length === 0 &&
|
||
neighborhoodScores.data &&
|
||
neighborhoodScores.data.length >= 2
|
||
) {
|
||
setComparisonNeighborhoodIds(
|
||
neighborhoodScores.data.slice(0, 3).map((score) => score.neighborhood_id),
|
||
);
|
||
}
|
||
}, [comparisonNeighborhoodIds.length, neighborhoodScores.data]);
|
||
|
||
useEffect(() => {
|
||
if (modelRuns.data && modelRuns.data.length >= 2) {
|
||
if (candidateModelRunId === null) {
|
||
setCandidateModelRunId(modelRuns.data[0].model_run_id);
|
||
}
|
||
if (baseModelRunId === null) {
|
||
setBaseModelRunId(modelRuns.data[1].model_run_id);
|
||
}
|
||
}
|
||
}, [baseModelRunId, candidateModelRunId, modelRuns.data]);
|
||
|
||
const isLoading =
|
||
overview.isLoading ||
|
||
scores.isLoading ||
|
||
areaDetail.isLoading ||
|
||
neighborhoodDetail.isLoading ||
|
||
areaComparison.isLoading ||
|
||
neighborhoodComparison.isLoading ||
|
||
neighborhoodScores.isLoading ||
|
||
watchlist.isLoading ||
|
||
watchlistEvents.isLoading ||
|
||
modelRuns.isLoading ||
|
||
areaScoreRunDiff.isLoading ||
|
||
dataSources.isLoading ||
|
||
ingestionRuns.isLoading;
|
||
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
||
const hasError =
|
||
overview.isError ||
|
||
scores.isError ||
|
||
areaDetail.isError ||
|
||
neighborhoodDetail.isError ||
|
||
areaComparison.isError ||
|
||
neighborhoodComparison.isError ||
|
||
neighborhoodScores.isError ||
|
||
watchlist.isError ||
|
||
watchlistEvents.isError ||
|
||
modelRuns.isError ||
|
||
areaScoreRunDiff.isError ||
|
||
dataSources.isError ||
|
||
ingestionRuns.isError ||
|
||
rawArtifacts.isError;
|
||
|
||
return (
|
||
<main className="min-h-screen px-4 py-5 sm:px-6 lg:px-8">
|
||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||
<header className="flex flex-col gap-3 border-b border-[var(--border)] pb-4 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium text-[var(--muted-foreground)]">上海房市投资研究系统</p>
|
||
<h1 className="mt-1 text-2xl font-semibold tracking-normal text-slate-950">
|
||
市场总览
|
||
</h1>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="muted">{MONTH}</Badge>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
void overview.refetch();
|
||
void scores.refetch();
|
||
void areaDetail.refetch();
|
||
void neighborhoodDetail.refetch();
|
||
void areaComparison.refetch();
|
||
void neighborhoodComparison.refetch();
|
||
void neighborhoodScores.refetch();
|
||
void watchlist.refetch();
|
||
void dataSources.refetch();
|
||
void ingestionRuns.refetch();
|
||
void rawArtifacts.refetch();
|
||
void modelRuns.refetch();
|
||
void areaScoreRunDiff.refetch();
|
||
}}
|
||
disabled={isLoading}
|
||
aria-label="刷新"
|
||
title="刷新"
|
||
>
|
||
<RefreshCw className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
|
||
{hasError ? <ErrorState /> : null}
|
||
|
||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
<MetricCard
|
||
title="样本板块"
|
||
value={overview.data?.area_count ?? 0}
|
||
suffix="个"
|
||
icon={<MapPinned className="h-4 w-4" />}
|
||
loading={isLoading}
|
||
/>
|
||
<MetricCard
|
||
title="平均综合分"
|
||
value={overview.data?.average_investment_score ?? 0}
|
||
icon={<Activity className="h-4 w-4" />}
|
||
loading={isLoading}
|
||
/>
|
||
<MetricCard
|
||
title="平均租金收益率"
|
||
value={overview.data?.average_rent_yield_pct ?? 0}
|
||
suffix="%"
|
||
icon={<TrendingUp className="h-4 w-4" />}
|
||
loading={isLoading}
|
||
/>
|
||
<MetricCard
|
||
title="供应压力最高"
|
||
textValue={overview.data?.highest_supply_risk_area?.name ?? "-"}
|
||
icon={<AlertTriangle className="h-4 w-4" />}
|
||
loading={isLoading}
|
||
tone="warning"
|
||
/>
|
||
</section>
|
||
|
||
<section className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||
<CardTitle>板块投资观察评分</CardTitle>
|
||
{overview.data?.top_area ? (
|
||
<Badge variant="success">首位:{overview.data.top_area.name}</Badge>
|
||
) : null}
|
||
</CardHeader>
|
||
<CardContent className="h-[340px]">
|
||
<ScoreChart scores={scores.data ?? []} loading={isLoading} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>核心结论</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<InsightRow
|
||
label="最高评分"
|
||
value={overview.data?.top_area?.name ?? "-"}
|
||
detail={
|
||
overview.data?.top_area
|
||
? `${formatNumber(overview.data.top_area.investment_score)} 分`
|
||
: "-"
|
||
}
|
||
/>
|
||
<InsightRow
|
||
label="最低评分"
|
||
value={overview.data?.weakest_area?.name ?? "-"}
|
||
detail={
|
||
overview.data?.weakest_area
|
||
? `${formatNumber(overview.data.weakest_area.investment_score)} 分`
|
||
: "-"
|
||
}
|
||
/>
|
||
<InsightRow
|
||
label="供应压力"
|
||
value={overview.data?.highest_supply_risk_area?.name ?? "-"}
|
||
detail={overview.data?.highest_supply_risk_area?.recommendation ?? "-"}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
</section>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>板块明细</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="overflow-x-auto p-0">
|
||
<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}
|
||
onSelectNeighborhood={setDetailNeighborhoodId}
|
||
/>
|
||
|
||
<NeighborhoodRankingPanel
|
||
scores={scores.data ?? []}
|
||
neighborhoodScores={neighborhoodScores.data ?? []}
|
||
selectedAreaId={selectedAreaId}
|
||
selectedNeighborhoodId={detailNeighborhoodId}
|
||
loading={isLoading}
|
||
creating={createMutation.isPending}
|
||
onSelectArea={setSelectedAreaId}
|
||
onSelectNeighborhood={setDetailNeighborhoodId}
|
||
onCreate={(payload) => createMutation.mutate(payload)}
|
||
/>
|
||
|
||
<NeighborhoodDetailPanel
|
||
detail={neighborhoodDetail.data ?? null}
|
||
loading={neighborhoodDetail.isLoading}
|
||
creating={createMutation.isPending}
|
||
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
|
||
onSelectNeighborhood={setDetailNeighborhoodId}
|
||
/>
|
||
|
||
<ComparisonWorkspace
|
||
areaScores={scores.data ?? []}
|
||
neighborhoodScores={neighborhoodScores.data ?? []}
|
||
areaComparison={areaComparison.data ?? null}
|
||
neighborhoodComparison={neighborhoodComparison.data ?? null}
|
||
selectedAreaIds={comparisonAreaIds}
|
||
selectedNeighborhoodIds={comparisonNeighborhoodIds}
|
||
loading={areaComparison.isLoading || neighborhoodComparison.isLoading}
|
||
onAreaIdsChange={setComparisonAreaIds}
|
||
onNeighborhoodIdsChange={setComparisonNeighborhoodIds}
|
||
/>
|
||
|
||
<WatchlistPanel
|
||
scores={scores.data ?? []}
|
||
neighborhoodScores={neighborhoodScores.data ?? []}
|
||
items={watchlist.data ?? []}
|
||
events={watchlistEvents.data ?? []}
|
||
loading={isLoading}
|
||
creating={createMutation.isPending}
|
||
archivingId={archiveMutation.variables ?? null}
|
||
updatingId={updateWatchlistMutation.variables?.id ?? null}
|
||
creatingEvent={createWatchlistEventMutation.isPending}
|
||
statusFilter={watchlistStatus}
|
||
areaFilter={watchlistAreaId}
|
||
labelFilter={watchlistLabel}
|
||
expandedItemId={expandedWatchlistItemId}
|
||
onStatusFilterChange={setWatchlistStatus}
|
||
onAreaFilterChange={setWatchlistAreaId}
|
||
onLabelFilterChange={setWatchlistLabel}
|
||
onToggleEvents={(id) =>
|
||
setExpandedWatchlistItemId((current) => (current === id ? null : id))
|
||
}
|
||
onCreate={(payload) => createMutation.mutate(payload)}
|
||
onUpdate={(id, payload) => updateWatchlistMutation.mutate({ id, payload })}
|
||
onCreateEvent={(id, payload) => createWatchlistEventMutation.mutate({ id, payload })}
|
||
onArchive={(id) => archiveMutation.mutate(id)}
|
||
/>
|
||
|
||
<ModelRunPanel
|
||
runs={modelRuns.data ?? []}
|
||
diff={areaScoreRunDiff.data ?? null}
|
||
baseRunId={baseModelRunId}
|
||
candidateRunId={candidateModelRunId}
|
||
loading={modelRuns.isLoading || areaScoreRunDiff.isLoading}
|
||
creating={createAreaScoreModelRunMutation.isPending}
|
||
onBaseRunChange={setBaseModelRunId}
|
||
onCandidateRunChange={setCandidateModelRunId}
|
||
onCreateRun={(payload) => createAreaScoreModelRunMutation.mutate(payload)}
|
||
/>
|
||
|
||
<ImportCenterPanel
|
||
sources={dataSources.data ?? []}
|
||
runs={ingestionRuns.data ?? []}
|
||
artifacts={rawArtifacts.data ?? []}
|
||
loading={isLoading}
|
||
importLoading={importLoading}
|
||
creatingSource={createDataSourceMutation.isPending}
|
||
creatingRun={createRunMutation.isPending}
|
||
creatingArtifact={createRawArtifactMutation.isPending}
|
||
finishingRunId={finishRunMutation.variables?.id ?? null}
|
||
onCreateSource={(payload) => createDataSourceMutation.mutate(payload)}
|
||
onCreateRun={(payload) => createRunMutation.mutate(payload)}
|
||
onCreateArtifact={(payload) => createRawArtifactMutation.mutate(payload)}
|
||
onFinishRun={(id, rowCount) => finishRunMutation.mutate({ id, rowCount })}
|
||
/>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function ImportCenterPanel({
|
||
sources,
|
||
runs,
|
||
artifacts,
|
||
loading,
|
||
importLoading,
|
||
creatingSource,
|
||
creatingRun,
|
||
creatingArtifact,
|
||
finishingRunId,
|
||
onCreateSource,
|
||
onCreateRun,
|
||
onCreateArtifact,
|
||
onFinishRun,
|
||
}: {
|
||
sources: DataSource[];
|
||
runs: IngestionRun[];
|
||
artifacts: RawArtifact[];
|
||
loading: boolean;
|
||
importLoading: boolean;
|
||
creatingSource: boolean;
|
||
creatingRun: boolean;
|
||
creatingArtifact: boolean;
|
||
finishingRunId: number | null;
|
||
onCreateSource: (payload: CreateDataSource) => void;
|
||
onCreateRun: (payload: CreateIngestionRun) => void;
|
||
onCreateArtifact: (payload: CreateRawArtifact) => void;
|
||
onFinishRun: (id: number, rowCount?: number) => void;
|
||
}) {
|
||
const [sourceName, setSourceName] = useState("");
|
||
const [sourceType, setSourceType] = useState("manual");
|
||
const [sourceUrl, setSourceUrl] = useState("");
|
||
const [sourceId, setSourceId] = useState("");
|
||
const [rawUri, setRawUri] = useState("");
|
||
const [rowCount, setRowCount] = useState("");
|
||
const [artifactRunId, setArtifactRunId] = useState("");
|
||
const [artifactSourceId, setArtifactSourceId] = useState("");
|
||
const [artifactFilename, setArtifactFilename] = useState("");
|
||
const [artifactRawUri, setArtifactRawUri] = useState("");
|
||
const [artifactSha256, setArtifactSha256] = useState("");
|
||
const [artifactSizeBytes, setArtifactSizeBytes] = useState("");
|
||
|
||
function submitSource(event: React.FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!sourceName.trim()) {
|
||
return;
|
||
}
|
||
onCreateSource({
|
||
name: sourceName.trim(),
|
||
source_type: sourceType,
|
||
url: sourceUrl.trim() || undefined,
|
||
cadence: "ad hoc",
|
||
reliability: sourceType === "official" ? "high" : "medium",
|
||
notes: "front-end registered source",
|
||
});
|
||
setSourceName("");
|
||
setSourceUrl("");
|
||
}
|
||
|
||
function submitRun(event: React.FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
const parsedSourceId = Number(sourceId);
|
||
if (!Number.isFinite(parsedSourceId) || parsedSourceId <= 0) {
|
||
return;
|
||
}
|
||
onCreateRun({
|
||
source_id: parsedSourceId,
|
||
raw_uri: rawUri.trim() || undefined,
|
||
status: "running",
|
||
});
|
||
setRawUri("");
|
||
}
|
||
|
||
function submitArtifact(event: React.FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
const parsedRunId = Number(artifactRunId);
|
||
const parsedSourceId = Number(artifactSourceId);
|
||
const parsedSizeBytes = Number(artifactSizeBytes);
|
||
if (
|
||
!artifactFilename.trim() ||
|
||
!artifactRawUri.trim() ||
|
||
artifactSha256.trim().length !== 64 ||
|
||
!Number.isFinite(parsedSizeBytes) ||
|
||
parsedSizeBytes < 0
|
||
) {
|
||
return;
|
||
}
|
||
onCreateArtifact({
|
||
run_id: Number.isFinite(parsedRunId) && parsedRunId > 0 ? parsedRunId : undefined,
|
||
source_id:
|
||
Number.isFinite(parsedSourceId) && parsedSourceId > 0 ? parsedSourceId : undefined,
|
||
original_filename: artifactFilename.trim(),
|
||
raw_uri: artifactRawUri.trim(),
|
||
sha256: artifactSha256.trim(),
|
||
size_bytes: parsedSizeBytes,
|
||
uploaded_by: "dashboard",
|
||
});
|
||
setArtifactFilename("");
|
||
setArtifactRawUri("");
|
||
setArtifactSha256("");
|
||
setArtifactSizeBytes("");
|
||
}
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||
<div>
|
||
<CardTitle>导入中心</CardTitle>
|
||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||
数据源登记与导入批次审计
|
||
</p>
|
||
</div>
|
||
<div className="grid w-full gap-3 xl:w-auto xl:grid-cols-2">
|
||
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={submitSource}>
|
||
<input
|
||
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-40"
|
||
placeholder="数据源名称"
|
||
value={sourceName}
|
||
onChange={(event) => setSourceName(event.target.value)}
|
||
/>
|
||
<select
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={sourceType}
|
||
onChange={(event) => setSourceType(event.target.value)}
|
||
aria-label="数据源类型"
|
||
>
|
||
<option value="manual">manual</option>
|
||
<option value="official">official</option>
|
||
<option value="market">market</option>
|
||
</select>
|
||
<input
|
||
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-52"
|
||
placeholder="URL"
|
||
value={sourceUrl}
|
||
onChange={(event) => setSourceUrl(event.target.value)}
|
||
/>
|
||
<Button disabled={!sourceName.trim() || creatingSource} aria-label="新增数据源" title="新增数据源">
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
</form>
|
||
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={submitRun}>
|
||
<select
|
||
className="h-9 min-w-40 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={sourceId}
|
||
onChange={(event) => setSourceId(event.target.value)}
|
||
aria-label="导入数据源"
|
||
>
|
||
<option value="">数据源</option>
|
||
{sources.map((source) => (
|
||
<option key={source.source_id} value={source.source_id}>
|
||
{source.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-56"
|
||
placeholder="raw 路径"
|
||
value={rawUri}
|
||
onChange={(event) => setRawUri(event.target.value)}
|
||
/>
|
||
<Button disabled={!sourceId || creatingRun} aria-label="创建导入批次" title="创建导入批次">
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
</form>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-4 xl:grid-cols-[420px_minmax(0,1fr)]">
|
||
<DataSourceTable sources={sources} loading={loading} />
|
||
<IngestionRunTable
|
||
runs={runs}
|
||
loading={importLoading}
|
||
rowCount={rowCount}
|
||
onRowCountChange={setRowCount}
|
||
finishingRunId={finishingRunId}
|
||
onFinishRun={(id) => {
|
||
const parsedRowCount = Number(rowCount);
|
||
onFinishRun(id, Number.isFinite(parsedRowCount) && parsedRowCount >= 0 ? parsedRowCount : undefined);
|
||
setRowCount("");
|
||
}}
|
||
/>
|
||
<div className="xl:col-span-2">
|
||
<form className="mb-3 grid gap-2 lg:grid-cols-[120px_120px_1fr_1fr_1.5fr_120px_44px]" onSubmit={submitArtifact}>
|
||
<select
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={artifactRunId}
|
||
onChange={(event) => setArtifactRunId(event.target.value)}
|
||
aria-label="原始文件批次"
|
||
>
|
||
<option value="">批次</option>
|
||
{runs.map((run) => (
|
||
<option key={run.run_id} value={run.run_id}>
|
||
#{run.run_id}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={artifactSourceId}
|
||
onChange={(event) => setArtifactSourceId(event.target.value)}
|
||
aria-label="原始文件数据源"
|
||
>
|
||
<option value="">数据源</option>
|
||
{sources.map((source) => (
|
||
<option key={source.source_id} value={source.source_id}>
|
||
{source.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
placeholder="文件名"
|
||
value={artifactFilename}
|
||
onChange={(event) => setArtifactFilename(event.target.value)}
|
||
/>
|
||
<input
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
placeholder="raw uri"
|
||
value={artifactRawUri}
|
||
onChange={(event) => setArtifactRawUri(event.target.value)}
|
||
/>
|
||
<input
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
placeholder="sha256"
|
||
value={artifactSha256}
|
||
onChange={(event) => setArtifactSha256(event.target.value)}
|
||
/>
|
||
<input
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
inputMode="numeric"
|
||
placeholder="字节"
|
||
value={artifactSizeBytes}
|
||
onChange={(event) => setArtifactSizeBytes(event.target.value)}
|
||
/>
|
||
<Button
|
||
disabled={
|
||
creatingArtifact ||
|
||
!artifactFilename.trim() ||
|
||
artifactSha256.trim().length !== 64
|
||
}
|
||
aria-label="登记原始文件"
|
||
title="登记原始文件"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
</form>
|
||
<RawArtifactTable artifacts={artifacts} loading={importLoading} />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function DataSourceTable({ sources, loading }: { sources: DataSource[]; loading: boolean }) {
|
||
if (loading) {
|
||
return <div className="h-48 rounded-md bg-[var(--muted)]" />;
|
||
}
|
||
|
||
return (
|
||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>数据源</TableHead>
|
||
<TableHead>类型</TableHead>
|
||
<TableHead>可靠性</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{sources.map((source) => (
|
||
<TableRow key={source.source_id}>
|
||
<TableCell className="font-medium">{source.name}</TableCell>
|
||
<TableCell>{source.source_type}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={source.reliability === "high" ? "success" : "muted"}>
|
||
{source.reliability}
|
||
</Badge>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IngestionRunTable({
|
||
runs,
|
||
loading,
|
||
rowCount,
|
||
onRowCountChange,
|
||
finishingRunId,
|
||
onFinishRun,
|
||
}: {
|
||
runs: IngestionRun[];
|
||
loading: boolean;
|
||
rowCount: string;
|
||
onRowCountChange: (value: string) => void;
|
||
finishingRunId: number | null;
|
||
onFinishRun: (id: number) => void;
|
||
}) {
|
||
if (loading) {
|
||
return <div className="h-48 rounded-md bg-[var(--muted)]" />;
|
||
}
|
||
|
||
return (
|
||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>批次</TableHead>
|
||
<TableHead>数据源</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>raw 路径</TableHead>
|
||
<TableHead className="text-right">行数</TableHead>
|
||
<TableHead className="w-44" />
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{runs.map((run) => (
|
||
<TableRow key={run.run_id}>
|
||
<TableCell className="font-medium">#{run.run_id}</TableCell>
|
||
<TableCell>{run.source_name ?? "-"}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={run.status === "succeeded" ? "success" : "warning"}>
|
||
{run.status}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="max-w-64 truncate text-[var(--muted-foreground)]">
|
||
{run.raw_uri ?? "-"}
|
||
</TableCell>
|
||
<TableCell className="text-right">{run.row_count ?? "-"}</TableCell>
|
||
<TableCell>
|
||
{run.status === "running" ? (
|
||
<div className="flex items-center justify-end gap-2">
|
||
<input
|
||
className="h-9 w-20 rounded-md border border-[var(--border)] bg-white px-2 text-sm"
|
||
inputMode="numeric"
|
||
placeholder="行数"
|
||
value={rowCount}
|
||
onChange={(event) => onRowCountChange(event.target.value)}
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
disabled={finishingRunId === run.run_id}
|
||
onClick={() => onFinishRun(run.run_id)}
|
||
aria-label="完成导入"
|
||
title="完成导入"
|
||
>
|
||
<Check className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RawArtifactTable({
|
||
artifacts,
|
||
loading,
|
||
}: {
|
||
artifacts: RawArtifact[];
|
||
loading: boolean;
|
||
}) {
|
||
if (loading) {
|
||
return <div className="h-48 rounded-md bg-[var(--muted)]" />;
|
||
}
|
||
|
||
return (
|
||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>原始文件</TableHead>
|
||
<TableHead>数据源</TableHead>
|
||
<TableHead>批次</TableHead>
|
||
<TableHead>SHA-256</TableHead>
|
||
<TableHead className="text-right">大小</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{artifacts.map((artifact) => (
|
||
<TableRow key={artifact.artifact_id}>
|
||
<TableCell className="font-medium">{artifact.original_filename}</TableCell>
|
||
<TableCell>{artifact.source_name ?? "-"}</TableCell>
|
||
<TableCell>{artifact.run_id ? `#${artifact.run_id}` : "-"}</TableCell>
|
||
<TableCell className="max-w-80 truncate font-mono text-xs text-[var(--muted-foreground)]">
|
||
{artifact.sha256}
|
||
</TableCell>
|
||
<TableCell className="text-right">{formatNumber(artifact.size_bytes)}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AreaDetailPanel({
|
||
detail,
|
||
loading,
|
||
creating,
|
||
onCreateWatchlist,
|
||
onSelectNeighborhood,
|
||
}: {
|
||
detail: AreaDetail | null;
|
||
loading: boolean;
|
||
creating: boolean;
|
||
onCreateWatchlist: (payload: CreateWatchlistItem) => void;
|
||
onSelectNeighborhood: (neighborhoodId: string) => 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>
|
||
<AreaDiagnosticsPanel diagnostics={detail.diagnostics} />
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<AreaMetricHistoryTable metrics={detail.monthly_metrics} />
|
||
<AreaNeighborhoodTable
|
||
scores={detail.neighborhood_scores}
|
||
creating={creating}
|
||
onCreate={onCreateWatchlist}
|
||
onSelectNeighborhood={onSelectNeighborhood}
|
||
/>
|
||
</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 AreaDiagnosticsPanel({ diagnostics }: { diagnostics: AreaDetail["diagnostics"] }) {
|
||
if (!diagnostics) {
|
||
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-3">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<p className="text-sm font-semibold text-slate-950">历史分位与异常</p>
|
||
<Badge variant={diagnostics.anomaly_count > 0 ? "warning" : "success"}>
|
||
{diagnostics.anomaly_count} 项异常
|
||
</Badge>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||
{diagnostics.metrics.map((metric) => (
|
||
<div key={metric.key} className="rounded-md border border-[var(--border)] bg-white p-3">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-sm font-medium text-slate-950">{metric.label}</span>
|
||
<Badge variant={metric.anomaly ? "warning" : "muted"}>{metric.severity}</Badge>
|
||
</div>
|
||
<p className="mt-2 text-lg font-semibold text-slate-950">
|
||
{formatMetricValue(metric.current_value, metric.unit)}
|
||
</p>
|
||
<div className="mt-3 h-2 rounded-full bg-[var(--muted)]">
|
||
<div
|
||
className={
|
||
metric.anomaly
|
||
? "h-2 rounded-full bg-[var(--warning)]"
|
||
: "h-2 rounded-full bg-[var(--primary)]"
|
||
}
|
||
style={{ width: `${Math.max(0, Math.min(100, metric.percentile))}%` }}
|
||
/>
|
||
</div>
|
||
<div className="mt-2 flex items-center justify-between text-xs text-[var(--muted-foreground)]">
|
||
<span>P{formatNumber(metric.percentile, 1)}</span>
|
||
<span>
|
||
{formatMetricValue(metric.historical_min, metric.unit)}-
|
||
{formatMetricValue(metric.historical_max, metric.unit)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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,
|
||
onSelectNeighborhood,
|
||
}: {
|
||
scores: NeighborhoodScore[];
|
||
creating: boolean;
|
||
onCreate: (payload: CreateWatchlistItem) => void;
|
||
onSelectNeighborhood: (neighborhoodId: string) => 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}
|
||
className="cursor-pointer hover:bg-slate-50"
|
||
onClick={() => onSelectNeighborhood(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={(event) => {
|
||
event.stopPropagation();
|
||
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,
|
||
selectedAreaId,
|
||
selectedNeighborhoodId,
|
||
loading,
|
||
creating,
|
||
onSelectArea,
|
||
onSelectNeighborhood,
|
||
onCreate,
|
||
}: {
|
||
scores: AreaScore[];
|
||
neighborhoodScores: NeighborhoodScore[];
|
||
selectedAreaId: string;
|
||
selectedNeighborhoodId: string;
|
||
loading: boolean;
|
||
creating: boolean;
|
||
onSelectArea: (areaId: string) => void;
|
||
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||
onCreate: (payload: CreateWatchlistItem) => void;
|
||
}) {
|
||
const filteredScores = selectedAreaId
|
||
? neighborhoodScores.filter((score) => score.area_id === selectedAreaId)
|
||
: neighborhoodScores;
|
||
const topNeighborhood = filteredScores[0];
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<CardTitle>小区排行</CardTitle>
|
||
{topNeighborhood ? <Badge variant="success">首位:{topNeighborhood.name}</Badge> : null}
|
||
</div>
|
||
<select
|
||
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm lg:w-44"
|
||
value={selectedAreaId}
|
||
onChange={(event) => onSelectArea(event.target.value)}
|
||
aria-label="小区板块筛选"
|
||
>
|
||
<option value="">全部板块</option>
|
||
{scores.map((score) => (
|
||
<option key={score.area_id} value={score.area_id}>
|
||
{score.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</CardHeader>
|
||
<CardContent className="overflow-x-auto p-0">
|
||
<NeighborhoodScoreTable
|
||
scores={filteredScores}
|
||
loading={loading}
|
||
creating={creating}
|
||
selectedNeighborhoodId={selectedNeighborhoodId}
|
||
onSelectNeighborhood={onSelectNeighborhood}
|
||
onCreate={onCreate}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function NeighborhoodScoreTable({
|
||
scores,
|
||
loading,
|
||
creating,
|
||
selectedNeighborhoodId,
|
||
onSelectNeighborhood,
|
||
onCreate,
|
||
}: {
|
||
scores: NeighborhoodScore[];
|
||
loading: boolean;
|
||
creating: boolean;
|
||
selectedNeighborhoodId: string;
|
||
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||
onCreate: (payload: CreateWatchlistItem) => void;
|
||
}) {
|
||
if (loading) {
|
||
return <div className="h-56 bg-[var(--muted)]" />;
|
||
}
|
||
|
||
if (scores.length === 0) {
|
||
return <div className="px-4 py-8 text-sm text-[var(--muted-foreground)]">暂无小区评分</div>;
|
||
}
|
||
|
||
return (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>小区</TableHead>
|
||
<TableHead>板块</TableHead>
|
||
<TableHead className="text-right">综合分</TableHead>
|
||
<TableHead>建议</TableHead>
|
||
<TableHead className="text-right">成交均价/㎡</TableHead>
|
||
<TableHead className="text-right">租金收益率</TableHead>
|
||
<TableHead className="text-right">流动性</TableHead>
|
||
<TableHead className="text-right">位置</TableHead>
|
||
<TableHead className="w-14" />
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{scores.map((score) => (
|
||
<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>{score.area_name}</TableCell>
|
||
<TableCell className="text-right font-semibold">
|
||
{formatNumber(score.investment_score)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={score.recommendation === "重点研究" ? "success" : "muted"}>
|
||
{score.recommendation}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-right">{formatPrice(score.transaction_price_psm)}</TableCell>
|
||
<TableCell className="text-right">
|
||
{formatNumber(score.annual_rent_yield_pct, 2)}%
|
||
</TableCell>
|
||
<TableCell className="text-right">{formatNumber(score.liquidity_score)}</TableCell>
|
||
<TableCell className="text-right">{formatNumber(score.location_score)}</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button
|
||
variant="outline"
|
||
disabled={creating}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
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>
|
||
);
|
||
}
|
||
|
||
function NeighborhoodDetailPanel({
|
||
detail,
|
||
loading,
|
||
creating,
|
||
onCreateWatchlist,
|
||
onSelectNeighborhood,
|
||
}: {
|
||
detail: NeighborhoodDetail | null;
|
||
loading: boolean;
|
||
creating: boolean;
|
||
onCreateWatchlist: (payload: CreateWatchlistItem) => void;
|
||
onSelectNeighborhood: (neighborhoodId: string) => 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="当前价 92%-96%"
|
||
/>
|
||
<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>
|
||
<SimilarNeighborhoodTable
|
||
items={detail.similar_neighborhoods}
|
||
onSelectNeighborhood={onSelectNeighborhood}
|
||
/>
|
||
</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 SimilarNeighborhoodTable({
|
||
items,
|
||
onSelectNeighborhood,
|
||
}: {
|
||
items: SimilarNeighborhood[];
|
||
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||
}) {
|
||
if (items.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>板块</TableHead>
|
||
<TableHead className="text-right">相似度</TableHead>
|
||
<TableHead className="text-right">相对价格</TableHead>
|
||
<TableHead className="text-right">成交均价/㎡</TableHead>
|
||
<TableHead className="text-right">综合分</TableHead>
|
||
<TableHead>匹配原因</TableHead>
|
||
<TableHead className="w-12 text-right">查看</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{items.map((item) => (
|
||
<TableRow key={item.neighborhood_id}>
|
||
<TableCell className="min-w-40">
|
||
<span className="block font-medium text-slate-950">{item.name}</span>
|
||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||
{item.property_type} · {item.built_year ?? "-"}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="min-w-32">
|
||
<span className="block">{item.area_name}</span>
|
||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||
{item.district}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="text-right font-medium">
|
||
{formatNumber(item.similarity_score)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<Badge variant={relativePriceVariant(item.relative_price_pct)}>
|
||
{formatRelativePrice(item.relative_price_pct)}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-right">{formatPrice(item.transaction_price_psm)}</TableCell>
|
||
<TableCell className="text-right">
|
||
<span className="block font-medium">{formatNumber(item.investment_score)}</span>
|
||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||
{item.recommendation}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="min-w-64">
|
||
<div className="flex flex-wrap gap-1">
|
||
{item.reasons.slice(0, 5).map((reason) => (
|
||
<Badge key={reason} variant="muted" className="h-5">
|
||
{formatSimilarityReason(reason)}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button
|
||
variant="outline"
|
||
className="h-8 w-8 p-0"
|
||
onClick={() => onSelectNeighborhood(item.neighborhood_id)}
|
||
aria-label={`查看${item.name}`}
|
||
title="查看小区详情"
|
||
>
|
||
<MapPinned className="h-4 w-4" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComparisonWorkspace({
|
||
areaScores,
|
||
neighborhoodScores,
|
||
areaComparison,
|
||
neighborhoodComparison,
|
||
selectedAreaIds,
|
||
selectedNeighborhoodIds,
|
||
loading,
|
||
onAreaIdsChange,
|
||
onNeighborhoodIdsChange,
|
||
}: {
|
||
areaScores: AreaScore[];
|
||
neighborhoodScores: NeighborhoodScore[];
|
||
areaComparison: AreaComparison | null;
|
||
neighborhoodComparison: NeighborhoodComparison | null;
|
||
selectedAreaIds: string[];
|
||
selectedNeighborhoodIds: string[];
|
||
loading: boolean;
|
||
onAreaIdsChange: (ids: string[]) => void;
|
||
onNeighborhoodIdsChange: (ids: string[]) => void;
|
||
}) {
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||
<div>
|
||
<CardTitle>对比工作台</CardTitle>
|
||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||
板块与小区并排比较
|
||
</p>
|
||
</div>
|
||
<div className="grid w-full gap-3 xl:w-[640px] xl:grid-cols-2">
|
||
<ComparisonSelector
|
||
label="板块"
|
||
options={areaScores.map((score) => ({
|
||
id: score.area_id,
|
||
label: score.name,
|
||
detail: score.district,
|
||
}))}
|
||
selectedIds={selectedAreaIds}
|
||
onChange={onAreaIdsChange}
|
||
/>
|
||
<ComparisonSelector
|
||
label="小区"
|
||
options={neighborhoodScores.map((score) => ({
|
||
id: score.neighborhood_id,
|
||
label: score.name,
|
||
detail: score.area_name,
|
||
}))}
|
||
selectedIds={selectedNeighborhoodIds}
|
||
onChange={onNeighborhoodIdsChange}
|
||
/>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-4">
|
||
{loading ? <div className="h-72 rounded-md bg-[var(--muted)]" /> : null}
|
||
{!loading ? (
|
||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||
<ComparisonMatrix
|
||
title="板块矩阵"
|
||
comparison={areaComparison}
|
||
getId={(item) => item.area_id}
|
||
getName={(item) => item.name}
|
||
getMeta={(item) => `${item.district} · ${item.segment}`}
|
||
/>
|
||
<ComparisonRadar
|
||
title="板块雷达"
|
||
metrics={areaComparison?.metrics ?? []}
|
||
items={areaComparison?.items ?? []}
|
||
getId={(item) => item.area_id}
|
||
getName={(item) => item.name}
|
||
/>
|
||
<ComparisonMatrix
|
||
title="小区矩阵"
|
||
comparison={neighborhoodComparison}
|
||
getId={(item) => item.neighborhood_id}
|
||
getName={(item) => item.name}
|
||
getMeta={(item) =>
|
||
`${item.area_name} · ${item.built_year ?? "-"} · ${item.property_type}`
|
||
}
|
||
/>
|
||
<ComparisonRadar
|
||
title="小区雷达"
|
||
metrics={neighborhoodComparison?.metrics ?? []}
|
||
items={neighborhoodComparison?.items ?? []}
|
||
getId={(item) => item.neighborhood_id}
|
||
getName={(item) => item.name}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function ComparisonSelector({
|
||
label,
|
||
options,
|
||
selectedIds,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
options: Array<{ id: string; label: string; detail: string }>;
|
||
selectedIds: string[];
|
||
onChange: (ids: string[]) => void;
|
||
}) {
|
||
function toggle(id: string) {
|
||
if (selectedIds.includes(id)) {
|
||
if (selectedIds.length > 2) {
|
||
onChange(selectedIds.filter((selectedId) => selectedId !== id));
|
||
}
|
||
return;
|
||
}
|
||
if (selectedIds.length < 4) {
|
||
onChange([...selectedIds, id]);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-md border border-[var(--border)] p-3">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<p className="text-sm font-semibold text-slate-950">{label}</p>
|
||
<Badge variant="muted">{selectedIds.length}/4</Badge>
|
||
</div>
|
||
<div className="grid max-h-44 gap-2 overflow-y-auto pr-1">
|
||
{options.map((option) => {
|
||
const selected = selectedIds.includes(option.id);
|
||
const locked = selected && selectedIds.length <= 2;
|
||
return (
|
||
<label
|
||
key={option.id}
|
||
className={
|
||
selected
|
||
? "flex cursor-pointer items-center gap-2 rounded-md border border-teal-200 bg-teal-50 px-2 py-2"
|
||
: "flex cursor-pointer items-center gap-2 rounded-md border border-[var(--border)] px-2 py-2 hover:bg-slate-50"
|
||
}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selected}
|
||
disabled={locked}
|
||
onChange={() => toggle(option.id)}
|
||
/>
|
||
<span className="min-w-0">
|
||
<span className="block truncate text-sm font-medium text-slate-950">
|
||
{option.label}
|
||
</span>
|
||
<span className="block truncate text-xs text-[var(--muted-foreground)]">
|
||
{option.detail}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComparisonMatrix<T>({
|
||
title,
|
||
comparison,
|
||
getId,
|
||
getName,
|
||
getMeta,
|
||
}: {
|
||
title: string;
|
||
comparison: { items: T[]; metrics: ComparisonMetric[]; missing_ids: string[] } | null;
|
||
getId: (item: T) => string;
|
||
getName: (item: T) => string;
|
||
getMeta: (item: T) => string;
|
||
}) {
|
||
if (!comparison || comparison.items.length === 0) {
|
||
return (
|
||
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||
暂无对比数据
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const nameById = new Map(comparison.items.map((item) => [getId(item), getName(item)]));
|
||
const metaById = new Map(comparison.items.map((item) => [getId(item), getMeta(item)]));
|
||
|
||
return (
|
||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>{title}</TableHead>
|
||
{comparison.items.map((item) => (
|
||
<TableHead key={getId(item)} className="min-w-36 text-right">
|
||
<span className="block text-slate-950">{getName(item)}</span>
|
||
<span className="block font-normal text-[var(--muted-foreground)]">
|
||
{metaById.get(getId(item))}
|
||
</span>
|
||
</TableHead>
|
||
))}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{comparison.metrics.map((metric) => (
|
||
<TableRow key={metric.key}>
|
||
<TableCell className="font-medium">
|
||
{metric.label}
|
||
<span className="ml-1 text-xs font-normal text-[var(--muted-foreground)]">
|
||
{metric.unit}
|
||
</span>
|
||
</TableCell>
|
||
{comparison.items.map((item) => {
|
||
const id = getId(item);
|
||
const value = metric.values.find((metricValue) => metricValue.id === id)?.value;
|
||
return (
|
||
<TableCell key={`${metric.key}-${id}`} className="text-right">
|
||
{formatMetricValue(value, metric.unit)}
|
||
</TableCell>
|
||
);
|
||
})}
|
||
</TableRow>
|
||
))}
|
||
{comparison.missing_ids.length > 0 ? (
|
||
<TableRow>
|
||
<TableCell className="text-[var(--muted-foreground)]">缺失</TableCell>
|
||
<TableCell
|
||
colSpan={comparison.items.length}
|
||
className="text-right text-[var(--muted-foreground)]"
|
||
>
|
||
{comparison.missing_ids.map((id) => nameById.get(id) ?? id).join(", ")}
|
||
</TableCell>
|
||
</TableRow>
|
||
) : null}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComparisonRadar<T>({
|
||
title,
|
||
metrics,
|
||
items,
|
||
getId,
|
||
getName,
|
||
}: {
|
||
title: string;
|
||
metrics: ComparisonMetric[];
|
||
items: T[];
|
||
getId: (item: T) => string;
|
||
getName: (item: T) => string;
|
||
}) {
|
||
const radarMetrics = metrics.filter(
|
||
(metric) =>
|
||
metric.unit === "分" ||
|
||
metric.key === "annual_rent_yield_pct" ||
|
||
metric.key === "transaction_count",
|
||
);
|
||
const data = radarMetrics.map((metric) => {
|
||
const row: Record<string, string | number> = { metric: metric.label };
|
||
for (const item of items) {
|
||
const id = getId(item);
|
||
const value = metric.values.find((metricValue) => metricValue.id === id)?.value ?? 0;
|
||
row[id] = metric.direction === "lower" ? 100 - value : value;
|
||
}
|
||
return row;
|
||
});
|
||
const colors = ["#0f766e", "#2563eb", "#b45309", "#be123c"];
|
||
|
||
if (data.length === 0 || items.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="h-80 rounded-md border border-[var(--border)] p-3">
|
||
<p className="mb-2 text-sm font-semibold text-slate-950">{title}</p>
|
||
<ResponsiveContainer width="100%" height="90%">
|
||
<RadarChart data={data}>
|
||
<PolarGrid stroke="#dbe1ea" />
|
||
<PolarAngleAxis dataKey="metric" tick={{ fontSize: 12 }} />
|
||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
|
||
<Tooltip formatter={(value) => [formatNumber(Number(value)), "标准化"]} />
|
||
{items.map((item, index) => (
|
||
<Radar
|
||
key={getId(item)}
|
||
name={getName(item)}
|
||
dataKey={getId(item)}
|
||
stroke={colors[index % colors.length]}
|
||
fill={colors[index % colors.length]}
|
||
fillOpacity={0.14}
|
||
/>
|
||
))}
|
||
</RadarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatMetricValue(value: number | null | undefined, unit: string) {
|
||
if (value === null || value === undefined) {
|
||
return "-";
|
||
}
|
||
if (unit === "元/㎡" || unit === "米") {
|
||
return formatPrice(value);
|
||
}
|
||
if (unit === "%") {
|
||
return `${formatNumber(value, 2)}%`;
|
||
}
|
||
if (unit === "套") {
|
||
return formatNumber(value, 0);
|
||
}
|
||
return formatNumber(value);
|
||
}
|
||
|
||
function formatSignedNumber(value: number) {
|
||
const sign = value > 0 ? "+" : "";
|
||
return `${sign}${formatNumber(value)}`;
|
||
}
|
||
|
||
function formatRelativePrice(value: number) {
|
||
if (value === 0) {
|
||
return "持平";
|
||
}
|
||
const label = value > 0 ? "溢价" : "折价";
|
||
return `${label} ${formatSignedNumber(value)}%`;
|
||
}
|
||
|
||
function relativePriceVariant(value: number): "success" | "warning" | "muted" {
|
||
if (value <= -3) {
|
||
return "success";
|
||
}
|
||
if (value >= 3) {
|
||
return "warning";
|
||
}
|
||
return "muted";
|
||
}
|
||
|
||
function formatSimilarityReason(reason: string) {
|
||
if (reason === "property_type_match") {
|
||
return "物业类型一致";
|
||
}
|
||
if (reason === "same_area") {
|
||
return "同板块";
|
||
}
|
||
if (reason === "same_district") {
|
||
return "同行政区";
|
||
}
|
||
const [key, rawValue] = reason.split(":");
|
||
if (key === "price_gap_pct" && rawValue) {
|
||
return `价差 ${rawValue}%`;
|
||
}
|
||
if (key === "building_age_gap_years" && rawValue) {
|
||
return `楼龄差 ${rawValue}年`;
|
||
}
|
||
if (key === "metro_gap_m" && rawValue) {
|
||
return `地铁差 ${rawValue}米`;
|
||
}
|
||
return reason;
|
||
}
|
||
|
||
function WatchlistPanel({
|
||
scores,
|
||
neighborhoodScores,
|
||
items,
|
||
events,
|
||
loading,
|
||
creating,
|
||
archivingId,
|
||
updatingId,
|
||
creatingEvent,
|
||
statusFilter,
|
||
areaFilter,
|
||
labelFilter,
|
||
expandedItemId,
|
||
onStatusFilterChange,
|
||
onAreaFilterChange,
|
||
onLabelFilterChange,
|
||
onToggleEvents,
|
||
onCreate,
|
||
onUpdate,
|
||
onCreateEvent,
|
||
onArchive,
|
||
}: {
|
||
scores: AreaScore[];
|
||
neighborhoodScores: NeighborhoodScore[];
|
||
items: WatchlistItem[];
|
||
events: WatchlistEvent[];
|
||
loading: boolean;
|
||
creating: boolean;
|
||
archivingId: number | null;
|
||
updatingId: number | null;
|
||
creatingEvent: boolean;
|
||
statusFilter: string;
|
||
areaFilter: string;
|
||
labelFilter: string;
|
||
expandedItemId: number | null;
|
||
onStatusFilterChange: (value: string) => void;
|
||
onAreaFilterChange: (value: string) => void;
|
||
onLabelFilterChange: (value: string) => void;
|
||
onToggleEvents: (id: number) => void;
|
||
onCreate: (payload: CreateWatchlistItem) => void;
|
||
onUpdate: (id: number, payload: UpdateWatchlistItem) => void;
|
||
onCreateEvent: (id: number, payload: CreateWatchlistEvent) => void;
|
||
onArchive: (id: number) => void;
|
||
}) {
|
||
const [targetType, setTargetType] = useState<"area" | "neighborhood">("area");
|
||
const [areaId, setAreaId] = useState("");
|
||
const [neighborhoodId, setNeighborhoodId] = useState("");
|
||
const [targetPrice, setTargetPrice] = useState("");
|
||
const [triggerPrice, setTriggerPrice] = useState("");
|
||
const [triggerOperator, setTriggerOperator] = useState<"lte" | "gte">("lte");
|
||
const [label, setLabel] = useState("核心");
|
||
const [priority, setPriority] = useState<"low" | "medium" | "high">("medium");
|
||
const [notes, setNotes] = useState("");
|
||
|
||
const areaNameById = useMemo(() => {
|
||
return new Map(scores.map((score) => [score.area_id, score.name]));
|
||
}, [scores]);
|
||
const neighborhoodNameById = useMemo(() => {
|
||
return new Map(
|
||
neighborhoodScores.map((score) => [
|
||
score.neighborhood_id,
|
||
`${score.name}(${score.area_name})`,
|
||
]),
|
||
);
|
||
}, [neighborhoodScores]);
|
||
|
||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (targetType === "area" && !areaId) {
|
||
return;
|
||
}
|
||
if (targetType === "neighborhood" && !neighborhoodId) {
|
||
return;
|
||
}
|
||
const parsedTarget = Number(targetPrice);
|
||
const parsedTrigger = Number(triggerPrice);
|
||
onCreate({
|
||
area_id: targetType === "area" ? areaId : undefined,
|
||
neighborhood_id: targetType === "neighborhood" ? neighborhoodId : undefined,
|
||
target_price_psm: Number.isFinite(parsedTarget) && parsedTarget > 0 ? parsedTarget : undefined,
|
||
trigger_price_psm:
|
||
Number.isFinite(parsedTrigger) && parsedTrigger > 0 ? parsedTrigger : undefined,
|
||
trigger_operator: triggerOperator,
|
||
label: label.trim() || undefined,
|
||
priority,
|
||
notes: notes.trim() || undefined,
|
||
});
|
||
setAreaId("");
|
||
setNeighborhoodId("");
|
||
setTargetPrice("");
|
||
setTriggerPrice("");
|
||
setNotes("");
|
||
}
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-col gap-3">
|
||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||
<CardTitle>观察池</CardTitle>
|
||
<div className="grid gap-2 sm:grid-cols-3 xl:w-[620px]">
|
||
<select
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={statusFilter}
|
||
onChange={(event) => onStatusFilterChange(event.target.value)}
|
||
aria-label="观察池状态筛选"
|
||
>
|
||
<option value="active">active</option>
|
||
<option value="paused">paused</option>
|
||
<option value="archived">archived</option>
|
||
</select>
|
||
<select
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={areaFilter}
|
||
onChange={(event) => onAreaFilterChange(event.target.value)}
|
||
aria-label="观察池板块筛选"
|
||
>
|
||
<option value="">全部板块</option>
|
||
{scores.map((score) => (
|
||
<option key={score.area_id} value={score.area_id}>
|
||
{score.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
placeholder="标签"
|
||
value={labelFilter}
|
||
onChange={(event) => onLabelFilterChange(event.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<form className="grid gap-2 lg:grid-cols-[100px_1fr_1fr_120px_120px_90px_90px_100px_1fr_44px]" onSubmit={submit}>
|
||
<select
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={targetType}
|
||
onChange={(event) => setTargetType(event.target.value as "area" | "neighborhood")}
|
||
aria-label="观察标的类型"
|
||
>
|
||
<option value="area">板块</option>
|
||
<option value="neighborhood">小区</option>
|
||
</select>
|
||
<select
|
||
className="h-9 min-w-36 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={areaId}
|
||
onChange={(event) => setAreaId(event.target.value)}
|
||
aria-label="板块"
|
||
disabled={targetType !== "area"}
|
||
>
|
||
<option value="">板块</option>
|
||
{scores.map((score) => (
|
||
<option key={score.area_id} value={score.area_id}>
|
||
{score.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
className="h-9 min-w-36 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={neighborhoodId}
|
||
onChange={(event) => setNeighborhoodId(event.target.value)}
|
||
aria-label="小区"
|
||
disabled={targetType !== "neighborhood"}
|
||
>
|
||
<option value="">小区</option>
|
||
{neighborhoodScores.map((score) => (
|
||
<option key={score.neighborhood_id} value={score.neighborhood_id}>
|
||
{score.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
inputMode="numeric"
|
||
placeholder="目标价/㎡"
|
||
value={targetPrice}
|
||
onChange={(event) => setTargetPrice(event.target.value)}
|
||
/>
|
||
<input
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
inputMode="numeric"
|
||
placeholder="触发价/㎡"
|
||
value={triggerPrice}
|
||
onChange={(event) => setTriggerPrice(event.target.value)}
|
||
/>
|
||
<select
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={triggerOperator}
|
||
onChange={(event) => setTriggerOperator(event.target.value as "lte" | "gte")}
|
||
aria-label="触发条件"
|
||
>
|
||
<option value="lte">低于</option>
|
||
<option value="gte">高于</option>
|
||
</select>
|
||
<select
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={priority}
|
||
onChange={(event) => setPriority(event.target.value as "low" | "medium" | "high")}
|
||
aria-label="优先级"
|
||
>
|
||
<option value="high">high</option>
|
||
<option value="medium">medium</option>
|
||
<option value="low">low</option>
|
||
</select>
|
||
<input
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
placeholder="标签"
|
||
value={label}
|
||
onChange={(event) => setLabel(event.target.value)}
|
||
/>
|
||
<input
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
placeholder="备注"
|
||
value={notes}
|
||
onChange={(event) => setNotes(event.target.value)}
|
||
/>
|
||
<Button
|
||
disabled={
|
||
creating ||
|
||
(targetType === "area" && !areaId) ||
|
||
(targetType === "neighborhood" && !neighborhoodId)
|
||
}
|
||
aria-label="加入观察池"
|
||
title="加入观察池"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
</form>
|
||
</CardHeader>
|
||
<CardContent className="overflow-x-auto p-0">
|
||
<WatchlistTable
|
||
items={items}
|
||
areaNameById={areaNameById}
|
||
neighborhoodNameById={neighborhoodNameById}
|
||
loading={loading}
|
||
archivingId={archivingId}
|
||
updatingId={updatingId}
|
||
expandedItemId={expandedItemId}
|
||
events={events}
|
||
creatingEvent={creatingEvent}
|
||
onUpdate={onUpdate}
|
||
onCreateEvent={onCreateEvent}
|
||
onArchive={onArchive}
|
||
onToggleEvents={onToggleEvents}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function WatchlistTable({
|
||
items,
|
||
areaNameById,
|
||
neighborhoodNameById,
|
||
loading,
|
||
archivingId,
|
||
updatingId,
|
||
expandedItemId,
|
||
events,
|
||
creatingEvent,
|
||
onUpdate,
|
||
onCreateEvent,
|
||
onArchive,
|
||
onToggleEvents,
|
||
}: {
|
||
items: WatchlistItem[];
|
||
areaNameById: Map<string, string>;
|
||
neighborhoodNameById: Map<string, string>;
|
||
loading: boolean;
|
||
archivingId: number | null;
|
||
updatingId: number | null;
|
||
expandedItemId: number | null;
|
||
events: WatchlistEvent[];
|
||
creatingEvent: boolean;
|
||
onUpdate: (id: number, payload: UpdateWatchlistItem) => void;
|
||
onCreateEvent: (id: number, payload: CreateWatchlistEvent) => void;
|
||
onArchive: (id: number) => void;
|
||
onToggleEvents: (id: number) => void;
|
||
}) {
|
||
const [eventSummary, setEventSummary] = useState("");
|
||
|
||
if (loading) {
|
||
return <div className="h-32 bg-[var(--muted)]" />;
|
||
}
|
||
|
||
if (items.length === 0) {
|
||
return <div className="px-4 py-8 text-sm text-[var(--muted-foreground)]">暂无观察标的</div>;
|
||
}
|
||
|
||
return (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>标的</TableHead>
|
||
<TableHead className="text-right">目标价/㎡</TableHead>
|
||
<TableHead className="text-right">触发价/㎡</TableHead>
|
||
<TableHead>标签</TableHead>
|
||
<TableHead>优先级</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>复核</TableHead>
|
||
<TableHead>备注</TableHead>
|
||
<TableHead className="w-44" />
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{items.map((item) => {
|
||
const targetName = item.neighborhood_id
|
||
? neighborhoodNameById.get(item.neighborhood_id) ?? item.neighborhood_id
|
||
: item.area_id
|
||
? areaNameById.get(item.area_id) ?? item.area_id
|
||
: "-";
|
||
const expanded = expandedItemId === item.watchlist_item_id;
|
||
return (
|
||
<Fragment key={item.watchlist_item_id}>
|
||
<TableRow>
|
||
<TableCell className="font-medium">{targetName}</TableCell>
|
||
<TableCell className="text-right">
|
||
{item.target_price_psm ? formatPrice(item.target_price_psm) : "-"}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
{item.trigger_price_psm
|
||
? `${item.trigger_operator === "lte" ? "≤" : "≥"} ${formatPrice(item.trigger_price_psm)}`
|
||
: "-"}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant="muted">{item.label}</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={item.priority === "high" ? "warning" : "muted"}>
|
||
{item.priority}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<select
|
||
className="h-8 rounded-md border border-[var(--border)] bg-white px-2 text-sm"
|
||
value={item.status}
|
||
disabled={updatingId === item.watchlist_item_id}
|
||
onChange={(event) =>
|
||
onUpdate(item.watchlist_item_id, {
|
||
status: event.target.value as "active" | "paused" | "archived",
|
||
})
|
||
}
|
||
aria-label="观察池状态"
|
||
>
|
||
<option value="active">active</option>
|
||
<option value="paused">paused</option>
|
||
<option value="archived">archived</option>
|
||
</select>
|
||
</TableCell>
|
||
<TableCell className="text-[var(--muted-foreground)]">
|
||
{item.last_reviewed_at ? item.last_reviewed_at.slice(0, 10) : "-"}
|
||
</TableCell>
|
||
<TableCell className="max-w-80 truncate text-[var(--muted-foreground)]">
|
||
{item.notes || "-"}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex justify-end gap-2">
|
||
<Button
|
||
variant="outline"
|
||
disabled={updatingId === item.watchlist_item_id}
|
||
onClick={() =>
|
||
onUpdate(item.watchlist_item_id, {
|
||
mark_reviewed: true,
|
||
})
|
||
}
|
||
aria-label="复核"
|
||
title="复核"
|
||
>
|
||
<Check className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => onToggleEvents(item.watchlist_item_id)}
|
||
aria-label="历史"
|
||
title="历史"
|
||
>
|
||
<Activity className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
disabled={archivingId === item.watchlist_item_id}
|
||
onClick={() => onArchive(item.watchlist_item_id)}
|
||
aria-label="归档"
|
||
title="归档"
|
||
>
|
||
<Archive className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
{expanded ? (
|
||
<TableRow>
|
||
<TableCell colSpan={9} className="bg-slate-50">
|
||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||
<div className="grid gap-2">
|
||
{events.length === 0 ? (
|
||
<p className="text-sm text-[var(--muted-foreground)]">暂无历史</p>
|
||
) : (
|
||
events.map((event) => (
|
||
<div
|
||
key={event.event_id}
|
||
className="rounded-md border border-[var(--border)] bg-white px-3 py-2"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<Badge variant="muted">{event.event_type}</Badge>
|
||
<span className="text-xs text-[var(--muted-foreground)]">
|
||
{event.created_at.slice(0, 16)}
|
||
</span>
|
||
</div>
|
||
<p className="mt-2 text-sm text-slate-950">{event.summary}</p>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
<form
|
||
className="flex gap-2"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
if (!eventSummary.trim()) {
|
||
return;
|
||
}
|
||
onCreateEvent(item.watchlist_item_id, {
|
||
event_type: "note",
|
||
summary: eventSummary.trim(),
|
||
});
|
||
setEventSummary("");
|
||
}}
|
||
>
|
||
<input
|
||
className="h-9 min-w-0 flex-1 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
placeholder="新增历史备注"
|
||
value={eventSummary}
|
||
onChange={(event) => setEventSummary(event.target.value)}
|
||
/>
|
||
<Button
|
||
disabled={creatingEvent || !eventSummary.trim()}
|
||
aria-label="新增历史"
|
||
title="新增历史"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
</form>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : null}
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|
||
|
||
function ModelRunPanel({
|
||
runs,
|
||
diff,
|
||
baseRunId,
|
||
candidateRunId,
|
||
loading,
|
||
creating,
|
||
onBaseRunChange,
|
||
onCandidateRunChange,
|
||
onCreateRun,
|
||
}: {
|
||
runs: ModelRun[];
|
||
diff: AreaScoreRunDiff | null;
|
||
baseRunId: number | null;
|
||
candidateRunId: number | null;
|
||
loading: boolean;
|
||
creating: boolean;
|
||
onBaseRunChange: (id: number | null) => void;
|
||
onCandidateRunChange: (id: number | null) => void;
|
||
onCreateRun: (payload: CreateAreaScoreModelRun) => void;
|
||
}) {
|
||
const [modelVersion, setModelVersion] = useState("area-score-rust-v1");
|
||
|
||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
onCreateRun({
|
||
month: MONTH,
|
||
model_version: modelVersion.trim() || undefined,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||
<div>
|
||
<CardTitle>模型运行中心</CardTitle>
|
||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||
板块评分批次与版本差异
|
||
</p>
|
||
</div>
|
||
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={submit}>
|
||
<input
|
||
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-56"
|
||
placeholder="模型版本"
|
||
value={modelVersion}
|
||
onChange={(event) => setModelVersion(event.target.value)}
|
||
/>
|
||
<Button disabled={creating} aria-label="重算板块评分" title="重算板块评分">
|
||
<RefreshCw className="h-4 w-4" />
|
||
</Button>
|
||
</form>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-4 xl:grid-cols-[420px_minmax(0,1fr)]">
|
||
<ModelRunTable runs={runs} loading={loading} />
|
||
<div className="grid gap-3">
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<ModelRunSelect
|
||
label="基准批次"
|
||
runs={runs}
|
||
value={baseRunId}
|
||
onChange={onBaseRunChange}
|
||
/>
|
||
<ModelRunSelect
|
||
label="候选批次"
|
||
runs={runs}
|
||
value={candidateRunId}
|
||
onChange={onCandidateRunChange}
|
||
/>
|
||
</div>
|
||
<AreaScoreRunDiffTable diff={diff} loading={loading} />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function ModelRunTable({ runs, loading }: { runs: ModelRun[]; loading: boolean }) {
|
||
if (loading) {
|
||
return <div className="h-56 rounded-md bg-[var(--muted)]" />;
|
||
}
|
||
|
||
if (runs.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>版本</TableHead>
|
||
<TableHead>月份</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead className="text-right">行数</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{runs.slice(0, 8).map((run) => (
|
||
<TableRow key={run.model_run_id}>
|
||
<TableCell className="font-medium">#{run.model_run_id}</TableCell>
|
||
<TableCell className="max-w-40 truncate">{run.model_version}</TableCell>
|
||
<TableCell>{run.target_month}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={run.status === "succeeded" ? "success" : "warning"}>
|
||
{run.status}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-right">{run.row_count ?? "-"}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ModelRunSelect({
|
||
label,
|
||
runs,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
runs: ModelRun[];
|
||
value: number | null;
|
||
onChange: (id: number | null) => void;
|
||
}) {
|
||
return (
|
||
<label className="grid gap-1">
|
||
<span className="text-sm font-medium text-slate-950">{label}</span>
|
||
<select
|
||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||
value={value ?? ""}
|
||
onChange={(event) => onChange(event.target.value ? Number(event.target.value) : null)}
|
||
>
|
||
<option value="">选择批次</option>
|
||
{runs.map((run) => (
|
||
<option key={run.model_run_id} value={run.model_run_id}>
|
||
#{run.model_run_id} · {run.model_version} · {run.target_month}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function AreaScoreRunDiffTable({
|
||
diff,
|
||
loading,
|
||
}: {
|
||
diff: AreaScoreRunDiff | null;
|
||
loading: boolean;
|
||
}) {
|
||
if (loading) {
|
||
return <div className="h-56 rounded-md bg-[var(--muted)]" />;
|
||
}
|
||
|
||
if (!diff || diff.items.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>行政区</TableHead>
|
||
<TableHead className="text-right">基准</TableHead>
|
||
<TableHead className="text-right">候选</TableHead>
|
||
<TableHead className="text-right">差异</TableHead>
|
||
<TableHead>建议变化</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{diff.items.map((item) => (
|
||
<TableRow key={item.area_id}>
|
||
<TableCell className="font-medium">{item.name}</TableCell>
|
||
<TableCell>{item.district}</TableCell>
|
||
<TableCell className="text-right">
|
||
{item.base_score === null ? "-" : formatNumber(item.base_score)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
{item.candidate_score === null ? "-" : formatNumber(item.candidate_score)}
|
||
</TableCell>
|
||
<TableCell className="text-right font-semibold">
|
||
{item.score_delta === null ? "-" : formatSignedNumber(item.score_delta)}
|
||
</TableCell>
|
||
<TableCell>
|
||
{item.base_recommendation === item.candidate_recommendation ? (
|
||
<Badge variant="muted">{item.candidate_recommendation ?? "-"}</Badge>
|
||
) : (
|
||
<Badge variant="warning">
|
||
{item.base_recommendation ?? "-"} → {item.candidate_recommendation ?? "-"}
|
||
</Badge>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MetricCard({
|
||
title,
|
||
value,
|
||
textValue,
|
||
suffix,
|
||
icon,
|
||
loading,
|
||
tone = "default",
|
||
}: {
|
||
title: string;
|
||
value?: number;
|
||
textValue?: string;
|
||
suffix?: string;
|
||
icon: React.ReactNode;
|
||
loading: boolean;
|
||
tone?: "default" | "warning";
|
||
}) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="flex min-h-28 items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<p className="text-sm text-[var(--muted-foreground)]">{title}</p>
|
||
<p className="mt-2 truncate text-2xl font-semibold text-slate-950">
|
||
{loading ? "-" : textValue ?? `${formatNumber(value ?? 0)}${suffix ?? ""}`}
|
||
</p>
|
||
</div>
|
||
<div
|
||
className={
|
||
tone === "warning"
|
||
? "flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-amber-50 text-[var(--warning)]"
|
||
: "flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-teal-50 text-[var(--primary)]"
|
||
}
|
||
>
|
||
{icon}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function ScoreChart({ scores, loading }: { scores: AreaScore[]; loading: boolean }) {
|
||
if (loading) {
|
||
return <div className="h-full rounded-md bg-[var(--muted)]" />;
|
||
}
|
||
|
||
return (
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<BarChart data={scores} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
|
||
<CartesianGrid stroke="#e2e8f0" vertical={false} />
|
||
<XAxis dataKey="name" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
||
<YAxis domain={[0, 100]} tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
||
<Tooltip
|
||
cursor={{ fill: "#eef2f7" }}
|
||
formatter={(value) => [`${formatNumber(Number(value))} 分`, "综合分"]}
|
||
labelStyle={{ color: "#172033" }}
|
||
/>
|
||
<Bar dataKey="investment_score" fill="#0f766e" radius={[4, 4, 0, 0]} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
);
|
||
}
|
||
|
||
function InsightRow({ label, value, detail }: { label: string; value: string; detail: string }) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-3 border-b border-[var(--border)] pb-3 last:border-0 last:pb-0">
|
||
<div className="min-w-0">
|
||
<p className="text-sm text-[var(--muted-foreground)]">{label}</p>
|
||
<p className="mt-1 truncate text-base font-semibold text-slate-950">{value}</p>
|
||
</div>
|
||
<Badge variant="muted">{detail}</Badge>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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)]" />;
|
||
}
|
||
|
||
return (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>板块</TableHead>
|
||
<TableHead>行政区</TableHead>
|
||
<TableHead className="text-right">综合分</TableHead>
|
||
<TableHead>建议</TableHead>
|
||
<TableHead className="text-right">成交均价/㎡</TableHead>
|
||
<TableHead className="text-right">成交套数</TableHead>
|
||
<TableHead className="text-right">租金收益率</TableHead>
|
||
<TableHead className="text-right">供应风险</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{scores.map((score) => (
|
||
<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">
|
||
{formatNumber(score.investment_score)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={score.recommendation === "观察池" ? "success" : "muted"}>
|
||
{score.recommendation}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-right">{formatPrice(score.transaction_price_psm)}</TableCell>
|
||
<TableCell className="text-right">{score.transaction_count}</TableCell>
|
||
<TableCell className="text-right">
|
||
{formatNumber(score.annual_rent_yield_pct, 2)}%
|
||
</TableCell>
|
||
<TableCell className="text-right">{formatNumber(score.supply_risk_score)}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|
||
|
||
function ErrorState() {
|
||
return (
|
||
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
|
||
API 暂时不可用
|
||
</div>
|
||
);
|
||
}
|