From c2604c65c1721ad4ddbe69d79214c3055bc5c5c1 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Tue, 23 Jun 2026 10:00:09 +0800 Subject: [PATCH] feat: add watchlist dashboard workflow --- .../components/dashboard/market-dashboard.tsx | 208 +++++++++++++++++- apps/web/src/lib/api.ts | 45 +++- 2 files changed, 246 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index 6bd7bf9..43dd345 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -1,7 +1,17 @@ "use client"; 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 { Bar, BarChart, @@ -12,8 +22,14 @@ import { YAxis, } from "recharts"; -import { fetchAreaScores, fetchMarketOverview } from "@/lib/api"; -import type { AreaScore } from "@/lib/api"; +import { + archiveWatchlistItem, + createWatchlistItem, + fetchAreaScores, + fetchMarketOverview, + fetchWatchlistItems, +} from "@/lib/api"; +import type { AreaScore, WatchlistItem } from "@/lib/api"; import { formatNumber, formatPrice } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -30,6 +46,7 @@ import { const MONTH = "2026-05"; export function MarketDashboard() { + const queryClient = useQueryClient(); const overview = useQuery({ queryKey: ["market-overview", MONTH], queryFn: () => fetchMarketOverview(MONTH), @@ -38,9 +55,25 @@ export function MarketDashboard() { queryKey: ["area-scores", 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 hasError = overview.isError || scores.isError; + const isLoading = overview.isLoading || scores.isLoading || watchlist.isLoading; + const hasError = overview.isError || scores.isError || watchlist.isError; return (
@@ -59,6 +92,7 @@ export function MarketDashboard() { onClick={() => { void overview.refetch(); void scores.refetch(); + void watchlist.refetch(); }} disabled={isLoading} aria-label="刷新" @@ -154,11 +188,175 @@ export function MarketDashboard() { + + createMutation.mutate(payload)} + onArchive={(id) => archiveMutation.mutate(id)} + />
); } +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) { + 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 ( + + + 观察池 +
+ + setTargetPrice(event.target.value)} + /> + setNotes(event.target.value)} + /> + +
+
+ + + +
+ ); +} + +function WatchlistTable({ + items, + areaNameById, + loading, + archivingId, + onArchive, +}: { + items: WatchlistItem[]; + areaNameById: Map; + loading: boolean; + archivingId: number | null; + onArchive: (id: number) => void; +}) { + if (loading) { + return
; + } + + if (items.length === 0) { + return
暂无观察标的
; + } + + return ( + + + + 标的 + 目标价/㎡ + 状态 + 备注 + + + + + {items.map((item) => ( + + + {item.area_id ? areaNameById.get(item.area_id) ?? item.area_id : item.neighborhood_id} + + + {item.target_price_psm ? formatPrice(item.target_price_psm) : "-"} + + + {item.status} + + + {item.notes || "-"} + + + + + + ))} + +
+ ); +} + function MetricCard({ title, value, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 407d7b7..4b4fcd2 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -44,6 +44,22 @@ export type MarketOverview = { 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 = 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 { return fetchJson(`/api/v1/areas/scores?month=${encodeURIComponent(month)}`); } -async function fetchJson(path: string): Promise { - const response = await fetch(`${API_BASE_URL}${path}`); +export async function fetchWatchlistItems(): Promise { + return fetchJson("/api/v1/watchlist"); +} + +export async function createWatchlistItem( + payload: CreateWatchlistItem, +): Promise { + return fetchJson("/api/v1/watchlist", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function archiveWatchlistItem(id: number): Promise { + return fetchJson(`/api/v1/watchlist/${id}`, { + method: "DELETE", + }); +} + +async function fetchJson(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...init?.headers, + }, + }); if (!response.ok) { throw new Error(`API request failed with ${response.status}`); }