feat: add watchlist dashboard workflow
This commit is contained in:
@@ -1,7 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Activity, AlertTriangle, MapPinned, RefreshCw, TrendingUp } from "lucide-react";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
Archive,
|
||||||
|
MapPinned,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -12,8 +22,14 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
import { fetchAreaScores, fetchMarketOverview } from "@/lib/api";
|
import {
|
||||||
import type { AreaScore } from "@/lib/api";
|
archiveWatchlistItem,
|
||||||
|
createWatchlistItem,
|
||||||
|
fetchAreaScores,
|
||||||
|
fetchMarketOverview,
|
||||||
|
fetchWatchlistItems,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type { AreaScore, WatchlistItem } from "@/lib/api";
|
||||||
import { formatNumber, formatPrice } from "@/lib/utils";
|
import { formatNumber, formatPrice } from "@/lib/utils";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -30,6 +46,7 @@ import {
|
|||||||
const MONTH = "2026-05";
|
const MONTH = "2026-05";
|
||||||
|
|
||||||
export function MarketDashboard() {
|
export function MarketDashboard() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const overview = useQuery({
|
const overview = useQuery({
|
||||||
queryKey: ["market-overview", MONTH],
|
queryKey: ["market-overview", MONTH],
|
||||||
queryFn: () => fetchMarketOverview(MONTH),
|
queryFn: () => fetchMarketOverview(MONTH),
|
||||||
@@ -38,9 +55,25 @@ export function MarketDashboard() {
|
|||||||
queryKey: ["area-scores", MONTH],
|
queryKey: ["area-scores", MONTH],
|
||||||
queryFn: () => fetchAreaScores(MONTH),
|
queryFn: () => fetchAreaScores(MONTH),
|
||||||
});
|
});
|
||||||
|
const watchlist = useQuery({
|
||||||
|
queryKey: ["watchlist"],
|
||||||
|
queryFn: fetchWatchlistItems,
|
||||||
|
});
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: createWatchlistItem,
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["watchlist"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: archiveWatchlistItem,
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["watchlist"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const isLoading = overview.isLoading || scores.isLoading;
|
const isLoading = overview.isLoading || scores.isLoading || watchlist.isLoading;
|
||||||
const hasError = overview.isError || scores.isError;
|
const hasError = overview.isError || scores.isError || watchlist.isError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen px-4 py-5 sm:px-6 lg:px-8">
|
<main className="min-h-screen px-4 py-5 sm:px-6 lg:px-8">
|
||||||
@@ -59,6 +92,7 @@ export function MarketDashboard() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
void overview.refetch();
|
void overview.refetch();
|
||||||
void scores.refetch();
|
void scores.refetch();
|
||||||
|
void watchlist.refetch();
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label="刷新"
|
aria-label="刷新"
|
||||||
@@ -154,11 +188,175 @@ export function MarketDashboard() {
|
|||||||
<AreaScoreTable scores={scores.data ?? []} loading={isLoading} />
|
<AreaScoreTable scores={scores.data ?? []} loading={isLoading} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<WatchlistPanel
|
||||||
|
scores={scores.data ?? []}
|
||||||
|
items={watchlist.data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
creating={createMutation.isPending}
|
||||||
|
archivingId={archiveMutation.variables ?? null}
|
||||||
|
onCreate={(payload) => createMutation.mutate(payload)}
|
||||||
|
onArchive={(id) => archiveMutation.mutate(id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WatchlistPanel({
|
||||||
|
scores,
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
creating,
|
||||||
|
archivingId,
|
||||||
|
onCreate,
|
||||||
|
onArchive,
|
||||||
|
}: {
|
||||||
|
scores: AreaScore[];
|
||||||
|
items: WatchlistItem[];
|
||||||
|
loading: boolean;
|
||||||
|
creating: boolean;
|
||||||
|
archivingId: number | null;
|
||||||
|
onCreate: (payload: { area_id: string; target_price_psm?: number; notes?: string }) => void;
|
||||||
|
onArchive: (id: number) => void;
|
||||||
|
}) {
|
||||||
|
const [areaId, setAreaId] = useState("");
|
||||||
|
const [targetPrice, setTargetPrice] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
const areaNameById = useMemo(() => {
|
||||||
|
return new Map(scores.map((score) => [score.area_id, score.name]));
|
||||||
|
}, [scores]);
|
||||||
|
|
||||||
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!areaId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsedTarget = Number(targetPrice);
|
||||||
|
onCreate({
|
||||||
|
area_id: areaId,
|
||||||
|
target_price_psm: Number.isFinite(parsedTarget) && parsedTarget > 0 ? parsedTarget : undefined,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
setTargetPrice("");
|
||||||
|
setNotes("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<CardTitle>观察池</CardTitle>
|
||||||
|
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={submit}>
|
||||||
|
<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="板块"
|
||||||
|
>
|
||||||
|
<option value="">板块</option>
|
||||||
|
{scores.map((score) => (
|
||||||
|
<option key={score.area_id} value={score.area_id}>
|
||||||
|
{score.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-32"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="目标价/㎡"
|
||||||
|
value={targetPrice}
|
||||||
|
onChange={(event) => setTargetPrice(event.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-56"
|
||||||
|
placeholder="备注"
|
||||||
|
value={notes}
|
||||||
|
onChange={(event) => setNotes(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button disabled={!areaId || creating} aria-label="加入观察池" title="加入观察池">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto p-0">
|
||||||
|
<WatchlistTable
|
||||||
|
items={items}
|
||||||
|
areaNameById={areaNameById}
|
||||||
|
loading={loading}
|
||||||
|
archivingId={archivingId}
|
||||||
|
onArchive={onArchive}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WatchlistTable({
|
||||||
|
items,
|
||||||
|
areaNameById,
|
||||||
|
loading,
|
||||||
|
archivingId,
|
||||||
|
onArchive,
|
||||||
|
}: {
|
||||||
|
items: WatchlistItem[];
|
||||||
|
areaNameById: Map<string, string>;
|
||||||
|
loading: boolean;
|
||||||
|
archivingId: number | null;
|
||||||
|
onArchive: (id: number) => void;
|
||||||
|
}) {
|
||||||
|
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>状态</TableHead>
|
||||||
|
<TableHead>备注</TableHead>
|
||||||
|
<TableHead className="w-14" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow key={item.watchlist_item_id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{item.area_id ? areaNameById.get(item.area_id) ?? item.area_id : item.neighborhood_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{item.target_price_psm ? formatPrice(item.target_price_psm) : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={item.status === "active" ? "success" : "muted"}>{item.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-80 truncate text-[var(--muted-foreground)]">
|
||||||
|
{item.notes || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<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>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MetricCard({
|
function MetricCard({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ export type MarketOverview = {
|
|||||||
highest_supply_risk_area: AreaScoreSummary | null;
|
highest_supply_risk_area: AreaScoreSummary | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WatchlistItem = {
|
||||||
|
watchlist_item_id: number;
|
||||||
|
neighborhood_id: string | null;
|
||||||
|
area_id: string | null;
|
||||||
|
target_price_psm: number | null;
|
||||||
|
status: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateWatchlistItem = {
|
||||||
|
area_id?: string;
|
||||||
|
neighborhood_id?: string;
|
||||||
|
target_price_psm?: number;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080";
|
||||||
|
|
||||||
@@ -55,8 +71,33 @@ export async function fetchAreaScores(month: string): Promise<AreaScore[]> {
|
|||||||
return fetchJson(`/api/v1/areas/scores?month=${encodeURIComponent(month)}`);
|
return fetchJson(`/api/v1/areas/scores?month=${encodeURIComponent(month)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(path: string): Promise<T> {
|
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`);
|
return fetchJson("/api/v1/watchlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWatchlistItem(
|
||||||
|
payload: CreateWatchlistItem,
|
||||||
|
): Promise<WatchlistItem> {
|
||||||
|
return fetchJson("/api/v1/watchlist", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveWatchlistItem(id: number): Promise<WatchlistItem> {
|
||||||
|
return fetchJson(`/api/v1/watchlist/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed with ${response.status}`);
|
throw new Error(`API request failed with ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user