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 (
+
+
+ 观察池
+
+
+
+
+
+
+ );
+}
+
+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}`);
}