diff --git a/src/generated_scene/generator.rs b/src/generated_scene/generator.rs index 610f5b8..417e519 100644 --- a/src/generated_scene/generator.rs +++ b/src/generated_scene/generator.rs @@ -336,6 +336,9 @@ fn browser_script_skeleton(scene_id: &str, _analysis: &SceneSourceAnalysis) -> S fn browser_script(scene_id: &str, analysis: &SceneSourceAnalysis, scene_info: Option<&SceneInfoJson>) -> String { match scene_info { + Some(info) if !info.modes.is_empty() => { + browser_script_with_modes(scene_id, info) + } Some(info) if !info.api_endpoints.is_empty() || !info.column_defs.is_empty() => { browser_script_with_business_logic(scene_id, analysis, info) } @@ -542,6 +545,249 @@ if (typeof args !== 'undefined') {{ ) } +fn browser_script_with_modes(scene_id: &str, scene_info: &SceneInfoJson) -> String { + let modes_json = serde_json::to_string_pretty(&scene_info.modes).unwrap_or_else(|_| "[]".to_string()); + let default_mode = scene_info.default_mode.as_deref().unwrap_or("month"); + let mode_switch_field = scene_info.mode_switch_field.as_deref().unwrap_or("period_mode"); + + format!(r#"const REPORT_NAME = '{scene_id}'; +const MODES = {modes_json}; +const DEFAULT_MODE = '{default_mode}'; +const MODE_SWITCH_FIELD = '{mode_switch_field}'; + +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 detectMode(args) {{ + const modeValue = args[MODE_SWITCH_FIELD] || DEFAULT_MODE; + return MODES.find(m => m.condition.value === modeValue) || MODES[0]; +}} + +function buildModeRequest(args, mode) {{ + const endpoint = mode.apiEndpoint; + const template = mode.requestTemplate || {{}}; + const contentType = endpoint.contentType || 'application/json'; + const url = endpoint.url; + const method = endpoint.method || 'POST'; + + let body; + if (contentType === 'application/x-www-form-urlencoded') {{ + body = {{ ...template }}; + for (const [key, value] of Object.entries(body)) {{ + if (typeof value === 'string' && value.startsWith('${{') && value.endsWith('}}')) {{ + const expr = value.slice(2, -1); + try {{ + body[key] = eval(expr); + }} catch (e) {{ + body[key] = args.org_code; + }} + }} + }} + body.orgno = args.org_code; + }} else {{ + body = JSON.stringify({{ ...template, ...args }}); + }} + + return {{ url, method, headers: {{ 'Content-Type': contentType }}, body }}; +}} + +function normalizeModeRows(data, mode) {{ + const rules = mode.normalizeRules || {{ type: 'validate_all_columns', filterNull: true }}; + const columns = mode.columnDefs.map(([key]) => key); + + if (!Array.isArray(data)) return []; + + return data.map(row => {{ + const result = {{}}; + for (const key of columns) {{ + const v = row[key]; + result[key] = (v === null || v === undefined || v === '') ? '' : String(v).trim(); + }} + return result; + }}).filter(row => {{ + if (!rules.filterNull) return true; + if (rules.type === 'validate_required' && rules.requiredFields) {{ + return rules.requiredFields.every(f => row[f] !== ''); + }} + return columns.every(k => row[k] !== ''); + }}); +}} + +function determineArtifactStatus({{ blockedReason = '', fatalError = '', reasons = [], rows = [] }}) {{ + if (blockedReason) return 'blocked'; + if (fatalError) return 'error'; + if (reasons.length > 0) return 'partial'; + if (!rows.length) return 'empty'; + return 'ok'; +}} + +function buildArtifact({{ status, blockedReason = '', fatalError = '', reasons = [], rows = [], args, columnDefs, columns }}) {{ + return {{ + type: 'report-artifact', + report_name: REPORT_NAME, + status: status || determineArtifactStatus({{ blockedReason, fatalError, reasons, rows }}), + 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: columnDefs || [], + columns: columns || [], + rows, + counts: {{ detail_rows: rows.length }}, + partial_reasons: reasons.filter(r => r && !r.startsWith('api_') && !r.startsWith('validation_')), + reasons: Array.from(new Set(reasons.filter(Boolean))) + }}; +}} + +const defaultDeps = {{ + validatePageContext(args) {{ + const host = (globalThis.location?.hostname || '').trim(); + const expected = (args.expected_domain || '').trim(); + if (!host) return {{ ok: false, reason: 'page_context_unavailable' }}; + if (host !== expected) return {{ ok: false, reason: 'page_context_mismatch' }}; + return {{ ok: true }}; + }}, + + async queryModeData(args, mode) {{ + const endpoint = mode.apiEndpoint; + const request = buildModeRequest(args, mode); + const contentType = endpoint.contentType || 'application/json'; + + // Prefer jQuery + if (typeof $ !== 'undefined' && typeof $.ajax === 'function') {{ + return new Promise((resolve, reject) => {{ + $.ajax({{ + url: request.url, + type: request.method, + data: request.body, + contentType: contentType, + dataType: 'json', + success: resolve, + error: (xhr, status, err) => reject(new Error( + `API failed (${{xhr.status}}): ${{err}} | body=${{(xhr.responseText || '').substring(0, 200)}}` + )) + }}); + }}); + }} + + // Fallback: fetch + if (typeof fetch === 'function') {{ + const response = await fetch(request.url, {{ + method: request.method, + headers: request.headers, + body: request.method !== 'GET' ? request.body : undefined + }}); + if (!response.ok) {{ + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${{response.status}}: ${{text.substring(0, 200)}}`); + }} + return response.json(); + }} + + throw new Error('No HTTP client available (need jQuery or fetch)'); + }} +}}; + +async function buildBrowserEntrypointResult(args, deps = defaultDeps) {{ + // 1. Parameter validation + const validation = validateArgs(args); + if (!validation.valid) {{ + const mode = detectMode(args); + return buildArtifact({{ + status: 'blocked', + blockedReason: 'validation_failed', + reasons: validation.errors, + rows: [], + args, + columnDefs: mode.columnDefs, + columns: mode.columnDefs.map(([key]) => key) + }}); + }} + + // 2. Page context validation + const pageValidation = typeof deps.validatePageContext === 'function' + ? deps.validatePageContext(args) + : {{ ok: true }}; + if (!pageValidation?.ok) {{ + const mode = detectMode(args); + return buildArtifact({{ + status: 'blocked', + blockedReason: pageValidation?.reason || 'page_context_mismatch', + reasons: [pageValidation?.reason || 'page_context_mismatch'], + rows: [], + args, + columnDefs: mode.columnDefs, + columns: mode.columnDefs.map(([key]) => key) + }}); + }} + + // 3. Detect mode + const mode = detectMode(args); + + // 4. Data fetching + const reasons = []; + let rawData = null; + try {{ + rawData = await (deps.queryModeData ? deps.queryModeData(args, mode) : Promise.resolve([])); + }} catch (error) {{ + return buildArtifact({{ + status: 'error', + fatalError: error.message, + reasons: ['api_query_failed:' + error.message], + rows: [], + args, + columnDefs: mode.columnDefs, + columns: mode.columnDefs.map(([key]) => key) + }}); + }} + + // 5. Extract response data + const responsePath = mode.responsePath || ''; + let data = rawData; + if (responsePath && rawData) {{ + data = rawData[responsePath] || rawData; + }} + + // 6. Row normalization + const rows = normalizeModeRows(data, mode); + if (rows.length === 0 && Array.isArray(data) && data.length > 0) {{ + reasons.push('row_normalization_partial'); + }} + + // 7. Build artifact + return buildArtifact({{ + reasons, + rows, + args, + columnDefs: mode.columnDefs, + columns: mode.columnDefs.map(([key]) => key) + }}); +}} + +if (typeof module !== 'undefined') {{ + module.exports = {{ buildBrowserEntrypointResult, normalizePayload, validateArgs, detectMode, buildModeRequest, normalizeModeRows, buildArtifact, determineArtifactStatus, MODES, REPORT_NAME }}; +}} + +if (typeof args !== 'undefined') {{ + return buildBrowserEntrypointResult(args); +}} +"#, scene_id = scene_id, modes_json = modes_json, default_mode = default_mode, mode_switch_field = mode_switch_field) +} + 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",