feat: enhance watchlist workflow
This commit is contained in:
21
apps/api/migrations/202606240004_watchlist_enhancements.sql
Normal file
21
apps/api/migrations/202606240004_watchlist_enhancements.sql
Normal file
@@ -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);
|
||||||
@@ -8,12 +8,13 @@ use crate::models::{
|
|||||||
AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage,
|
AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage,
|
||||||
AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreSummary, CompareQuery,
|
AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreSummary, CompareQuery,
|
||||||
ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource,
|
ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource,
|
||||||
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
|
CreateIngestionRun, CreateRawArtifact, CreateWatchlistEvent, CreateWatchlistItem, DataSource,
|
||||||
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
||||||
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
||||||
MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem,
|
MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem,
|
||||||
NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore,
|
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::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -660,7 +661,14 @@ pub async fn get_neighborhood_detail(
|
|||||||
area_id,
|
area_id,
|
||||||
target_price_psm,
|
target_price_psm,
|
||||||
status,
|
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
|
FROM app.watchlist_items
|
||||||
WHERE neighborhood_id = $1
|
WHERE neighborhood_id = $1
|
||||||
AND status <> 'archived'
|
AND status <> 'archived'
|
||||||
@@ -1095,6 +1103,7 @@ pub async fn create_raw_artifact(
|
|||||||
|
|
||||||
pub async fn list_watchlist_items(
|
pub async fn list_watchlist_items(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<WatchlistQuery>,
|
||||||
) -> ApiResult<Json<Vec<WatchlistItem>>> {
|
) -> ApiResult<Json<Vec<WatchlistItem>>> {
|
||||||
let items = sqlx::query_as::<_, WatchlistItem>(
|
let items = sqlx::query_as::<_, WatchlistItem>(
|
||||||
r#"
|
r#"
|
||||||
@@ -1104,12 +1113,27 @@ pub async fn list_watchlist_items(
|
|||||||
area_id,
|
area_id,
|
||||||
target_price_psm,
|
target_price_psm,
|
||||||
status,
|
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
|
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
|
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)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -1124,30 +1148,57 @@ pub async fn create_watchlist_item(
|
|||||||
.validate()
|
.validate()
|
||||||
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
|
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
|
||||||
|
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
let item = sqlx::query_as::<_, WatchlistItem>(
|
let item = sqlx::query_as::<_, WatchlistItem>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO app.watchlist_items (
|
INSERT INTO app.watchlist_items (
|
||||||
neighborhood_id,
|
neighborhood_id,
|
||||||
area_id,
|
area_id,
|
||||||
target_price_psm,
|
target_price_psm,
|
||||||
|
label,
|
||||||
|
priority,
|
||||||
|
trigger_operator,
|
||||||
|
trigger_price_psm,
|
||||||
notes
|
notes
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, COALESCE($4, ''))
|
VALUES (
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
COALESCE($4, 'general'),
|
||||||
|
COALESCE($5, 'medium'),
|
||||||
|
COALESCE($6, 'lte'),
|
||||||
|
$7,
|
||||||
|
COALESCE($8, '')
|
||||||
|
)
|
||||||
RETURNING
|
RETURNING
|
||||||
watchlist_item_id,
|
watchlist_item_id,
|
||||||
neighborhood_id,
|
neighborhood_id,
|
||||||
area_id,
|
area_id,
|
||||||
target_price_psm,
|
target_price_psm,
|
||||||
status,
|
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.neighborhood_id)
|
||||||
.bind(payload.area_id)
|
.bind(payload.area_id)
|
||||||
.bind(payload.target_price_psm)
|
.bind(payload.target_price_psm)
|
||||||
|
.bind(payload.label)
|
||||||
|
.bind(payload.priority)
|
||||||
|
.bind(payload.trigger_operator)
|
||||||
|
.bind(payload.trigger_price_psm)
|
||||||
.bind(payload.notes)
|
.bind(payload.notes)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
insert_watchlist_event(&mut tx, item.watchlist_item_id, "created", "创建观察标的").await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
Ok(Json(item))
|
Ok(Json(item))
|
||||||
}
|
}
|
||||||
@@ -1161,12 +1212,18 @@ pub async fn update_watchlist_item(
|
|||||||
.validate()
|
.validate()
|
||||||
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
|
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
|
||||||
|
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
let item = sqlx::query_as::<_, WatchlistItem>(
|
let item = sqlx::query_as::<_, WatchlistItem>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE app.watchlist_items
|
UPDATE app.watchlist_items
|
||||||
SET target_price_psm = COALESCE($2, target_price_psm),
|
SET target_price_psm = COALESCE($2, target_price_psm),
|
||||||
status = COALESCE($3, status),
|
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()
|
updated_at = now()
|
||||||
WHERE watchlist_item_id = $1
|
WHERE watchlist_item_id = $1
|
||||||
RETURNING
|
RETURNING
|
||||||
@@ -1175,16 +1232,44 @@ pub async fn update_watchlist_item(
|
|||||||
area_id,
|
area_id,
|
||||||
target_price_psm,
|
target_price_psm,
|
||||||
status,
|
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(watchlist_item_id)
|
||||||
.bind(payload.target_price_psm)
|
.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)
|
.bind(payload.notes)
|
||||||
.fetch_optional(&state.pool)
|
.bind(payload.mark_reviewed.unwrap_or(false))
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?;
|
.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))
|
Ok(Json(item))
|
||||||
}
|
}
|
||||||
@@ -1193,6 +1278,7 @@ pub async fn archive_watchlist_item(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(watchlist_item_id): Path<i64>,
|
Path(watchlist_item_id): Path<i64>,
|
||||||
) -> ApiResult<Json<WatchlistItem>> {
|
) -> ApiResult<Json<WatchlistItem>> {
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
let item = sqlx::query_as::<_, WatchlistItem>(
|
let item = sqlx::query_as::<_, WatchlistItem>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE app.watchlist_items
|
UPDATE app.watchlist_items
|
||||||
@@ -1205,17 +1291,109 @@ pub async fn archive_watchlist_item(
|
|||||||
area_id,
|
area_id,
|
||||||
target_price_psm,
|
target_price_psm,
|
||||||
status,
|
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(watchlist_item_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?;
|
.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))
|
Ok(Json(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_watchlist_events(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(watchlist_item_id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Vec<WatchlistEvent>>> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(watchlist_item_id): Path<i64>,
|
||||||
|
Json(payload): Json<CreateWatchlistEvent>,
|
||||||
|
) -> ApiResult<Json<WatchlistEvent>> {
|
||||||
|
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<Vec<AreaScore>, sqlx::Error> {
|
async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScore>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, AreaScore>(
|
sqlx::query_as::<_, AreaScore>(
|
||||||
r#"
|
r#"
|
||||||
@@ -1788,6 +1966,10 @@ mod tests {
|
|||||||
neighborhood_id: None,
|
neighborhood_id: None,
|
||||||
area_id: None,
|
area_id: None,
|
||||||
target_price_psm: None,
|
target_price_psm: None,
|
||||||
|
label: None,
|
||||||
|
priority: None,
|
||||||
|
trigger_operator: None,
|
||||||
|
trigger_price_psm: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
}
|
}
|
||||||
.validate()
|
.validate()
|
||||||
@@ -1797,6 +1979,10 @@ mod tests {
|
|||||||
neighborhood_id: None,
|
neighborhood_id: None,
|
||||||
area_id: Some("zhangjiang".to_string()),
|
area_id: Some("zhangjiang".to_string()),
|
||||||
target_price_psm: Some(80_000.0),
|
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,
|
notes: None,
|
||||||
}
|
}
|
||||||
.validate()
|
.validate()
|
||||||
@@ -1808,7 +1994,12 @@ mod tests {
|
|||||||
assert!(UpdateWatchlistItem {
|
assert!(UpdateWatchlistItem {
|
||||||
target_price_psm: Some(-1.0),
|
target_price_psm: Some(-1.0),
|
||||||
status: None,
|
status: None,
|
||||||
|
label: None,
|
||||||
|
priority: None,
|
||||||
|
trigger_operator: None,
|
||||||
|
trigger_price_psm: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
|
mark_reviewed: None,
|
||||||
}
|
}
|
||||||
.validate()
|
.validate()
|
||||||
.is_err());
|
.is_err());
|
||||||
@@ -1816,7 +2007,12 @@ mod tests {
|
|||||||
assert!(UpdateWatchlistItem {
|
assert!(UpdateWatchlistItem {
|
||||||
target_price_psm: None,
|
target_price_psm: None,
|
||||||
status: Some("unknown".to_string()),
|
status: Some("unknown".to_string()),
|
||||||
|
label: None,
|
||||||
|
priority: None,
|
||||||
|
trigger_operator: None,
|
||||||
|
trigger_price_psm: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
|
mark_reviewed: None,
|
||||||
}
|
}
|
||||||
.validate()
|
.validate()
|
||||||
.is_err());
|
.is_err());
|
||||||
@@ -1824,7 +2020,12 @@ mod tests {
|
|||||||
assert!(UpdateWatchlistItem {
|
assert!(UpdateWatchlistItem {
|
||||||
target_price_psm: Some(80_000.0),
|
target_price_psm: Some(80_000.0),
|
||||||
status: Some("active".to_string()),
|
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()),
|
notes: Some("跟踪产业兑现和挂牌变化".to_string()),
|
||||||
|
mark_reviewed: Some(true),
|
||||||
}
|
}
|
||||||
.validate()
|
.validate()
|
||||||
.is_ok());
|
.is_ok());
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ pub struct RawArtifactQuery {
|
|||||||
pub sha256: Option<String>,
|
pub sha256: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct WatchlistQuery {
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub area_id: Option<String>,
|
||||||
|
pub neighborhood_id: Option<String>,
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ImportValidationRequest {
|
pub struct ImportValidationRequest {
|
||||||
pub template_name: String,
|
pub template_name: String,
|
||||||
@@ -551,7 +559,14 @@ pub struct WatchlistItem {
|
|||||||
pub area_id: Option<String>,
|
pub area_id: Option<String>,
|
||||||
pub target_price_psm: Option<f64>,
|
pub target_price_psm: Option<f64>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
pub label: String,
|
||||||
|
pub priority: String,
|
||||||
|
pub trigger_operator: String,
|
||||||
|
pub trigger_price_psm: Option<f64>,
|
||||||
pub notes: String,
|
pub notes: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub last_reviewed_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -559,6 +574,10 @@ pub struct CreateWatchlistItem {
|
|||||||
pub neighborhood_id: Option<String>,
|
pub neighborhood_id: Option<String>,
|
||||||
pub area_id: Option<String>,
|
pub area_id: Option<String>,
|
||||||
pub target_price_psm: Option<f64>,
|
pub target_price_psm: Option<f64>,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub priority: Option<String>,
|
||||||
|
pub trigger_operator: Option<String>,
|
||||||
|
pub trigger_price_psm: Option<f64>,
|
||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,6 +589,32 @@ impl CreateWatchlistItem {
|
|||||||
if matches!(self.target_price_psm, Some(value) if value < 0.0) {
|
if matches!(self.target_price_psm, Some(value) if value < 0.0) {
|
||||||
return Err("target_price_psm must be non-negative");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,7 +645,12 @@ fn is_valid_sha256(value: &str) -> bool {
|
|||||||
pub struct UpdateWatchlistItem {
|
pub struct UpdateWatchlistItem {
|
||||||
pub target_price_psm: Option<f64>,
|
pub target_price_psm: Option<f64>,
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub priority: Option<String>,
|
||||||
|
pub trigger_operator: Option<String>,
|
||||||
|
pub trigger_price_psm: Option<f64>,
|
||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
|
pub mark_reviewed: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpdateWatchlistItem {
|
impl UpdateWatchlistItem {
|
||||||
@@ -608,11 +658,78 @@ impl UpdateWatchlistItem {
|
|||||||
if matches!(self.target_price_psm, Some(value) if value < 0.0) {
|
if matches!(self.target_price_psm, Some(value) if value < 0.0) {
|
||||||
return Err("target_price_psm must be non-negative");
|
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 let Some(status) = &self.status {
|
||||||
if !matches!(status.as_str(), "active" | "paused" | "archived") {
|
if !matches!(status.as_str(), "active" | "paused" | "archived") {
|
||||||
return Err("status must be active, paused, or 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(())
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ use tower_http::trace::TraceLayer;
|
|||||||
|
|
||||||
use crate::handlers::{
|
use crate::handlers::{
|
||||||
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
|
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
|
||||||
create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_item,
|
create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_event,
|
||||||
execute_import, finish_ingestion_run, get_area_detail, get_area_score_lineage,
|
create_watchlist_item, execute_import, finish_ingestion_run, get_area_detail,
|
||||||
get_neighborhood, get_neighborhood_detail, health, list_area_scores, list_data_sources,
|
get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health, list_area_scores,
|
||||||
list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts,
|
list_data_sources, list_ingestion_runs, list_neighborhood_scores, list_neighborhoods,
|
||||||
list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import,
|
list_raw_artifacts, list_watchlist_events, list_watchlist_items, market_overview, ready,
|
||||||
|
update_watchlist_item, validate_import,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -65,6 +66,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route(
|
.route(
|
||||||
"/watchlist/{watchlist_item_id}",
|
"/watchlist/{watchlist_item_id}",
|
||||||
patch(update_watchlist_item).delete(archive_watchlist_item),
|
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())
|
.layer(TraceLayer::new_for_http())
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
createDataSource,
|
createDataSource,
|
||||||
createIngestionRun,
|
createIngestionRun,
|
||||||
createRawArtifact,
|
createRawArtifact,
|
||||||
|
createWatchlistEvent,
|
||||||
createWatchlistItem,
|
createWatchlistItem,
|
||||||
fetchDataSources,
|
fetchDataSources,
|
||||||
fetchAreaScores,
|
fetchAreaScores,
|
||||||
@@ -46,8 +47,10 @@ import {
|
|||||||
fetchNeighborhoodDetail,
|
fetchNeighborhoodDetail,
|
||||||
fetchNeighborhoodScores,
|
fetchNeighborhoodScores,
|
||||||
fetchRawArtifacts,
|
fetchRawArtifacts,
|
||||||
|
fetchWatchlistEvents,
|
||||||
fetchWatchlistItems,
|
fetchWatchlistItems,
|
||||||
finishIngestionRun,
|
finishIngestionRun,
|
||||||
|
updateWatchlistItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
AreaScore,
|
AreaScore,
|
||||||
@@ -57,6 +60,7 @@ import type {
|
|||||||
CreateDataSource,
|
CreateDataSource,
|
||||||
CreateIngestionRun,
|
CreateIngestionRun,
|
||||||
CreateRawArtifact,
|
CreateRawArtifact,
|
||||||
|
CreateWatchlistEvent,
|
||||||
CreateWatchlistItem,
|
CreateWatchlistItem,
|
||||||
DataSource,
|
DataSource,
|
||||||
IngestionRun,
|
IngestionRun,
|
||||||
@@ -65,6 +69,8 @@ import type {
|
|||||||
NeighborhoodDetail,
|
NeighborhoodDetail,
|
||||||
NeighborhoodScore,
|
NeighborhoodScore,
|
||||||
RawArtifact,
|
RawArtifact,
|
||||||
|
UpdateWatchlistItem,
|
||||||
|
WatchlistEvent,
|
||||||
WatchlistItem,
|
WatchlistItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { formatNumber, formatPrice } from "@/lib/utils";
|
import { formatNumber, formatPrice } from "@/lib/utils";
|
||||||
@@ -89,6 +95,10 @@ export function MarketDashboard() {
|
|||||||
const [detailNeighborhoodId, setDetailNeighborhoodId] = useState("");
|
const [detailNeighborhoodId, setDetailNeighborhoodId] = useState("");
|
||||||
const [comparisonAreaIds, setComparisonAreaIds] = useState<string[]>([]);
|
const [comparisonAreaIds, setComparisonAreaIds] = useState<string[]>([]);
|
||||||
const [comparisonNeighborhoodIds, setComparisonNeighborhoodIds] = 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({
|
const overview = useQuery({
|
||||||
queryKey: ["market-overview", MONTH],
|
queryKey: ["market-overview", MONTH],
|
||||||
queryFn: () => fetchMarketOverview(MONTH),
|
queryFn: () => fetchMarketOverview(MONTH),
|
||||||
@@ -122,8 +132,18 @@ export function MarketDashboard() {
|
|||||||
enabled: comparisonNeighborhoodIds.length >= 2,
|
enabled: comparisonNeighborhoodIds.length >= 2,
|
||||||
});
|
});
|
||||||
const watchlist = useQuery({
|
const watchlist = useQuery({
|
||||||
queryKey: ["watchlist"],
|
queryKey: ["watchlist", watchlistStatus, watchlistAreaId, watchlistLabel],
|
||||||
queryFn: fetchWatchlistItems,
|
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({
|
const dataSources = useQuery({
|
||||||
queryKey: ["data-sources"],
|
queryKey: ["data-sources"],
|
||||||
@@ -152,6 +172,24 @@ export function MarketDashboard() {
|
|||||||
await queryClient.invalidateQueries({ queryKey: ["watchlist"] });
|
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({
|
const createDataSourceMutation = useMutation({
|
||||||
mutationFn: createDataSource,
|
mutationFn: createDataSource,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -214,6 +252,7 @@ export function MarketDashboard() {
|
|||||||
neighborhoodComparison.isLoading ||
|
neighborhoodComparison.isLoading ||
|
||||||
neighborhoodScores.isLoading ||
|
neighborhoodScores.isLoading ||
|
||||||
watchlist.isLoading ||
|
watchlist.isLoading ||
|
||||||
|
watchlistEvents.isLoading ||
|
||||||
dataSources.isLoading ||
|
dataSources.isLoading ||
|
||||||
ingestionRuns.isLoading;
|
ingestionRuns.isLoading;
|
||||||
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
||||||
@@ -226,6 +265,7 @@ export function MarketDashboard() {
|
|||||||
neighborhoodComparison.isError ||
|
neighborhoodComparison.isError ||
|
||||||
neighborhoodScores.isError ||
|
neighborhoodScores.isError ||
|
||||||
watchlist.isError ||
|
watchlist.isError ||
|
||||||
|
watchlistEvents.isError ||
|
||||||
dataSources.isError ||
|
dataSources.isError ||
|
||||||
ingestionRuns.isError ||
|
ingestionRuns.isError ||
|
||||||
rawArtifacts.isError;
|
rawArtifacts.isError;
|
||||||
@@ -400,10 +440,25 @@ export function MarketDashboard() {
|
|||||||
scores={scores.data ?? []}
|
scores={scores.data ?? []}
|
||||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||||
items={watchlist.data ?? []}
|
items={watchlist.data ?? []}
|
||||||
|
events={watchlistEvents.data ?? []}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
creating={createMutation.isPending}
|
creating={createMutation.isPending}
|
||||||
archivingId={archiveMutation.variables ?? null}
|
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)}
|
onCreate={(payload) => createMutation.mutate(payload)}
|
||||||
|
onUpdate={(id, payload) => updateWatchlistMutation.mutate({ id, payload })}
|
||||||
|
onCreateEvent={(id, payload) => createWatchlistEventMutation.mutate({ id, payload })}
|
||||||
onArchive={(id) => archiveMutation.mutate(id)}
|
onArchive={(id) => archiveMutation.mutate(id)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1833,23 +1888,55 @@ function WatchlistPanel({
|
|||||||
scores,
|
scores,
|
||||||
neighborhoodScores,
|
neighborhoodScores,
|
||||||
items,
|
items,
|
||||||
|
events,
|
||||||
loading,
|
loading,
|
||||||
creating,
|
creating,
|
||||||
archivingId,
|
archivingId,
|
||||||
|
updatingId,
|
||||||
|
creatingEvent,
|
||||||
|
statusFilter,
|
||||||
|
areaFilter,
|
||||||
|
labelFilter,
|
||||||
|
expandedItemId,
|
||||||
|
onStatusFilterChange,
|
||||||
|
onAreaFilterChange,
|
||||||
|
onLabelFilterChange,
|
||||||
|
onToggleEvents,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
onCreateEvent,
|
||||||
onArchive,
|
onArchive,
|
||||||
}: {
|
}: {
|
||||||
scores: AreaScore[];
|
scores: AreaScore[];
|
||||||
neighborhoodScores: NeighborhoodScore[];
|
neighborhoodScores: NeighborhoodScore[];
|
||||||
items: WatchlistItem[];
|
items: WatchlistItem[];
|
||||||
|
events: WatchlistEvent[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
archivingId: number | null;
|
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;
|
onCreate: (payload: CreateWatchlistItem) => void;
|
||||||
|
onUpdate: (id: number, payload: UpdateWatchlistItem) => void;
|
||||||
|
onCreateEvent: (id: number, payload: CreateWatchlistEvent) => void;
|
||||||
onArchive: (id: number) => void;
|
onArchive: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [targetType, setTargetType] = useState<"area" | "neighborhood">("area");
|
||||||
const [areaId, setAreaId] = useState("");
|
const [areaId, setAreaId] = useState("");
|
||||||
|
const [neighborhoodId, setNeighborhoodId] = useState("");
|
||||||
const [targetPrice, setTargetPrice] = 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 [notes, setNotes] = useState("");
|
||||||
|
|
||||||
const areaNameById = useMemo(() => {
|
const areaNameById = useMemo(() => {
|
||||||
@@ -1866,29 +1953,85 @@ function WatchlistPanel({
|
|||||||
|
|
||||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!areaId) {
|
if (targetType === "area" && !areaId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetType === "neighborhood" && !neighborhoodId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parsedTarget = Number(targetPrice);
|
const parsedTarget = Number(targetPrice);
|
||||||
|
const parsedTrigger = Number(triggerPrice);
|
||||||
onCreate({
|
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,
|
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,
|
notes: notes.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
setAreaId("");
|
||||||
|
setNeighborhoodId("");
|
||||||
setTargetPrice("");
|
setTargetPrice("");
|
||||||
|
setTriggerPrice("");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<CardHeader className="flex flex-col gap-3">
|
||||||
<CardTitle>观察池</CardTitle>
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={submit}>
|
<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
|
<select
|
||||||
className="h-9 min-w-36 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
className="h-9 min-w-36 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||||||
value={areaId}
|
value={areaId}
|
||||||
onChange={(event) => setAreaId(event.target.value)}
|
onChange={(event) => setAreaId(event.target.value)}
|
||||||
aria-label="板块"
|
aria-label="板块"
|
||||||
|
disabled={targetType !== "area"}
|
||||||
>
|
>
|
||||||
<option value="">板块</option>
|
<option value="">板块</option>
|
||||||
{scores.map((score) => (
|
{scores.map((score) => (
|
||||||
@@ -1897,20 +2040,74 @@ function WatchlistPanel({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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
|
<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"
|
inputMode="numeric"
|
||||||
placeholder="目标价/㎡"
|
placeholder="目标价/㎡"
|
||||||
value={targetPrice}
|
value={targetPrice}
|
||||||
onChange={(event) => setTargetPrice(event.target.value)}
|
onChange={(event) => setTargetPrice(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<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="备注"
|
placeholder="备注"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -1922,7 +2119,14 @@ function WatchlistPanel({
|
|||||||
neighborhoodNameById={neighborhoodNameById}
|
neighborhoodNameById={neighborhoodNameById}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
archivingId={archivingId}
|
archivingId={archivingId}
|
||||||
|
updatingId={updatingId}
|
||||||
|
expandedItemId={expandedItemId}
|
||||||
|
events={events}
|
||||||
|
creatingEvent={creatingEvent}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onCreateEvent={onCreateEvent}
|
||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
|
onToggleEvents={onToggleEvents}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1935,15 +2139,31 @@ function WatchlistTable({
|
|||||||
neighborhoodNameById,
|
neighborhoodNameById,
|
||||||
loading,
|
loading,
|
||||||
archivingId,
|
archivingId,
|
||||||
|
updatingId,
|
||||||
|
expandedItemId,
|
||||||
|
events,
|
||||||
|
creatingEvent,
|
||||||
|
onUpdate,
|
||||||
|
onCreateEvent,
|
||||||
onArchive,
|
onArchive,
|
||||||
|
onToggleEvents,
|
||||||
}: {
|
}: {
|
||||||
items: WatchlistItem[];
|
items: WatchlistItem[];
|
||||||
areaNameById: Map<string, string>;
|
areaNameById: Map<string, string>;
|
||||||
neighborhoodNameById: Map<string, string>;
|
neighborhoodNameById: Map<string, string>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
archivingId: number | null;
|
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;
|
onArchive: (id: number) => void;
|
||||||
|
onToggleEvents: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [eventSummary, setEventSummary] = useState("");
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="h-32 bg-[var(--muted)]" />;
|
return <div className="h-32 bg-[var(--muted)]" />;
|
||||||
}
|
}
|
||||||
@@ -1958,43 +2178,160 @@ function WatchlistTable({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>标的</TableHead>
|
<TableHead>标的</TableHead>
|
||||||
<TableHead className="text-right">目标价/㎡</TableHead>
|
<TableHead className="text-right">目标价/㎡</TableHead>
|
||||||
|
<TableHead className="text-right">触发价/㎡</TableHead>
|
||||||
|
<TableHead>标签</TableHead>
|
||||||
|
<TableHead>优先级</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>复核</TableHead>
|
||||||
<TableHead>备注</TableHead>
|
<TableHead>备注</TableHead>
|
||||||
<TableHead className="w-14" />
|
<TableHead className="w-44" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<TableRow key={item.watchlist_item_id}>
|
const targetName = item.neighborhood_id
|
||||||
<TableCell className="font-medium">
|
? neighborhoodNameById.get(item.neighborhood_id) ?? item.neighborhood_id
|
||||||
{item.neighborhood_id
|
: item.area_id
|
||||||
? neighborhoodNameById.get(item.neighborhood_id) ?? item.neighborhood_id
|
? areaNameById.get(item.area_id) ?? item.area_id
|
||||||
: item.area_id
|
: "-";
|
||||||
? areaNameById.get(item.area_id) ?? item.area_id
|
const expanded = expandedItemId === item.watchlist_item_id;
|
||||||
: "-"}
|
return (
|
||||||
</TableCell>
|
<Fragment key={item.watchlist_item_id}>
|
||||||
<TableCell className="text-right">
|
<TableRow>
|
||||||
{item.target_price_psm ? formatPrice(item.target_price_psm) : "-"}
|
<TableCell className="font-medium">{targetName}</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-right">
|
||||||
<TableCell>
|
{item.target_price_psm ? formatPrice(item.target_price_psm) : "-"}
|
||||||
<Badge variant={item.status === "active" ? "success" : "muted"}>{item.status}</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-right">
|
||||||
<TableCell className="max-w-80 truncate text-[var(--muted-foreground)]">
|
{item.trigger_price_psm
|
||||||
{item.notes || "-"}
|
? `${item.trigger_operator === "lte" ? "≤" : "≥"} ${formatPrice(item.trigger_price_psm)}`
|
||||||
</TableCell>
|
: "-"}
|
||||||
<TableCell className="text-right">
|
</TableCell>
|
||||||
<Button
|
<TableCell>
|
||||||
variant="outline"
|
<Badge variant="muted">{item.label}</Badge>
|
||||||
disabled={archivingId === item.watchlist_item_id}
|
</TableCell>
|
||||||
onClick={() => onArchive(item.watchlist_item_id)}
|
<TableCell>
|
||||||
aria-label="归档"
|
<Badge variant={item.priority === "high" ? "warning" : "muted"}>
|
||||||
title="归档"
|
{item.priority}
|
||||||
>
|
</Badge>
|
||||||
<Archive className="h-4 w-4" />
|
</TableCell>
|
||||||
</Button>
|
<TableCell>
|
||||||
</TableCell>
|
<select
|
||||||
</TableRow>
|
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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,16 +103,58 @@ export type WatchlistItem = {
|
|||||||
area_id: string | null;
|
area_id: string | null;
|
||||||
target_price_psm: number | null;
|
target_price_psm: number | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
label: string;
|
||||||
|
priority: string;
|
||||||
|
trigger_operator: string;
|
||||||
|
trigger_price_psm: number | null;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
last_reviewed_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateWatchlistItem = {
|
export type CreateWatchlistItem = {
|
||||||
area_id?: string;
|
area_id?: string;
|
||||||
neighborhood_id?: string;
|
neighborhood_id?: string;
|
||||||
target_price_psm?: number;
|
target_price_psm?: number;
|
||||||
|
label?: string;
|
||||||
|
priority?: string;
|
||||||
|
trigger_operator?: string;
|
||||||
|
trigger_price_psm?: number;
|
||||||
notes?: string;
|
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 = {
|
export type DataSource = {
|
||||||
source_id: number;
|
source_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -372,8 +414,17 @@ export async function fetchNeighborhoodComparison(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
|
export async function fetchWatchlistItems(
|
||||||
return fetchJson("/api/v1/watchlist");
|
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(
|
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[]> {
|
export async function fetchDataSources(): Promise<DataSource[]> {
|
||||||
return fetchJson("/api/v1/data-sources");
|
return fetchJson("/api/v1/data-sources");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,20 +234,25 @@
|
|||||||
|
|
||||||
### M2.4 观察池增强
|
### M2.4 观察池增强
|
||||||
|
|
||||||
|
状态:已完成。
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
|
|
||||||
- 观察池从简单列表升级为投资跟踪工具。
|
- 观察池从简单列表升级为投资跟踪工具。
|
||||||
|
|
||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
- 目标价、触发条件、状态、标签、备注。
|
- 目标价、触发条件、状态、标签、优先级、备注。
|
||||||
- 更新历史。
|
- `PATCH /api/v1/watchlist/{watchlist_item_id}` 支持状态、触发价、标签、复核时间更新。
|
||||||
- 观察池筛选。
|
- `GET/POST /api/v1/watchlist/{watchlist_item_id}/events` 更新历史。
|
||||||
|
- 观察池筛选:状态、板块、标签。
|
||||||
|
- 前端观察池增强表单、筛选器、状态切换、复核、历史展开。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
- 可以区分 active、paused、archived。
|
- 可以区分 active、paused、archived。
|
||||||
- 可以按板块、小区、状态筛选。
|
- 可以按板块、小区、状态筛选。
|
||||||
|
- 任一观察标的可记录人工事件和复核时间。
|
||||||
|
|
||||||
## M3 模型与回测
|
## M3 模型与回测
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user