feat: add report center
This commit is contained in:
28
apps/api/migrations/202606240007_reports.sql
Normal file
28
apps/api/migrations/202606240007_reports.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS app.reports (
|
||||||
|
report_id BIGSERIAL PRIMARY KEY,
|
||||||
|
report_type TEXT NOT NULL CHECK (report_type IN ('monthly', 'area_special', 'neighborhood_memo')),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
target_month TEXT CHECK (target_month IS NULL OR target_month ~ '^[0-9]{4}-[0-9]{2}$'),
|
||||||
|
area_id TEXT REFERENCES silver.areas(area_id),
|
||||||
|
neighborhood_id TEXT REFERENCES silver.neighborhoods(neighborhood_id),
|
||||||
|
summary TEXT NOT NULL DEFAULT '',
|
||||||
|
content_markdown TEXT NOT NULL,
|
||||||
|
linked_metrics JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CHECK (char_length(title) > 0),
|
||||||
|
CHECK (char_length(content_markdown) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reports_type_month
|
||||||
|
ON app.reports(report_type, target_month, report_id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reports_area
|
||||||
|
ON app.reports(area_id, report_id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reports_neighborhood
|
||||||
|
ON app.reports(neighborhood_id, report_id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reports_linked_metrics
|
||||||
|
ON app.reports USING GIN (linked_metrics);
|
||||||
@@ -9,16 +9,16 @@ use crate::models::{
|
|||||||
AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse,
|
AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse,
|
||||||
AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric,
|
AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric,
|
||||||
ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
|
ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
|
||||||
CreateRawArtifact, CreateScenarioRun, CreateWatchlistEvent, CreateWatchlistItem, DataSource,
|
CreateRawArtifact, CreateReport, CreateScenarioRun, CreateWatchlistEvent, CreateWatchlistItem,
|
||||||
DiagnosticMetric, FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse,
|
DataSource, DiagnosticMetric, FinishIngestionRun, ImportExecutionRequest,
|
||||||
ImportValidationRequest, ImportValidationResponse, IngestionRun, IngestionRunQuery,
|
ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun,
|
||||||
MarketOverview, ModelRun, ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood,
|
IngestionRunQuery, MarketOverview, ModelRun, ModelRunDiffQuery, ModelRunQuery, MonthQuery,
|
||||||
NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail,
|
Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail,
|
||||||
NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
|
NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
|
||||||
RawArtifact, RawArtifactQuery, ScenarioAreaResult, ScenarioRun, ScenarioRunQuery,
|
RawArtifact, RawArtifactQuery, Report, ReportQuery, ScenarioAreaResult, ScenarioRun,
|
||||||
ScenarioRunResponse, ScenarioVariant, SimilarNeighborhood, SimilarNeighborhoodCandidate,
|
ScenarioRunQuery, ScenarioRunResponse, ScenarioVariant, SimilarNeighborhood,
|
||||||
SimilarNeighborhoodQuery, SimilarNeighborhoodResponse, UpdateWatchlistItem, WatchlistEvent,
|
SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery, SimilarNeighborhoodResponse,
|
||||||
WatchlistItem, WatchlistQuery,
|
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;
|
||||||
@@ -1848,6 +1848,101 @@ pub async fn create_scenario_run(
|
|||||||
Ok(Json(ScenarioRunResponse { run, variants }))
|
Ok(Json(ScenarioRunResponse { run, variants }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_reports(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ReportQuery>,
|
||||||
|
) -> ApiResult<Json<Vec<Report>>> {
|
||||||
|
validate_report_query(&query)?;
|
||||||
|
let reports = sqlx::query_as::<_, Report>(
|
||||||
|
report_select_sql(
|
||||||
|
r#"
|
||||||
|
WHERE ($1::text IS NULL OR r.report_type = $1)
|
||||||
|
AND ($2::text IS NULL OR r.target_month = $2)
|
||||||
|
AND ($3::text IS NULL OR r.area_id = $3)
|
||||||
|
AND ($4::text IS NULL OR r.neighborhood_id = $4)
|
||||||
|
AND ($5::text IS NULL OR r.status = $5)
|
||||||
|
ORDER BY r.updated_at DESC, r.report_id DESC
|
||||||
|
LIMIT 80
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
)
|
||||||
|
.bind(query.report_type)
|
||||||
|
.bind(query.target_month)
|
||||||
|
.bind(query.area_id)
|
||||||
|
.bind(query.neighborhood_id)
|
||||||
|
.bind(query.status)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(reports))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_report(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(report_id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Report>> {
|
||||||
|
let report = sqlx::query_as::<_, Report>(report_select_sql("WHERE r.report_id = $1").as_str())
|
||||||
|
.bind(report_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::BadRequest("report not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_report(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateReport>,
|
||||||
|
) -> ApiResult<Json<Report>> {
|
||||||
|
validate_create_report(&payload)?;
|
||||||
|
let linked_metrics = payload
|
||||||
|
.linked_metrics
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| serde_json::json!({}));
|
||||||
|
if !linked_metrics.is_object() {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"linked_metrics must be a JSON object".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
let report_id = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO app.reports (
|
||||||
|
report_type,
|
||||||
|
title,
|
||||||
|
target_month,
|
||||||
|
area_id,
|
||||||
|
neighborhood_id,
|
||||||
|
summary,
|
||||||
|
content_markdown,
|
||||||
|
linked_metrics,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, COALESCE($6, ''), $7, $8, COALESCE($9, 'draft'))
|
||||||
|
RETURNING report_id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(payload.report_type.trim().to_string())
|
||||||
|
.bind(payload.title.trim().to_string())
|
||||||
|
.bind(payload.target_month.clone())
|
||||||
|
.bind(payload.area_id.clone())
|
||||||
|
.bind(payload.neighborhood_id.clone())
|
||||||
|
.bind(payload.summary.clone())
|
||||||
|
.bind(payload.content_markdown.trim().to_string())
|
||||||
|
.bind(linked_metrics)
|
||||||
|
.bind(payload.status.clone())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let report = fetch_report_by_id(&mut tx, report_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::BadRequest("report not found".to_string()))?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
async fn insert_watchlist_event(
|
async fn insert_watchlist_event(
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
watchlist_item_id: i64,
|
watchlist_item_id: i64,
|
||||||
@@ -1872,6 +1967,81 @@ async fn insert_watchlist_event(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn report_select_sql(where_clause: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
r.report_id,
|
||||||
|
r.report_type,
|
||||||
|
r.title,
|
||||||
|
r.target_month,
|
||||||
|
r.area_id,
|
||||||
|
a.name AS area_name,
|
||||||
|
r.neighborhood_id,
|
||||||
|
n.name AS neighborhood_name,
|
||||||
|
r.summary,
|
||||||
|
r.content_markdown,
|
||||||
|
r.linked_metrics,
|
||||||
|
r.status,
|
||||||
|
r.created_at::text AS created_at,
|
||||||
|
r.updated_at::text AS updated_at
|
||||||
|
FROM app.reports r
|
||||||
|
LEFT JOIN silver.areas a ON a.area_id = r.area_id
|
||||||
|
LEFT JOIN silver.neighborhoods n ON n.neighborhood_id = r.neighborhood_id
|
||||||
|
{where_clause}
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_report_by_id(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
report_id: i64,
|
||||||
|
) -> Result<Option<Report>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Report>(report_select_sql("WHERE r.report_id = $1").as_str())
|
||||||
|
.bind(report_id)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_report_query(query: &ReportQuery) -> ApiResult<()> {
|
||||||
|
if let Some(report_type) = query.report_type.as_deref() {
|
||||||
|
validate_report_type(report_type)?;
|
||||||
|
}
|
||||||
|
if let Some(status) = query.status.as_deref() {
|
||||||
|
validate_report_status(status)?;
|
||||||
|
}
|
||||||
|
if let Some(month) = query.target_month.as_deref() {
|
||||||
|
validate_month(month)?;
|
||||||
|
}
|
||||||
|
if let Some(area_id) = query.area_id.as_deref() {
|
||||||
|
validate_identifier(area_id)?;
|
||||||
|
}
|
||||||
|
if let Some(neighborhood_id) = query.neighborhood_id.as_deref() {
|
||||||
|
validate_identifier(neighborhood_id)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_create_report(payload: &CreateReport) -> ApiResult<()> {
|
||||||
|
payload
|
||||||
|
.validate()
|
||||||
|
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
|
||||||
|
validate_report_type(&payload.report_type)?;
|
||||||
|
if let Some(status) = payload.status.as_deref() {
|
||||||
|
validate_report_status(status)?;
|
||||||
|
}
|
||||||
|
if let Some(month) = payload.target_month.as_deref() {
|
||||||
|
validate_month(month)?;
|
||||||
|
}
|
||||||
|
if let Some(area_id) = payload.area_id.as_deref() {
|
||||||
|
validate_identifier(area_id)?;
|
||||||
|
}
|
||||||
|
if let Some(neighborhood_id) = payload.neighborhood_id.as_deref() {
|
||||||
|
validate_identifier(neighborhood_id)?;
|
||||||
|
}
|
||||||
|
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#"
|
||||||
@@ -2861,6 +3031,26 @@ fn validate_identifier(value: &str) -> ApiResult<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_report_type(value: &str) -> ApiResult<()> {
|
||||||
|
if matches!(value, "monthly" | "area_special" | "neighborhood_memo") {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::BadRequest(
|
||||||
|
"report_type must be monthly, area_special, or neighborhood_memo".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_report_status(value: &str) -> ApiResult<()> {
|
||||||
|
if matches!(value, "draft" | "published" | "archived") {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::BadRequest(
|
||||||
|
"status must be draft, published, or archived".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn has_sensitive_text(value: &str) -> bool {
|
fn has_sensitive_text(value: &str) -> bool {
|
||||||
let lower = value.to_ascii_lowercase();
|
let lower = value.to_ascii_lowercase();
|
||||||
lower.contains("password")
|
lower.contains("password")
|
||||||
@@ -2875,7 +3065,7 @@ fn has_sensitive_text(value: &str) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateScenarioRun,
|
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateReport, CreateScenarioRun,
|
||||||
CreateWatchlistItem, FinishIngestionRun, ScenarioRun, SimilarNeighborhoodCandidate,
|
CreateWatchlistItem, FinishIngestionRun, ScenarioRun, SimilarNeighborhoodCandidate,
|
||||||
UpdateWatchlistItem,
|
UpdateWatchlistItem,
|
||||||
};
|
};
|
||||||
@@ -3076,6 +3266,51 @@ mod tests {
|
|||||||
.is_err());
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_report_payload() {
|
||||||
|
assert!(CreateReport {
|
||||||
|
report_type: "monthly".to_string(),
|
||||||
|
title: "2026-05 月报".to_string(),
|
||||||
|
target_month: Some("2026-05".to_string()),
|
||||||
|
area_id: None,
|
||||||
|
neighborhood_id: None,
|
||||||
|
summary: Some("核心结论".to_string()),
|
||||||
|
content_markdown: "# 月报\n\n正文".to_string(),
|
||||||
|
linked_metrics: Some(serde_json::json!({"month": "2026-05"})),
|
||||||
|
status: Some("published".to_string()),
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_ok());
|
||||||
|
|
||||||
|
assert!(CreateReport {
|
||||||
|
report_type: "unknown".to_string(),
|
||||||
|
title: "bad".to_string(),
|
||||||
|
target_month: None,
|
||||||
|
area_id: None,
|
||||||
|
neighborhood_id: None,
|
||||||
|
summary: None,
|
||||||
|
content_markdown: "# bad".to_string(),
|
||||||
|
linked_metrics: None,
|
||||||
|
status: None,
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
assert!(CreateReport {
|
||||||
|
report_type: "area_special".to_string(),
|
||||||
|
title: "bad".to_string(),
|
||||||
|
target_month: None,
|
||||||
|
area_id: Some("zhangjiang".to_string()),
|
||||||
|
neighborhood_id: None,
|
||||||
|
summary: Some("contains password marker".to_string()),
|
||||||
|
content_markdown: "# bad".to_string(),
|
||||||
|
linked_metrics: None,
|
||||||
|
status: None,
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validates_scenario_run_payload() {
|
fn validates_scenario_run_payload() {
|
||||||
assert!(CreateScenarioRun {
|
assert!(CreateScenarioRun {
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ pub struct ScenarioRunQuery {
|
|||||||
pub area_id: Option<String>,
|
pub area_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ReportQuery {
|
||||||
|
pub report_type: Option<String>,
|
||||||
|
pub target_month: Option<String>,
|
||||||
|
pub area_id: Option<String>,
|
||||||
|
pub neighborhood_id: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RawArtifactQuery {
|
pub struct RawArtifactQuery {
|
||||||
pub source_id: Option<i64>,
|
pub source_id: Option<i64>,
|
||||||
@@ -587,6 +596,78 @@ pub struct ScenarioRunResponse {
|
|||||||
pub variants: Vec<ScenarioVariant>,
|
pub variants: Vec<ScenarioVariant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct Report {
|
||||||
|
pub report_id: i64,
|
||||||
|
pub report_type: String,
|
||||||
|
pub title: String,
|
||||||
|
pub target_month: Option<String>,
|
||||||
|
pub area_id: Option<String>,
|
||||||
|
pub area_name: Option<String>,
|
||||||
|
pub neighborhood_id: Option<String>,
|
||||||
|
pub neighborhood_name: Option<String>,
|
||||||
|
pub summary: String,
|
||||||
|
pub content_markdown: String,
|
||||||
|
pub linked_metrics: Value,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateReport {
|
||||||
|
pub report_type: String,
|
||||||
|
pub title: String,
|
||||||
|
pub target_month: Option<String>,
|
||||||
|
pub area_id: Option<String>,
|
||||||
|
pub neighborhood_id: Option<String>,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub content_markdown: String,
|
||||||
|
pub linked_metrics: Option<Value>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateReport {
|
||||||
|
pub fn validate(&self) -> Result<(), &'static str> {
|
||||||
|
if !is_report_type(&self.report_type) {
|
||||||
|
return Err("report_type must be monthly, area_special, or neighborhood_memo");
|
||||||
|
}
|
||||||
|
if self.title.trim().is_empty() {
|
||||||
|
return Err("title is required");
|
||||||
|
}
|
||||||
|
if self.title.len() > 160 {
|
||||||
|
return Err("title is too long");
|
||||||
|
}
|
||||||
|
if self.content_markdown.trim().is_empty() {
|
||||||
|
return Err("content_markdown is required");
|
||||||
|
}
|
||||||
|
if self.content_markdown.len() > 80_000 {
|
||||||
|
return Err("content_markdown is too long");
|
||||||
|
}
|
||||||
|
if let Some(status) = self.status.as_deref() {
|
||||||
|
if !is_report_status(status) {
|
||||||
|
return Err("status must be draft, published, or archived");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for value in [
|
||||||
|
Some(self.report_type.as_str()),
|
||||||
|
Some(self.title.as_str()),
|
||||||
|
self.target_month.as_deref(),
|
||||||
|
self.area_id.as_deref(),
|
||||||
|
self.neighborhood_id.as_deref(),
|
||||||
|
self.summary.as_deref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
if has_sensitive_text(value) {
|
||||||
|
return Err("payload contains sensitive text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
pub struct DataSource {
|
pub struct DataSource {
|
||||||
pub source_id: i64,
|
pub source_id: i64,
|
||||||
@@ -948,3 +1029,14 @@ fn is_watchlist_priority(priority: &str) -> bool {
|
|||||||
fn is_watchlist_trigger_operator(operator: &str) -> bool {
|
fn is_watchlist_trigger_operator(operator: &str) -> bool {
|
||||||
matches!(operator, "lte" | "gte")
|
matches!(operator, "lte" | "gte")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_report_type(report_type: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
report_type,
|
||||||
|
"monthly" | "area_special" | "neighborhood_memo"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_report_status(status: &str) -> bool {
|
||||||
|
matches!(status, "draft" | "published" | "archived")
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ 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_scenario_run,
|
create_data_source, create_ingestion_run, create_raw_artifact, create_report,
|
||||||
create_watchlist_event, create_watchlist_item, diff_area_score_model_runs, execute_import,
|
create_scenario_run, create_watchlist_event, create_watchlist_item, diff_area_score_model_runs,
|
||||||
finish_ingestion_run, get_area_detail, get_area_diagnostics, get_area_score_lineage,
|
execute_import, finish_ingestion_run, get_area_detail, get_area_diagnostics,
|
||||||
get_neighborhood, get_neighborhood_detail, get_similar_neighborhoods, health, list_area_scores,
|
get_area_score_lineage, get_neighborhood, get_neighborhood_detail, get_report,
|
||||||
list_data_sources, list_ingestion_runs, list_model_runs, list_neighborhood_scores,
|
get_similar_neighborhoods, health, list_area_scores, list_data_sources, list_ingestion_runs,
|
||||||
list_neighborhoods, list_raw_artifacts, list_scenario_runs, list_watchlist_events,
|
list_model_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts,
|
||||||
list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import,
|
list_reports, list_scenario_runs, list_watchlist_events, list_watchlist_items, market_overview,
|
||||||
|
ready, update_watchlist_item, validate_import,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -85,7 +86,9 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route(
|
.route(
|
||||||
"/scenario-runs",
|
"/scenario-runs",
|
||||||
get(list_scenario_runs).post(create_scenario_run),
|
get(list_scenario_runs).post(create_scenario_run),
|
||||||
),
|
)
|
||||||
|
.route("/reports", get(list_reports).post(create_report))
|
||||||
|
.route("/reports/{report_id}", get(get_report)),
|
||||||
)
|
)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
createIngestionRun,
|
createIngestionRun,
|
||||||
createRawArtifact,
|
createRawArtifact,
|
||||||
createAreaScoreModelRun,
|
createAreaScoreModelRun,
|
||||||
|
createReport,
|
||||||
createScenarioRun,
|
createScenarioRun,
|
||||||
createWatchlistEvent,
|
createWatchlistEvent,
|
||||||
createWatchlistItem,
|
createWatchlistItem,
|
||||||
@@ -51,6 +52,8 @@ import {
|
|||||||
fetchNeighborhoodDetail,
|
fetchNeighborhoodDetail,
|
||||||
fetchNeighborhoodScores,
|
fetchNeighborhoodScores,
|
||||||
fetchRawArtifacts,
|
fetchRawArtifacts,
|
||||||
|
fetchReport,
|
||||||
|
fetchReports,
|
||||||
fetchScenarioRuns,
|
fetchScenarioRuns,
|
||||||
fetchWatchlistEvents,
|
fetchWatchlistEvents,
|
||||||
fetchWatchlistItems,
|
fetchWatchlistItems,
|
||||||
@@ -67,6 +70,7 @@ import type {
|
|||||||
CreateDataSource,
|
CreateDataSource,
|
||||||
CreateIngestionRun,
|
CreateIngestionRun,
|
||||||
CreateRawArtifact,
|
CreateRawArtifact,
|
||||||
|
CreateReport,
|
||||||
CreateScenarioRun,
|
CreateScenarioRun,
|
||||||
CreateWatchlistEvent,
|
CreateWatchlistEvent,
|
||||||
CreateWatchlistItem,
|
CreateWatchlistItem,
|
||||||
@@ -78,6 +82,7 @@ import type {
|
|||||||
NeighborhoodDetail,
|
NeighborhoodDetail,
|
||||||
NeighborhoodScore,
|
NeighborhoodScore,
|
||||||
RawArtifact,
|
RawArtifact,
|
||||||
|
Report,
|
||||||
ScenarioRun,
|
ScenarioRun,
|
||||||
ScenarioRunResponse,
|
ScenarioRunResponse,
|
||||||
SimilarNeighborhood,
|
SimilarNeighborhood,
|
||||||
@@ -114,6 +119,8 @@ export function MarketDashboard() {
|
|||||||
const [baseModelRunId, setBaseModelRunId] = useState<number | null>(null);
|
const [baseModelRunId, setBaseModelRunId] = useState<number | null>(null);
|
||||||
const [candidateModelRunId, setCandidateModelRunId] = useState<number | null>(null);
|
const [candidateModelRunId, setCandidateModelRunId] = useState<number | null>(null);
|
||||||
const [latestScenario, setLatestScenario] = useState<ScenarioRunResponse | null>(null);
|
const [latestScenario, setLatestScenario] = useState<ScenarioRunResponse | null>(null);
|
||||||
|
const [reportTypeFilter, setReportTypeFilter] = useState("monthly");
|
||||||
|
const [selectedReportId, setSelectedReportId] = useState<number | null>(null);
|
||||||
const overview = useQuery({
|
const overview = useQuery({
|
||||||
queryKey: ["market-overview", MONTH],
|
queryKey: ["market-overview", MONTH],
|
||||||
queryFn: () => fetchMarketOverview(MONTH),
|
queryFn: () => fetchMarketOverview(MONTH),
|
||||||
@@ -176,6 +183,19 @@ export function MarketDashboard() {
|
|||||||
queryKey: ["scenario-runs", MONTH],
|
queryKey: ["scenario-runs", MONTH],
|
||||||
queryFn: () => fetchScenarioRuns({ target_month: MONTH }),
|
queryFn: () => fetchScenarioRuns({ target_month: MONTH }),
|
||||||
});
|
});
|
||||||
|
const reports = useQuery({
|
||||||
|
queryKey: ["reports", reportTypeFilter, MONTH],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchReports({
|
||||||
|
report_type: reportTypeFilter,
|
||||||
|
target_month: reportTypeFilter === "monthly" ? MONTH : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const selectedReport = useQuery({
|
||||||
|
queryKey: ["report", selectedReportId],
|
||||||
|
queryFn: () => fetchReport(selectedReportId ?? 0),
|
||||||
|
enabled: selectedReportId !== null,
|
||||||
|
});
|
||||||
const dataSources = useQuery({
|
const dataSources = useQuery({
|
||||||
queryKey: ["data-sources"],
|
queryKey: ["data-sources"],
|
||||||
queryFn: fetchDataSources,
|
queryFn: fetchDataSources,
|
||||||
@@ -244,6 +264,16 @@ export function MarketDashboard() {
|
|||||||
await queryClient.invalidateQueries({ queryKey: ["scenario-runs"] });
|
await queryClient.invalidateQueries({ queryKey: ["scenario-runs"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const createReportMutation = useMutation({
|
||||||
|
mutationFn: createReport,
|
||||||
|
onSuccess: async (report) => {
|
||||||
|
setSelectedReportId(report.report_id);
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["reports"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["report", report.report_id] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
const createDataSourceMutation = useMutation({
|
const createDataSourceMutation = useMutation({
|
||||||
mutationFn: createDataSource,
|
mutationFn: createDataSource,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -308,6 +338,17 @@ export function MarketDashboard() {
|
|||||||
}
|
}
|
||||||
}, [baseModelRunId, candidateModelRunId, modelRuns.data]);
|
}, [baseModelRunId, candidateModelRunId, modelRuns.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reports.data && reports.data.length > 0) {
|
||||||
|
const selectedExists =
|
||||||
|
selectedReportId !== null &&
|
||||||
|
reports.data.some((report) => report.report_id === selectedReportId);
|
||||||
|
if (!selectedExists) {
|
||||||
|
setSelectedReportId(reports.data[0].report_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [reports.data, selectedReportId]);
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
overview.isLoading ||
|
overview.isLoading ||
|
||||||
scores.isLoading ||
|
scores.isLoading ||
|
||||||
@@ -321,6 +362,8 @@ export function MarketDashboard() {
|
|||||||
modelRuns.isLoading ||
|
modelRuns.isLoading ||
|
||||||
areaScoreRunDiff.isLoading ||
|
areaScoreRunDiff.isLoading ||
|
||||||
scenarioRuns.isLoading ||
|
scenarioRuns.isLoading ||
|
||||||
|
reports.isLoading ||
|
||||||
|
selectedReport.isLoading ||
|
||||||
dataSources.isLoading ||
|
dataSources.isLoading ||
|
||||||
ingestionRuns.isLoading;
|
ingestionRuns.isLoading;
|
||||||
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
||||||
@@ -337,6 +380,8 @@ export function MarketDashboard() {
|
|||||||
modelRuns.isError ||
|
modelRuns.isError ||
|
||||||
areaScoreRunDiff.isError ||
|
areaScoreRunDiff.isError ||
|
||||||
scenarioRuns.isError ||
|
scenarioRuns.isError ||
|
||||||
|
reports.isError ||
|
||||||
|
selectedReport.isError ||
|
||||||
dataSources.isError ||
|
dataSources.isError ||
|
||||||
ingestionRuns.isError ||
|
ingestionRuns.isError ||
|
||||||
rawArtifacts.isError;
|
rawArtifacts.isError;
|
||||||
@@ -370,6 +415,8 @@ export function MarketDashboard() {
|
|||||||
void modelRuns.refetch();
|
void modelRuns.refetch();
|
||||||
void areaScoreRunDiff.refetch();
|
void areaScoreRunDiff.refetch();
|
||||||
void scenarioRuns.refetch();
|
void scenarioRuns.refetch();
|
||||||
|
void reports.refetch();
|
||||||
|
void selectedReport.refetch();
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label="刷新"
|
aria-label="刷新"
|
||||||
@@ -546,6 +593,23 @@ export function MarketDashboard() {
|
|||||||
onCreate={(payload) => createScenarioMutation.mutate(payload)}
|
onCreate={(payload) => createScenarioMutation.mutate(payload)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ReportCenter
|
||||||
|
areaScores={scores.data ?? []}
|
||||||
|
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||||
|
reports={reports.data ?? []}
|
||||||
|
selectedReport={selectedReport.data ?? null}
|
||||||
|
selectedReportId={selectedReportId}
|
||||||
|
reportTypeFilter={reportTypeFilter}
|
||||||
|
loading={reports.isLoading || selectedReport.isLoading}
|
||||||
|
creating={createReportMutation.isPending}
|
||||||
|
onReportTypeFilterChange={(value) => {
|
||||||
|
setReportTypeFilter(value);
|
||||||
|
setSelectedReportId(null);
|
||||||
|
}}
|
||||||
|
onSelectReport={setSelectedReportId}
|
||||||
|
onCreate={(payload) => createReportMutation.mutate(payload)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ModelRunPanel
|
<ModelRunPanel
|
||||||
runs={modelRuns.data ?? []}
|
runs={modelRuns.data ?? []}
|
||||||
diff={areaScoreRunDiff.data ?? null}
|
diff={areaScoreRunDiff.data ?? null}
|
||||||
@@ -2493,6 +2557,342 @@ function scenarioDeltaClass(value: number) {
|
|||||||
return "text-xs font-medium text-[var(--muted-foreground)]";
|
return "text-xs font-medium text-[var(--muted-foreground)]";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReportCenter({
|
||||||
|
areaScores,
|
||||||
|
neighborhoodScores,
|
||||||
|
reports,
|
||||||
|
selectedReport,
|
||||||
|
selectedReportId,
|
||||||
|
reportTypeFilter,
|
||||||
|
loading,
|
||||||
|
creating,
|
||||||
|
onReportTypeFilterChange,
|
||||||
|
onSelectReport,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
areaScores: AreaScore[];
|
||||||
|
neighborhoodScores: NeighborhoodScore[];
|
||||||
|
reports: Report[];
|
||||||
|
selectedReport: Report | null;
|
||||||
|
selectedReportId: number | null;
|
||||||
|
reportTypeFilter: string;
|
||||||
|
loading: boolean;
|
||||||
|
creating: boolean;
|
||||||
|
onReportTypeFilterChange: (value: string) => void;
|
||||||
|
onSelectReport: (reportId: number) => void;
|
||||||
|
onCreate: (payload: CreateReport) => void;
|
||||||
|
}) {
|
||||||
|
const [draftType, setDraftType] = useState<CreateReport["report_type"]>("monthly");
|
||||||
|
const [draftAreaId, setDraftAreaId] = useState("");
|
||||||
|
const [draftNeighborhoodId, setDraftNeighborhoodId] = useState("");
|
||||||
|
const [draftTitle, setDraftTitle] = useState(`上海房市投资研究月报 ${MONTH}`);
|
||||||
|
const [draftSummary, setDraftSummary] = useState("核心结论待复核。");
|
||||||
|
const [draftContent, setDraftContent] = useState(defaultReportMarkdown("monthly"));
|
||||||
|
|
||||||
|
function submitReport() {
|
||||||
|
onCreate({
|
||||||
|
report_type: draftType,
|
||||||
|
title: draftTitle,
|
||||||
|
target_month: MONTH,
|
||||||
|
area_id: draftAreaId || undefined,
|
||||||
|
neighborhood_id: draftNeighborhoodId || undefined,
|
||||||
|
summary: draftSummary,
|
||||||
|
content_markdown: draftContent,
|
||||||
|
linked_metrics: {
|
||||||
|
month: MONTH,
|
||||||
|
area_id: draftAreaId || null,
|
||||||
|
neighborhood_id: draftNeighborhoodId || null,
|
||||||
|
},
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeDraftType(value: CreateReport["report_type"]) {
|
||||||
|
setDraftType(value);
|
||||||
|
setDraftTitle(defaultReportTitle(value));
|
||||||
|
setDraftContent(defaultReportMarkdown(value));
|
||||||
|
if (value !== "area_special") {
|
||||||
|
setDraftAreaId("");
|
||||||
|
}
|
||||||
|
if (value !== "neighborhood_memo") {
|
||||||
|
setDraftNeighborhoodId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>报告中心</CardTitle>
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||||
|
月报、板块专题与小区备忘录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["monthly", "area_special", "neighborhood_memo"] as const).map((type) => (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
variant={reportTypeFilter === type ? "default" : "outline"}
|
||||||
|
onClick={() => onReportTypeFilterChange(type)}
|
||||||
|
>
|
||||||
|
{reportTypeLabel(type)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="rounded-md border border-[var(--border)] p-4">
|
||||||
|
<p className="text-sm font-semibold text-slate-950">新建报告</p>
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
<label className="grid gap-1 text-sm">
|
||||||
|
<span className="font-medium text-slate-950">类型</span>
|
||||||
|
<select
|
||||||
|
value={draftType}
|
||||||
|
onChange={(event) =>
|
||||||
|
changeDraftType(event.target.value as CreateReport["report_type"])
|
||||||
|
}
|
||||||
|
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||||
|
>
|
||||||
|
<option value="monthly">月报</option>
|
||||||
|
<option value="area_special">板块专题</option>
|
||||||
|
<option value="neighborhood_memo">小区备忘录</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{draftType === "area_special" ? (
|
||||||
|
<label className="grid gap-1 text-sm">
|
||||||
|
<span className="font-medium text-slate-950">关联板块</span>
|
||||||
|
<select
|
||||||
|
value={draftAreaId}
|
||||||
|
onChange={(event) => setDraftAreaId(event.target.value)}
|
||||||
|
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||||
|
>
|
||||||
|
<option value="">未选择</option>
|
||||||
|
{areaScores.map((score) => (
|
||||||
|
<option key={score.area_id} value={score.area_id}>
|
||||||
|
{score.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{draftType === "neighborhood_memo" ? (
|
||||||
|
<label className="grid gap-1 text-sm">
|
||||||
|
<span className="font-medium text-slate-950">关联小区</span>
|
||||||
|
<select
|
||||||
|
value={draftNeighborhoodId}
|
||||||
|
onChange={(event) => setDraftNeighborhoodId(event.target.value)}
|
||||||
|
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||||
|
>
|
||||||
|
<option value="">未选择</option>
|
||||||
|
{neighborhoodScores.map((score) => (
|
||||||
|
<option key={score.neighborhood_id} value={score.neighborhood_id}>
|
||||||
|
{score.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className="grid gap-1 text-sm">
|
||||||
|
<span className="font-medium text-slate-950">标题</span>
|
||||||
|
<input
|
||||||
|
value={draftTitle}
|
||||||
|
onChange={(event) => setDraftTitle(event.target.value)}
|
||||||
|
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1 text-sm">
|
||||||
|
<span className="font-medium text-slate-950">摘要</span>
|
||||||
|
<textarea
|
||||||
|
value={draftSummary}
|
||||||
|
onChange={(event) => setDraftSummary(event.target.value)}
|
||||||
|
className="min-h-16 rounded-md border border-[var(--border)] px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1 text-sm">
|
||||||
|
<span className="font-medium text-slate-950">Markdown 正文</span>
|
||||||
|
<textarea
|
||||||
|
value={draftContent}
|
||||||
|
onChange={(event) => setDraftContent(event.target.value)}
|
||||||
|
className="min-h-56 rounded-md border border-[var(--border)] px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
onClick={submitReport}
|
||||||
|
disabled={creating || !draftTitle.trim() || !draftContent.trim()}
|
||||||
|
>
|
||||||
|
{creating ? "保存中" : "保存草稿"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ReportList
|
||||||
|
reports={reports}
|
||||||
|
selectedReportId={selectedReportId}
|
||||||
|
loading={loading}
|
||||||
|
onSelectReport={onSelectReport}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ReportViewer report={selectedReport} loading={loading} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportList({
|
||||||
|
reports,
|
||||||
|
selectedReportId,
|
||||||
|
loading,
|
||||||
|
onSelectReport,
|
||||||
|
}: {
|
||||||
|
reports: Report[];
|
||||||
|
selectedReportId: number | null;
|
||||||
|
loading: boolean;
|
||||||
|
onSelectReport: (reportId: number) => void;
|
||||||
|
}) {
|
||||||
|
if (loading && reports.length === 0) {
|
||||||
|
return <div className="h-56 rounded-md bg-[var(--muted)]" />;
|
||||||
|
}
|
||||||
|
if (reports.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||||
|
暂无报告
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-md border border-[var(--border)]">
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
{reports.slice(0, 8).map((report) => (
|
||||||
|
<TableRow
|
||||||
|
key={report.report_id}
|
||||||
|
className={
|
||||||
|
selectedReportId === report.report_id
|
||||||
|
? "cursor-pointer bg-teal-50"
|
||||||
|
: "cursor-pointer hover:bg-slate-50"
|
||||||
|
}
|
||||||
|
onClick={() => onSelectReport(report.report_id)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<span className="block font-medium text-slate-950">{report.title}</span>
|
||||||
|
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||||
|
{reportTypeLabel(report.report_type)} · {report.target_month ?? "-"} ·{" "}
|
||||||
|
{report.status}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportViewer({ report, loading }: { report: Report | null; loading: boolean }) {
|
||||||
|
if (loading && !report) {
|
||||||
|
return <div className="h-[520px] rounded-md bg-[var(--muted)]" />;
|
||||||
|
}
|
||||||
|
if (!report) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||||
|
选择或新建一篇报告
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedMetricRows = Object.entries(report.linked_metrics ?? {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="rounded-md border border-[var(--border)] p-4">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-slate-950">{report.title}</p>
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||||
|
{reportTypeLabel(report.report_type)} · {report.target_month ?? "-"}
|
||||||
|
{report.area_name ? ` · ${report.area_name}` : ""}
|
||||||
|
{report.neighborhood_name ? ` · ${report.neighborhood_name}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={report.status === "published" ? "success" : "muted"}>
|
||||||
|
{report.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{report.summary ? (
|
||||||
|
<p className="mt-3 text-sm text-[var(--muted-foreground)]">{report.summary}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{linkedMetricRows.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>关联指标</TableHead>
|
||||||
|
<TableHead>值</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{linkedMetricRows.map(([key, value]) => (
|
||||||
|
<TableRow key={key}>
|
||||||
|
<TableCell className="font-medium">{key}</TableCell>
|
||||||
|
<TableCell>{formatLinkedMetricValue(value)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<article className="max-h-[640px] overflow-auto whitespace-pre-wrap rounded-md border border-[var(--border)] bg-white p-5 font-mono text-sm leading-6 text-slate-900">
|
||||||
|
{report.content_markdown}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportTypeLabel(type: string) {
|
||||||
|
if (type === "monthly") {
|
||||||
|
return "月报";
|
||||||
|
}
|
||||||
|
if (type === "area_special") {
|
||||||
|
return "板块专题";
|
||||||
|
}
|
||||||
|
if (type === "neighborhood_memo") {
|
||||||
|
return "小区备忘录";
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReportTitle(type: CreateReport["report_type"]) {
|
||||||
|
if (type === "area_special") {
|
||||||
|
return `板块专题 ${MONTH}`;
|
||||||
|
}
|
||||||
|
if (type === "neighborhood_memo") {
|
||||||
|
return `小区备忘录 ${MONTH}`;
|
||||||
|
}
|
||||||
|
return `上海房市投资研究月报 ${MONTH}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReportMarkdown(type: CreateReport["report_type"]) {
|
||||||
|
if (type === "area_special") {
|
||||||
|
return `# 板块专题 ${MONTH}\n\n## 核心判断\n\n- \n\n## 数据证据\n\n- \n\n## 风险与跟踪\n\n- `;
|
||||||
|
}
|
||||||
|
if (type === "neighborhood_memo") {
|
||||||
|
return `# 小区备忘录 ${MONTH}\n\n## 资产画像\n\n- \n\n## 相似资产与价格\n\n- \n\n## 触发条件\n\n- `;
|
||||||
|
}
|
||||||
|
return `# 上海房市投资研究月报 ${MONTH}\n\n## 核心结论\n\n- \n\n## 板块变化\n\n- \n\n## 观察池动作\n\n- \n\n## 下月跟踪\n\n- `;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLinkedMetricValue(value: unknown) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
function WatchlistPanel({
|
function WatchlistPanel({
|
||||||
scores,
|
scores,
|
||||||
neighborhoodScores,
|
neighborhoodScores,
|
||||||
|
|||||||
@@ -468,6 +468,43 @@ export type ScenarioRunResponse = {
|
|||||||
variants: ScenarioVariant[];
|
variants: ScenarioVariant[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Report = {
|
||||||
|
report_id: number;
|
||||||
|
report_type: string;
|
||||||
|
title: string;
|
||||||
|
target_month: string | null;
|
||||||
|
area_id: string | null;
|
||||||
|
area_name: string | null;
|
||||||
|
neighborhood_id: string | null;
|
||||||
|
neighborhood_name: string | null;
|
||||||
|
summary: string;
|
||||||
|
content_markdown: string;
|
||||||
|
linked_metrics: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateReport = {
|
||||||
|
report_type: "monthly" | "area_special" | "neighborhood_memo";
|
||||||
|
title: string;
|
||||||
|
target_month?: string;
|
||||||
|
area_id?: string;
|
||||||
|
neighborhood_id?: string;
|
||||||
|
summary?: string;
|
||||||
|
content_markdown: string;
|
||||||
|
linked_metrics?: Record<string, unknown>;
|
||||||
|
status?: "draft" | "published" | "archived";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportFilters = {
|
||||||
|
report_type?: string;
|
||||||
|
target_month?: string;
|
||||||
|
area_id?: string;
|
||||||
|
neighborhood_id?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ComparisonMetricValue = {
|
export type ComparisonMetricValue = {
|
||||||
id: string;
|
id: string;
|
||||||
value: number | null;
|
value: number | null;
|
||||||
@@ -739,6 +776,28 @@ export async function createScenarioRun(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchReports(filters: ReportFilters = {}): Promise<Report[]> {
|
||||||
|
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/reports${query ? `?${query}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReport(id: number): Promise<Report> {
|
||||||
|
return fetchJson(`/api/v1/reports/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReport(payload: CreateReport): Promise<Report> {
|
||||||
|
return fetchJson("/api/v1/reports", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
|
|||||||
@@ -343,20 +343,23 @@
|
|||||||
|
|
||||||
### M4.1 报告中心
|
### M4.1 报告中心
|
||||||
|
|
||||||
|
状态:已完成。
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
|
|
||||||
- 将 Markdown 报告产品化。
|
- 将 Markdown 报告产品化。
|
||||||
|
|
||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
- 报告表。
|
- `app.reports` 报告表。
|
||||||
- 报告 API。
|
- `GET /api/v1/reports`、`GET /api/v1/reports/{report_id}`、`POST /api/v1/reports`。
|
||||||
- 前端报告中心。
|
- 前端报告中心,支持月报、板块专题、小区备忘录的草稿创建、列表过滤和 Markdown 查看。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
- 可查看月报、板块专题、小区备忘录。
|
- 可查看月报、板块专题、小区备忘录。
|
||||||
- 报告结论关联结构化指标。
|
- 报告结论关联结构化指标。
|
||||||
|
- 当前完成人工创建与查看,自动生成进入 M4.2。
|
||||||
|
|
||||||
### M4.2 自动周报/月报
|
### M4.2 自动周报/月报
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user