feat: enhance watchlist workflow

This commit is contained in:
2026-06-24 14:43:58 +08:00
parent f8fb09d2a9
commit f1ad5a7d15
7 changed files with 829 additions and 68 deletions

View File

@@ -12,7 +12,7 @@ import {
RefreshCw,
TrendingUp,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import {
Bar,
BarChart,
@@ -35,6 +35,7 @@ import {
createDataSource,
createIngestionRun,
createRawArtifact,
createWatchlistEvent,
createWatchlistItem,
fetchDataSources,
fetchAreaScores,
@@ -46,8 +47,10 @@ import {
fetchNeighborhoodDetail,
fetchNeighborhoodScores,
fetchRawArtifacts,
fetchWatchlistEvents,
fetchWatchlistItems,
finishIngestionRun,
updateWatchlistItem,
} from "@/lib/api";
import type {
AreaScore,
@@ -57,6 +60,7 @@ import type {
CreateDataSource,
CreateIngestionRun,
CreateRawArtifact,
CreateWatchlistEvent,
CreateWatchlistItem,
DataSource,
IngestionRun,
@@ -65,6 +69,8 @@ import type {
NeighborhoodDetail,
NeighborhoodScore,
RawArtifact,
UpdateWatchlistItem,
WatchlistEvent,
WatchlistItem,
} from "@/lib/api";
import { formatNumber, formatPrice } from "@/lib/utils";
@@ -89,6 +95,10 @@ export function MarketDashboard() {
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 overview = useQuery({
queryKey: ["market-overview", MONTH],
queryFn: () => fetchMarketOverview(MONTH),
@@ -122,8 +132,18 @@ export function MarketDashboard() {
enabled: comparisonNeighborhoodIds.length >= 2,
});
const watchlist = useQuery({
queryKey: ["watchlist"],
queryFn: fetchWatchlistItems,
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 dataSources = useQuery({
queryKey: ["data-sources"],
@@ -152,6 +172,24 @@ export function MarketDashboard() {
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 createDataSourceMutation = useMutation({
mutationFn: createDataSource,
onSuccess: async () => {
@@ -214,6 +252,7 @@ export function MarketDashboard() {
neighborhoodComparison.isLoading ||
neighborhoodScores.isLoading ||
watchlist.isLoading ||
watchlistEvents.isLoading ||
dataSources.isLoading ||
ingestionRuns.isLoading;
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
@@ -226,6 +265,7 @@ export function MarketDashboard() {
neighborhoodComparison.isError ||
neighborhoodScores.isError ||
watchlist.isError ||
watchlistEvents.isError ||
dataSources.isError ||
ingestionRuns.isError ||
rawArtifacts.isError;
@@ -400,10 +440,25 @@ export function MarketDashboard() {
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)}
/>
@@ -1833,23 +1888,55 @@ 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(() => {
@@ -1866,29 +1953,85 @@ function WatchlistPanel({
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!areaId) {
if (targetType === "area" && !areaId) {
return;
}
if (targetType === "neighborhood" && !neighborhoodId) {
return;
}
const parsedTarget = Number(targetPrice);
const parsedTrigger = Number(triggerPrice);
onCreate({
area_id: areaId,
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 lg:flex-row lg:items-center lg:justify-between">
<CardTitle></CardTitle>
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={submit}>
<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) => (
@@ -1897,20 +2040,74 @@ function WatchlistPanel({
</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 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-32"
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 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-56"
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={!areaId || creating} aria-label="加入观察池" title="加入观察池">
<Button
disabled={
creating ||
(targetType === "area" && !areaId) ||
(targetType === "neighborhood" && !neighborhoodId)
}
aria-label="加入观察池"
title="加入观察池"
>
<Plus className="h-4 w-4" />
</Button>
</form>
@@ -1922,7 +2119,14 @@ function WatchlistPanel({
neighborhoodNameById={neighborhoodNameById}
loading={loading}
archivingId={archivingId}
updatingId={updatingId}
expandedItemId={expandedItemId}
events={events}
creatingEvent={creatingEvent}
onUpdate={onUpdate}
onCreateEvent={onCreateEvent}
onArchive={onArchive}
onToggleEvents={onToggleEvents}
/>
</CardContent>
</Card>
@@ -1935,15 +2139,31 @@ function WatchlistTable({
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)]" />;
}
@@ -1958,43 +2178,160 @@ function WatchlistTable({
<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-14" />
<TableHead className="w-44" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.watchlist_item_id}>
<TableCell className="font-medium">
{item.neighborhood_id
? neighborhoodNameById.get(item.neighborhood_id) ?? item.neighborhood_id
: item.area_id
? areaNameById.get(item.area_id) ?? item.area_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>
))}
{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>
);