diff --git a/src/bin/sg_scene_generate.rs b/src/bin/sg_scene_generate.rs index b364a4e..85b9abc 100644 --- a/src/bin/sg_scene_generate.rs +++ b/src/bin/sg_scene_generate.rs @@ -2,7 +2,7 @@ use std::env; use std::path::PathBuf; use sgclaw::generated_scene::analyzer::SceneKind; -use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest}; +use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest, SceneInfoJson}; fn main() { if let Err(err) = run() { @@ -13,6 +13,10 @@ fn main() { fn run() -> Result<(), String> { let args = parse_args(env::args().skip(1))?; + let scene_info: Option = args.scene_info_json + .map(|json| serde_json::from_str(&json)) + .transpose() + .map_err(|e| format!("Invalid scene-info-json: {}", e))?; let skill_root = generate_scene_package(GenerateSceneRequest { source_dir: args.source_dir, scene_id: args.scene_id, @@ -21,6 +25,7 @@ fn run() -> Result<(), String> { target_url: args.target_url, output_root: args.output_root, lessons_path: args.lessons_path, + scene_info_json: scene_info, }) .map_err(|err| err.to_string())?; @@ -35,7 +40,8 @@ struct CliArgs { scene_kind: Option, target_url: Option, output_root: PathBuf, - lessons_path: PathBuf, + lessons_path: Option, + scene_info_json: Option, } fn parse_args(args: impl Iterator) -> Result { @@ -46,6 +52,7 @@ fn parse_args(args: impl Iterator) -> Result { let mut target_url = None; let mut output_root = None; let mut lessons_path = None; + let mut scene_info_json = None; let mut pending_flag: Option = None; for arg in args { @@ -63,6 +70,7 @@ fn parse_args(args: impl Iterator) -> Result { "--target-url" => target_url = Some(arg), "--output-root" => output_root = Some(PathBuf::from(arg)), "--lessons" => lessons_path = Some(PathBuf::from(arg)), + "--scene-info-json" => scene_info_json = Some(arg), _ => return Err(format!("unsupported argument {flag}")), } continue; @@ -70,7 +78,7 @@ fn parse_args(args: impl Iterator) -> Result { match arg.as_str() { "--source-dir" | "--scene-id" | "--scene-name" | "--scene-kind" | "--target-url" - | "--output-root" | "--lessons" => { + | "--output-root" | "--lessons" | "--scene-info-json" => { pending_flag = Some(arg); } "--help" | "-h" => return Err(usage()), @@ -89,10 +97,11 @@ fn parse_args(args: impl Iterator) -> Result { scene_kind, target_url, output_root: output_root.ok_or_else(usage)?, - lessons_path: lessons_path.ok_or_else(usage)?, + lessons_path, + scene_info_json, }) } fn usage() -> String { - "usage: sg_scene_generate --source-dir --scene-id --scene-name [--scene-kind ] [--target-url ] --output-root --lessons ".to_string() + "usage: sg_scene_generate --source-dir --scene-id --scene-name [--scene-kind ] [--target-url ] --output-root [--lessons ] [--scene-info-json '']".to_string() } diff --git a/src/generated_scene/generator.rs b/src/generated_scene/generator.rs index 391ab62..ca2d1a1 100644 --- a/src/generated_scene/generator.rs +++ b/src/generated_scene/generator.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; @@ -5,7 +6,53 @@ use std::path::{Path, PathBuf}; use crate::generated_scene::analyzer::{ analyze_scene_source_with_hint, AnalyzeSceneError, SceneKind, SceneSourceAnalysis, }; -use crate::generated_scene::lessons::load_generation_lessons; +use crate::generated_scene::lessons::{ + load_generation_lessons, GenerationLessons, BUILTIN_REPORT_COLLECTION_LESSONS, +}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ApiEndpointJson { + pub name: String, + pub url: String, + #[serde(default)] + pub method: String, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BusinessLogicJson { + #[serde(default)] + pub data_fetch: Option, + #[serde(default)] + pub data_transform: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SceneInfoJson { + #[serde(rename = "sceneId")] + pub scene_id: String, + #[serde(rename = "sceneName")] + pub scene_name: String, + #[serde(rename = "sceneKind", default)] + pub scene_kind: String, + #[serde(rename = "sourceSystem", default)] + pub source_system: Option, + #[serde(rename = "expectedDomain", default)] + pub expected_domain: Option, + #[serde(rename = "targetUrl", default)] + pub target_url: Option, + #[serde(rename = "apiEndpoints", default)] + pub api_endpoints: Vec, + #[serde(rename = "staticParams", default)] + pub static_params: HashMap, + #[serde(rename = "columnDefs", default)] + pub column_defs: Vec<(String, String)>, + #[serde(rename = "entryMethod", default)] + pub entry_method: Option, + #[serde(rename = "businessLogic", default)] + pub business_logic: Option, +} #[derive(Debug, Clone)] pub struct GenerateSceneRequest { @@ -15,7 +62,8 @@ pub struct GenerateSceneRequest { pub scene_kind: Option, pub target_url: Option, pub output_root: PathBuf, - pub lessons_path: PathBuf, + pub lessons_path: Option, + pub scene_info_json: Option, } #[derive(Debug)] @@ -49,8 +97,16 @@ pub fn generate_scene_package( request: GenerateSceneRequest, ) -> Result { let analysis = analyze_scene_source_with_hint(&request.source_dir, request.scene_kind.clone())?; - let lessons = - load_generation_lessons(&request.lessons_path).map_err(GenerateSceneError::new)?; + let (lessons, lessons_source): (GenerationLessons, String) = match &request.lessons_path { + Some(path) => ( + load_generation_lessons(path).map_err(GenerateSceneError::new)?, + path.display().to_string(), + ), + None => ( + GenerationLessons::default_report_collection(), + BUILTIN_REPORT_COLLECTION_LESSONS.to_string(), + ), + }; if !lessons.routing.require_exact_suffix || !lessons.bootstrap.require_expected_domain || !lessons.bootstrap.require_target_url @@ -86,7 +142,7 @@ pub fn generate_scene_package( )?; write_file( &scripts_dir.join(format!("{tool_name}.js")), - &browser_script(&request.scene_id, &analysis), + &browser_script(&request.scene_id, &analysis, request.scene_info_json.as_ref()), )?; write_file( &scripts_dir.join(format!("{tool_name}.test.js")), @@ -97,7 +153,7 @@ pub fn generate_scene_package( &format!( "# Generation Lessons\n\nGenerated from `{}` with lessons `{}`.\n\nThis package is limited to report/collection browser_script scenes.\n", request.source_dir.display(), - request.lessons_path.display() + lessons_source ), )?; @@ -204,13 +260,166 @@ fn default_org_dictionary() -> &'static str { "# } -fn browser_script(scene_id: &str, _analysis: &SceneSourceAnalysis) -> String { +fn browser_script_skeleton(scene_id: &str, _analysis: &SceneSourceAnalysis) -> String { format!( "function normalizePayload(payload) {{\n if (typeof payload === 'string') {{\n try {{ return JSON.parse(payload); }} catch (_) {{ return {{}}; }}\n }}\n return payload && typeof payload === 'object' ? payload : {{}};\n}}\n\nasync function buildBrowserEntrypointResult(args, deps = {{}}) {{\n const rows = typeof deps.collectRows === 'function'\n ? await deps.collectRows(args)\n : [{{\n org_label: args.org_label || '',\n org_code: args.org_code || '',\n period_mode: args.period_mode || '',\n period_value: args.period_value || '',\n value: ''\n }}];\n return {{\n type: 'report-artifact',\n report_name: '{}',\n status: rows.length > 0 ? 'ok' : 'empty',\n period: {{\n mode: args.period_mode,\n mode_code: args.period_mode_code,\n value: args.period_value,\n payload: normalizePayload(args.period_payload)\n }},\n org: {{ label: args.org_label, code: args.org_code }},\n column_defs: [\n ['org_label', '供电单位'],\n ['org_code', '供电单位编码'],\n ['period_mode', '统计周期类型'],\n ['period_value', '统计周期'],\n ['value', '采集值']\n ],\n columns: ['org_label', 'org_code', 'period_mode', 'period_value', 'value'],\n rows,\n counts: {{ detail_rows: rows.length }},\n partial_reasons: [],\n reasons: []\n }};\n}}\n\nif (typeof module !== 'undefined') {{\n module.exports = {{ buildBrowserEntrypointResult, normalizePayload }};\n}}\n\nif (typeof args !== 'undefined') {{\n return buildBrowserEntrypointResult(args);\n}}\n", scene_id ) } +fn browser_script(scene_id: &str, analysis: &SceneSourceAnalysis, scene_info: Option<&SceneInfoJson>) -> String { + match scene_info { + Some(info) if !info.api_endpoints.is_empty() || !info.column_defs.is_empty() => { + browser_script_with_business_logic(scene_id, analysis, info) + } + _ => browser_script_skeleton(scene_id, analysis), + } +} + +fn browser_script_with_business_logic(scene_id: &str, _analysis: &SceneSourceAnalysis, scene_info: &SceneInfoJson) -> String { + let api_endpoints_json = serde_json::to_string_pretty(&scene_info.api_endpoints).unwrap_or_else(|_| "[]".to_string()); + let static_params_json = serde_json::to_string_pretty(&scene_info.static_params).unwrap_or_else(|_| "{}".to_string()); + let column_defs_json = serde_json::to_string_pretty(&scene_info.column_defs).unwrap_or_else(|_| "[]".to_string()); + + let column_names: Vec<&str> = scene_info.column_defs.iter().map(|(name, _)| name.as_str()).collect(); + let columns_json = serde_json::to_string(&column_names).unwrap_or_else(|_| "[]".to_string()); + + format!( + r#"const API_ENDPOINTS = {api_endpoints_json}; + +const STATIC_PARAMS = {static_params_json}; + +const COLUMN_DEFS = {column_defs_json}; + +function normalizePayload(payload) {{ + if (typeof payload === 'string') {{ + try {{ return JSON.parse(payload); }} catch (_) {{ return {{}}; }} + }} + return payload && typeof payload === 'object' ? payload : {{}}; +}} + +function validateArgs(args) {{ + const errors = []; + if (!args.org_code) errors.push('Missing org_code'); + if (!args.period_value) errors.push('Missing period_value'); + return {{ valid: errors.length === 0, errors }}; +}} + +function buildRequest(args, endpoint) {{ + const url = new URL(endpoint.url, window.location.origin); + const params = {{ ...STATIC_PARAMS, ...args }}; + for (const [key, value] of Object.entries(params)) {{ + if (value !== undefined && value !== null) {{ + url.searchParams.set(key, String(value)); + }} + }} + return {{ + url: url.toString(), + method: endpoint.method || 'GET', + headers: {{ 'Content-Type': 'application/json' }} + }}; +}} + +function normalizeRows(rawData) {{ + if (!Array.isArray(rawData)) return []; + return rawData.map((row, index) => {{ + const result = {{ _index: index }}; + for (const [key, label] of COLUMN_DEFS) {{ + result[key] = row[key] ?? ''; + }} + return result; + }}); +}} + +function buildArtifact(args, rows) {{ + return {{ + type: 'report-artifact', + report_name: '{scene_id}', + status: rows.length > 0 ? 'ok' : 'empty', + period: {{ + mode: args.period_mode, + mode_code: args.period_mode_code, + value: args.period_value, + payload: normalizePayload(args.period_payload) + }}, + org: {{ label: args.org_label, code: args.org_code }}, + column_defs: COLUMN_DEFS, + columns: {columns_json}, + rows, + counts: {{ detail_rows: rows.length }}, + partial_reasons: [], + reasons: [] + }}; +}} + +const defaultDeps = {{ + validatePageContext: async () => true, + queryData: async (args) => {{ + const endpoint = API_ENDPOINTS[0]; + if (!endpoint) throw new Error('No API endpoint configured'); + const request = buildRequest(args, endpoint); + const response = await fetch(request.url, {{ + method: request.method, + headers: request.headers + }}); + if (!response.ok) throw new Error(`HTTP ${{response.status}}: ${{response.statusText}}`); + return response.json(); + }} +}}; + +async function buildBrowserEntrypointResult(args, deps = defaultDeps) {{ + const validation = validateArgs(args); + if (!validation.valid) {{ + return {{ + type: 'report-artifact', + report_name: '{scene_id}', + status: 'error', + error: 'Validation failed: ' + validation.errors.join(', '), + column_defs: COLUMN_DEFS, + columns: {columns_json}, + rows: [], + counts: {{ detail_rows: 0 }}, + partial_reasons: [], + reasons: validation.errors + }}; + }} + + try {{ + const rawData = await (deps.queryData ? deps.queryData(args) : Promise.resolve([])); + const rows = normalizeRows(rawData); + return buildArtifact(args, rows); + }} catch (error) {{ + return {{ + type: 'report-artifact', + report_name: '{scene_id}', + status: 'error', + error: error.message, + column_defs: COLUMN_DEFS, + columns: {columns_json}, + rows: [], + counts: {{ detail_rows: 0 }}, + partial_reasons: [], + reasons: [error.message] + }}; + }} +}} + +if (typeof module !== 'undefined') {{ + module.exports = {{ buildBrowserEntrypointResult, normalizePayload, validateArgs, buildRequest, normalizeRows, buildArtifact, API_ENDPOINTS, STATIC_PARAMS, COLUMN_DEFS }}; +}} + +if (typeof args !== 'undefined') {{ + return buildBrowserEntrypointResult(args); +}} +"#, + scene_id = scene_id, + api_endpoints_json = api_endpoints_json, + static_params_json = static_params_json, + column_defs_json = column_defs_json, + columns_json = columns_json + ) +} + fn browser_script_test(tool_name: &str, _analysis: &SceneSourceAnalysis) -> String { format!( "const assert = require('assert');\nconst {{ buildBrowserEntrypointResult }} = require('./{}.js');\n\n(async () => {{\n const artifact = await buildBrowserEntrypointResult({{\n org_label: '国网兰州供电公司',\n org_code: '62401',\n period_mode: 'month',\n period_mode_code: '1',\n period_value: '2026-03',\n period_payload: '{{\"fdate\":\"2026-03\"}}'\n }});\n assert.equal(artifact.type, 'report-artifact');\n assert.ok(Array.isArray(artifact.column_defs));\n assert.equal(artifact.rows.length, 1);\n}})().catch((err) => {{\n console.error(err);\n process.exit(1);\n}});\n",