From f1ad5a7d15ff754156949d92f90bd5e41d83bea4 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Wed, 24 Jun 2026 14:43:58 +0800 Subject: [PATCH] feat: enhance watchlist workflow --- .../202606240004_watchlist_enhancements.sql | 21 + apps/api/src/handlers.rs | 231 +++++++++- apps/api/src/models.rs | 117 +++++ apps/api/src/routes.rs | 15 +- .../components/dashboard/market-dashboard.tsx | 423 ++++++++++++++++-- apps/web/src/lib/api.ts | 79 +++- docs/product_roadmap.md | 11 +- 7 files changed, 829 insertions(+), 68 deletions(-) create mode 100644 apps/api/migrations/202606240004_watchlist_enhancements.sql diff --git a/apps/api/migrations/202606240004_watchlist_enhancements.sql b/apps/api/migrations/202606240004_watchlist_enhancements.sql new file mode 100644 index 0000000..ba75227 --- /dev/null +++ b/apps/api/migrations/202606240004_watchlist_enhancements.sql @@ -0,0 +1,21 @@ +ALTER TABLE app.watchlist_items + ADD COLUMN IF NOT EXISTS label TEXT NOT NULL DEFAULT 'general', + ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'medium', + ADD COLUMN IF NOT EXISTS trigger_operator TEXT NOT NULL DEFAULT 'lte', + ADD COLUMN IF NOT EXISTS trigger_price_psm DOUBLE PRECISION CHECK (trigger_price_psm >= 0), + ADD COLUMN IF NOT EXISTS last_reviewed_at TIMESTAMPTZ; + +CREATE TABLE IF NOT EXISTS app.watchlist_events ( + event_id BIGSERIAL PRIMARY KEY, + watchlist_item_id BIGINT NOT NULL REFERENCES app.watchlist_items(watchlist_item_id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + summary TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_watchlist_items_label + ON app.watchlist_items(label); +CREATE INDEX IF NOT EXISTS idx_watchlist_items_priority + ON app.watchlist_items(priority); +CREATE INDEX IF NOT EXISTS idx_watchlist_events_item_created + ON app.watchlist_events(watchlist_item_id, created_at DESC); diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index eb7efa2..8506aa1 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -8,12 +8,13 @@ use crate::models::{ AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreSummary, CompareQuery, ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, - CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun, - ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest, + CreateIngestionRun, CreateRawArtifact, CreateWatchlistEvent, CreateWatchlistItem, DataSource, + FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun, MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore, - NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem, + NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistEvent, + WatchlistItem, WatchlistQuery, }; use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore}; use crate::state::AppState; @@ -660,7 +661,14 @@ pub async fn get_neighborhood_detail( area_id, target_price_psm, status, - notes + label, + priority, + trigger_operator, + trigger_price_psm, + notes, + created_at::text AS created_at, + updated_at::text AS updated_at, + last_reviewed_at::text AS last_reviewed_at FROM app.watchlist_items WHERE neighborhood_id = $1 AND status <> 'archived' @@ -1095,6 +1103,7 @@ pub async fn create_raw_artifact( pub async fn list_watchlist_items( State(state): State, + Query(query): Query, ) -> ApiResult>> { let items = sqlx::query_as::<_, WatchlistItem>( r#" @@ -1104,12 +1113,27 @@ pub async fn list_watchlist_items( area_id, target_price_psm, status, - notes + label, + priority, + trigger_operator, + trigger_price_psm, + notes, + created_at::text AS created_at, + updated_at::text AS updated_at, + last_reviewed_at::text AS last_reviewed_at FROM app.watchlist_items - WHERE status <> 'archived' + WHERE ($1::text IS NULL OR status = $1) + AND ($1::text IS NOT NULL OR status <> 'archived') + AND ($2::text IS NULL OR area_id = $2) + AND ($3::text IS NULL OR neighborhood_id = $3) + AND ($4::text IS NULL OR label = $4) ORDER BY updated_at DESC, watchlist_item_id DESC "#, ) + .bind(query.status) + .bind(query.area_id) + .bind(query.neighborhood_id) + .bind(query.label) .fetch_all(&state.pool) .await?; @@ -1124,30 +1148,57 @@ pub async fn create_watchlist_item( .validate() .map_err(|message| ApiError::BadRequest(message.to_string()))?; + let mut tx = state.pool.begin().await?; let item = sqlx::query_as::<_, WatchlistItem>( r#" INSERT INTO app.watchlist_items ( neighborhood_id, area_id, target_price_psm, + label, + priority, + trigger_operator, + trigger_price_psm, notes ) - VALUES ($1, $2, $3, COALESCE($4, '')) + VALUES ( + $1, + $2, + $3, + COALESCE($4, 'general'), + COALESCE($5, 'medium'), + COALESCE($6, 'lte'), + $7, + COALESCE($8, '') + ) RETURNING watchlist_item_id, neighborhood_id, area_id, target_price_psm, status, - notes + label, + priority, + trigger_operator, + trigger_price_psm, + notes, + created_at::text AS created_at, + updated_at::text AS updated_at, + last_reviewed_at::text AS last_reviewed_at "#, ) .bind(payload.neighborhood_id) .bind(payload.area_id) .bind(payload.target_price_psm) + .bind(payload.label) + .bind(payload.priority) + .bind(payload.trigger_operator) + .bind(payload.trigger_price_psm) .bind(payload.notes) - .fetch_one(&state.pool) + .fetch_one(&mut *tx) .await?; + insert_watchlist_event(&mut tx, item.watchlist_item_id, "created", "创建观察标的").await?; + tx.commit().await?; Ok(Json(item)) } @@ -1161,12 +1212,18 @@ pub async fn update_watchlist_item( .validate() .map_err(|message| ApiError::BadRequest(message.to_string()))?; + let mut tx = state.pool.begin().await?; let item = sqlx::query_as::<_, WatchlistItem>( r#" UPDATE app.watchlist_items SET target_price_psm = COALESCE($2, target_price_psm), status = COALESCE($3, status), - notes = COALESCE($4, notes), + label = COALESCE($4, label), + priority = COALESCE($5, priority), + trigger_operator = COALESCE($6, trigger_operator), + trigger_price_psm = COALESCE($7, trigger_price_psm), + notes = COALESCE($8, notes), + last_reviewed_at = CASE WHEN $9 THEN now() ELSE last_reviewed_at END, updated_at = now() WHERE watchlist_item_id = $1 RETURNING @@ -1175,16 +1232,44 @@ pub async fn update_watchlist_item( area_id, target_price_psm, status, - notes + label, + priority, + trigger_operator, + trigger_price_psm, + notes, + created_at::text AS created_at, + updated_at::text AS updated_at, + last_reviewed_at::text AS last_reviewed_at "#, ) .bind(watchlist_item_id) .bind(payload.target_price_psm) - .bind(payload.status) + .bind(payload.status.clone()) + .bind(payload.label) + .bind(payload.priority) + .bind(payload.trigger_operator) + .bind(payload.trigger_price_psm) .bind(payload.notes) - .fetch_optional(&state.pool) + .bind(payload.mark_reviewed.unwrap_or(false)) + .fetch_optional(&mut *tx) .await? .ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?; + insert_watchlist_event( + &mut tx, + item.watchlist_item_id, + if payload.mark_reviewed.unwrap_or(false) { + "reviewed" + } else { + "updated" + }, + if payload.mark_reviewed.unwrap_or(false) { + "完成一次人工复核" + } else { + "更新观察标的" + }, + ) + .await?; + tx.commit().await?; Ok(Json(item)) } @@ -1193,6 +1278,7 @@ pub async fn archive_watchlist_item( State(state): State, Path(watchlist_item_id): Path, ) -> ApiResult> { + let mut tx = state.pool.begin().await?; let item = sqlx::query_as::<_, WatchlistItem>( r#" UPDATE app.watchlist_items @@ -1205,17 +1291,109 @@ pub async fn archive_watchlist_item( area_id, target_price_psm, status, - notes + label, + priority, + trigger_operator, + trigger_price_psm, + notes, + created_at::text AS created_at, + updated_at::text AS updated_at, + last_reviewed_at::text AS last_reviewed_at "#, ) .bind(watchlist_item_id) - .fetch_optional(&state.pool) + .fetch_optional(&mut *tx) .await? .ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?; + insert_watchlist_event(&mut tx, item.watchlist_item_id, "archived", "归档观察标的").await?; + tx.commit().await?; Ok(Json(item)) } +pub async fn list_watchlist_events( + State(state): State, + Path(watchlist_item_id): Path, +) -> ApiResult>> { + let events = sqlx::query_as::<_, WatchlistEvent>( + r#" + SELECT + event_id, + watchlist_item_id, + event_type, + summary, + created_at::text AS created_at + FROM app.watchlist_events + WHERE watchlist_item_id = $1 + ORDER BY created_at DESC, event_id DESC + LIMIT 50 + "#, + ) + .bind(watchlist_item_id) + .fetch_all(&state.pool) + .await?; + + Ok(Json(events)) +} + +pub async fn create_watchlist_event( + State(state): State, + Path(watchlist_item_id): Path, + Json(payload): Json, +) -> ApiResult> { + payload + .validate() + .map_err(|message| ApiError::BadRequest(message.to_string()))?; + + let event = sqlx::query_as::<_, WatchlistEvent>( + r#" + INSERT INTO app.watchlist_events ( + watchlist_item_id, + event_type, + summary + ) + VALUES ($1, $2, $3) + RETURNING + event_id, + watchlist_item_id, + event_type, + summary, + created_at::text AS created_at + "#, + ) + .bind(watchlist_item_id) + .bind(payload.event_type.trim().to_string()) + .bind(payload.summary.trim().to_string()) + .fetch_one(&state.pool) + .await?; + + Ok(Json(event)) +} + +async fn insert_watchlist_event( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + watchlist_item_id: i64, + event_type: &str, + summary: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO app.watchlist_events ( + watchlist_item_id, + event_type, + summary + ) + VALUES ($1, $2, $3) + "#, + ) + .bind(watchlist_item_id) + .bind(event_type) + .bind(summary) + .execute(&mut **tx) + .await?; + Ok(()) +} + async fn fetch_area_scores(state: &AppState, month: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, AreaScore>( r#" @@ -1788,6 +1966,10 @@ mod tests { neighborhood_id: None, area_id: None, target_price_psm: None, + label: None, + priority: None, + trigger_operator: None, + trigger_price_psm: None, notes: None, } .validate() @@ -1797,6 +1979,10 @@ mod tests { neighborhood_id: None, area_id: Some("zhangjiang".to_string()), target_price_psm: Some(80_000.0), + label: Some("核心".to_string()), + priority: Some("high".to_string()), + trigger_operator: Some("lte".to_string()), + trigger_price_psm: Some(76_000.0), notes: None, } .validate() @@ -1808,7 +1994,12 @@ mod tests { assert!(UpdateWatchlistItem { target_price_psm: Some(-1.0), status: None, + label: None, + priority: None, + trigger_operator: None, + trigger_price_psm: None, notes: None, + mark_reviewed: None, } .validate() .is_err()); @@ -1816,7 +2007,12 @@ mod tests { assert!(UpdateWatchlistItem { target_price_psm: None, status: Some("unknown".to_string()), + label: None, + priority: None, + trigger_operator: None, + trigger_price_psm: None, notes: None, + mark_reviewed: None, } .validate() .is_err()); @@ -1824,7 +2020,12 @@ mod tests { assert!(UpdateWatchlistItem { target_price_psm: Some(80_000.0), status: Some("active".to_string()), + label: Some("核心".to_string()), + priority: Some("high".to_string()), + trigger_operator: Some("lte".to_string()), + trigger_price_psm: Some(76_000.0), notes: Some("跟踪产业兑现和挂牌变化".to_string()), + mark_reviewed: Some(true), } .validate() .is_ok()); diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index abd4392..117c097 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -43,6 +43,14 @@ pub struct RawArtifactQuery { pub sha256: Option, } +#[derive(Debug, Deserialize)] +pub struct WatchlistQuery { + pub status: Option, + pub area_id: Option, + pub neighborhood_id: Option, + pub label: Option, +} + #[derive(Debug, Deserialize)] pub struct ImportValidationRequest { pub template_name: String, @@ -551,7 +559,14 @@ pub struct WatchlistItem { pub area_id: Option, pub target_price_psm: Option, pub status: String, + pub label: String, + pub priority: String, + pub trigger_operator: String, + pub trigger_price_psm: Option, pub notes: String, + pub created_at: String, + pub updated_at: String, + pub last_reviewed_at: Option, } #[derive(Debug, Deserialize)] @@ -559,6 +574,10 @@ pub struct CreateWatchlistItem { pub neighborhood_id: Option, pub area_id: Option, pub target_price_psm: Option, + pub label: Option, + pub priority: Option, + pub trigger_operator: Option, + pub trigger_price_psm: Option, pub notes: Option, } @@ -570,6 +589,32 @@ impl CreateWatchlistItem { if matches!(self.target_price_psm, Some(value) if value < 0.0) { return Err("target_price_psm must be non-negative"); } + if matches!(self.trigger_price_psm, Some(value) if value < 0.0) { + return Err("trigger_price_psm must be non-negative"); + } + if let Some(priority) = &self.priority { + if !is_watchlist_priority(priority) { + return Err("priority must be low, medium, or high"); + } + } + if let Some(operator) = &self.trigger_operator { + if !is_watchlist_trigger_operator(operator) { + return Err("trigger_operator must be lte or gte"); + } + } + for value in [ + self.label.as_deref(), + self.priority.as_deref(), + self.trigger_operator.as_deref(), + self.notes.as_deref(), + ] + .into_iter() + .flatten() + { + if has_sensitive_text(value) { + return Err("payload contains sensitive text"); + } + } Ok(()) } } @@ -600,7 +645,12 @@ fn is_valid_sha256(value: &str) -> bool { pub struct UpdateWatchlistItem { pub target_price_psm: Option, pub status: Option, + pub label: Option, + pub priority: Option, + pub trigger_operator: Option, + pub trigger_price_psm: Option, pub notes: Option, + pub mark_reviewed: Option, } impl UpdateWatchlistItem { @@ -608,11 +658,78 @@ impl UpdateWatchlistItem { if matches!(self.target_price_psm, Some(value) if value < 0.0) { return Err("target_price_psm must be non-negative"); } + if matches!(self.trigger_price_psm, Some(value) if value < 0.0) { + return Err("trigger_price_psm must be non-negative"); + } if let Some(status) = &self.status { if !matches!(status.as_str(), "active" | "paused" | "archived") { return Err("status must be active, paused, or archived"); } } + if let Some(priority) = &self.priority { + if !is_watchlist_priority(priority) { + return Err("priority must be low, medium, or high"); + } + } + if let Some(operator) = &self.trigger_operator { + if !is_watchlist_trigger_operator(operator) { + return Err("trigger_operator must be lte or gte"); + } + } + for value in [ + self.status.as_deref(), + self.label.as_deref(), + self.priority.as_deref(), + self.trigger_operator.as_deref(), + self.notes.as_deref(), + ] + .into_iter() + .flatten() + { + if has_sensitive_text(value) { + return Err("payload contains sensitive text"); + } + } Ok(()) } } + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct WatchlistEvent { + pub event_id: i64, + pub watchlist_item_id: i64, + pub event_type: String, + pub summary: String, + pub created_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateWatchlistEvent { + pub event_type: String, + pub summary: String, +} + +impl CreateWatchlistEvent { + pub fn validate(&self) -> Result<(), &'static str> { + if self.event_type.trim().is_empty() { + return Err("event_type is required"); + } + if self.summary.trim().is_empty() { + return Err("summary is required"); + } + for value in [self.event_type.as_str(), self.summary.as_str()] { + if has_sensitive_text(value) { + return Err("payload contains sensitive text"); + } + } + Ok(()) + } +} + +fn is_watchlist_priority(priority: &str) -> bool { + matches!(priority, "low" | "medium" | "high") +} + +fn is_watchlist_trigger_operator(operator: &str) -> bool { + matches!(operator, "lte" | "gte") +} diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index 308bdc8..d8b4f48 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -5,11 +5,12 @@ use tower_http::trace::TraceLayer; use crate::handlers::{ archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run, - create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_item, - execute_import, finish_ingestion_run, get_area_detail, get_area_score_lineage, - get_neighborhood, get_neighborhood_detail, health, list_area_scores, list_data_sources, - list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts, - list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import, + create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_event, + create_watchlist_item, execute_import, finish_ingestion_run, get_area_detail, + get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health, list_area_scores, + list_data_sources, list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, + list_raw_artifacts, list_watchlist_events, list_watchlist_items, market_overview, ready, + update_watchlist_item, validate_import, }; use crate::state::AppState; @@ -65,6 +66,10 @@ pub fn build_router(state: AppState) -> Router { .route( "/watchlist/{watchlist_item_id}", patch(update_watchlist_item).delete(archive_watchlist_item), + ) + .route( + "/watchlist/{watchlist_item_id}/events", + get(list_watchlist_events).post(create_watchlist_event), ), ) .layer(TraceLayer::new_for_http()) diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index 79ab6e2..f0aa787 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -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([]); const [comparisonNeighborhoodIds, setComparisonNeighborhoodIds] = useState([]); + const [watchlistStatus, setWatchlistStatus] = useState("active"); + const [watchlistAreaId, setWatchlistAreaId] = useState(""); + const [watchlistLabel, setWatchlistLabel] = useState(""); + const [expandedWatchlistItemId, setExpandedWatchlistItemId] = useState(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) { 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 ( - - 观察池 -
+ +
+ 观察池 +
+ + + onLabelFilterChange(event.target.value)} + /> +
+
+ + + setTargetPrice(event.target.value)} /> setTriggerPrice(event.target.value)} + /> + + + setLabel(event.target.value)} + /> + setNotes(event.target.value)} /> - @@ -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} />
@@ -1935,15 +2139,31 @@ function WatchlistTable({ neighborhoodNameById, loading, archivingId, + updatingId, + expandedItemId, + events, + creatingEvent, + onUpdate, + onCreateEvent, onArchive, + onToggleEvents, }: { items: WatchlistItem[]; areaNameById: Map; neighborhoodNameById: Map; 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
; } @@ -1958,43 +2178,160 @@ function WatchlistTable({ 标的 目标价/㎡ + 触发价/㎡ + 标签 + 优先级 状态 + 复核 备注 - + - {items.map((item) => ( - - - {item.neighborhood_id - ? neighborhoodNameById.get(item.neighborhood_id) ?? item.neighborhood_id - : item.area_id - ? areaNameById.get(item.area_id) ?? item.area_id - : "-"} - - - {item.target_price_psm ? formatPrice(item.target_price_psm) : "-"} - - - {item.status} - - - {item.notes || "-"} - - - - - - ))} + {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 ( + + + {targetName} + + {item.target_price_psm ? formatPrice(item.target_price_psm) : "-"} + + + {item.trigger_price_psm + ? `${item.trigger_operator === "lte" ? "≤" : "≥"} ${formatPrice(item.trigger_price_psm)}` + : "-"} + + + {item.label} + + + + {item.priority} + + + + + + + {item.last_reviewed_at ? item.last_reviewed_at.slice(0, 10) : "-"} + + + {item.notes || "-"} + + +
+ + + +
+
+
+ {expanded ? ( + + +
+
+ {events.length === 0 ? ( +

暂无历史

+ ) : ( + events.map((event) => ( +
+
+ {event.event_type} + + {event.created_at.slice(0, 16)} + +
+

{event.summary}

+
+ )) + )} +
+
{ + event.preventDefault(); + if (!eventSummary.trim()) { + return; + } + onCreateEvent(item.watchlist_item_id, { + event_type: "note", + summary: eventSummary.trim(), + }); + setEventSummary(""); + }} + > + setEventSummary(event.target.value)} + /> + +
+
+
+
+ ) : null} +
+ ); + })}
); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index a168af9..713a0a8 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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 { - return fetchJson("/api/v1/watchlist"); +export async function fetchWatchlistItems( + filters: WatchlistFilters = {}, +): Promise { + 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 { }); } +export async function updateWatchlistItem( + id: number, + payload: UpdateWatchlistItem, +): Promise { + return fetchJson(`/api/v1/watchlist/${id}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function fetchWatchlistEvents(id: number): Promise { + return fetchJson(`/api/v1/watchlist/${id}/events`); +} + +export async function createWatchlistEvent( + id: number, + payload: CreateWatchlistEvent, +): Promise { + return fetchJson(`/api/v1/watchlist/${id}/events`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + export async function fetchDataSources(): Promise { return fetchJson("/api/v1/data-sources"); } diff --git a/docs/product_roadmap.md b/docs/product_roadmap.md index f5143b9..67c69b4 100644 --- a/docs/product_roadmap.md +++ b/docs/product_roadmap.md @@ -234,20 +234,25 @@ ### M2.4 观察池增强 +状态:已完成。 + 目标: - 观察池从简单列表升级为投资跟踪工具。 交付物: -- 目标价、触发条件、状态、标签、备注。 -- 更新历史。 -- 观察池筛选。 +- 目标价、触发条件、状态、标签、优先级、备注。 +- `PATCH /api/v1/watchlist/{watchlist_item_id}` 支持状态、触发价、标签、复核时间更新。 +- `GET/POST /api/v1/watchlist/{watchlist_item_id}/events` 更新历史。 +- 观察池筛选:状态、板块、标签。 +- 前端观察池增强表单、筛选器、状态切换、复核、历史展开。 验收标准: - 可以区分 active、paused、archived。 - 可以按板块、小区、状态筛选。 +- 任一观察标的可记录人工事件和复核时间。 ## M3 模型与回测