feat: add watchlist dashboard workflow

This commit is contained in:
2026-06-23 10:00:09 +08:00
parent fcc68c9eb8
commit c2604c65c1
2 changed files with 246 additions and 7 deletions

View File

@@ -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 (
<main className="min-h-screen px-4 py-5 sm:px-6 lg:px-8">
@@ -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() {
<AreaScoreTable scores={scores.data ?? []} loading={isLoading} />
</CardContent>
</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>
</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({
title,
value,

View File

@@ -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<AreaScore[]> {
return fetchJson(`/api/v1/areas/scores?month=${encodeURIComponent(month)}`);
}
async function fetchJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`);
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
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) {
throw new Error(`API request failed with ${response.status}`);
}