feat: enhance watchlist workflow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -103,16 +103,58 @@ export type WatchlistItem = {
|
||||
area_id: string | null;
|
||||
target_price_psm: number | null;
|
||||
status: string;
|
||||
label: string;
|
||||
priority: string;
|
||||
trigger_operator: string;
|
||||
trigger_price_psm: number | null;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_reviewed_at: string | null;
|
||||
};
|
||||
|
||||
export type CreateWatchlistItem = {
|
||||
area_id?: string;
|
||||
neighborhood_id?: string;
|
||||
target_price_psm?: number;
|
||||
label?: string;
|
||||
priority?: string;
|
||||
trigger_operator?: string;
|
||||
trigger_price_psm?: number;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type UpdateWatchlistItem = {
|
||||
target_price_psm?: number;
|
||||
status?: "active" | "paused" | "archived";
|
||||
label?: string;
|
||||
priority?: "low" | "medium" | "high";
|
||||
trigger_operator?: "lte" | "gte";
|
||||
trigger_price_psm?: number;
|
||||
notes?: string;
|
||||
mark_reviewed?: boolean;
|
||||
};
|
||||
|
||||
export type WatchlistEvent = {
|
||||
event_id: number;
|
||||
watchlist_item_id: number;
|
||||
event_type: string;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CreateWatchlistEvent = {
|
||||
event_type: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type WatchlistFilters = {
|
||||
status?: string;
|
||||
area_id?: string;
|
||||
neighborhood_id?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type DataSource = {
|
||||
source_id: number;
|
||||
name: string;
|
||||
@@ -372,8 +414,17 @@ export async function fetchNeighborhoodComparison(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
|
||||
return fetchJson("/api/v1/watchlist");
|
||||
export async function fetchWatchlistItems(
|
||||
filters: WatchlistFilters = {},
|
||||
): Promise<WatchlistItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
const query = params.toString();
|
||||
return fetchJson(`/api/v1/watchlist${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function createWatchlistItem(
|
||||
@@ -391,6 +442,30 @@ export async function archiveWatchlistItem(id: number): Promise<WatchlistItem> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWatchlistItem(
|
||||
id: number,
|
||||
payload: UpdateWatchlistItem,
|
||||
): Promise<WatchlistItem> {
|
||||
return fetchJson(`/api/v1/watchlist/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchWatchlistEvents(id: number): Promise<WatchlistEvent[]> {
|
||||
return fetchJson(`/api/v1/watchlist/${id}/events`);
|
||||
}
|
||||
|
||||
export async function createWatchlistEvent(
|
||||
id: number,
|
||||
payload: CreateWatchlistEvent,
|
||||
): Promise<WatchlistEvent> {
|
||||
return fetchJson(`/api/v1/watchlist/${id}/events`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDataSources(): Promise<DataSource[]> {
|
||||
return fetchJson("/api/v1/data-sources");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user