feat(market-intel): add opportunity plan preview
All checks were successful
CD Pipeline / deploy (push) Successful in 1m2s

This commit is contained in:
OoO
2026-05-18 16:16:55 +08:00
parent 0fcc0ea265
commit 18f8038b01
8 changed files with 377 additions and 15 deletions

View File

@@ -522,6 +522,24 @@
</div>
</div>
<div class="market-intel-panel" data-market-intel-opportunity>
<div class="market-intel-preview-head">
<div>
<p class="market-intel-muted momo-mono mb-1">OPPORTUNITY / THREAT PLAN</p>
<h2 class="market-intel-preview-title">市場機會與威脅計畫</h2>
</div>
<button class="market-intel-icon-button" type="button" title="重新整理市場機會與威脅計畫" data-market-intel-opportunity-refresh>
<i class="fas fa-rotate-right" aria-hidden="true"></i>
</button>
</div>
<div class="market-intel-preview-meta" data-market-intel-opportunity-meta>
<span class="market-intel-pill">loading</span>
</div>
<div data-market-intel-opportunity-body>
<div class="market-intel-empty">讀取市場機會與威脅計畫中...</div>
</div>
</div>
<div class="market-intel-panel" data-market-intel-migration>
<div class="market-intel-preview-head">
<div>
@@ -593,10 +611,11 @@
const mcpFetchGateRoot = document.querySelector('[data-market-intel-mcp-fetch-gate]');
const schedulerRoot = document.querySelector('[data-market-intel-scheduler]');
const matchReviewRoot = document.querySelector('[data-market-intel-match-review]');
const opportunityRoot = document.querySelector('[data-market-intel-opportunity]');
const migrationRoot = document.querySelector('[data-market-intel-migration]');
const approvalRoot = document.querySelector('[data-market-intel-approval]');
const deployRoot = document.querySelector('[data-market-intel-deploy]');
if (!root && !writerRoot && !cliRoot && !dbProbeRoot && !seedDiffRoot && !legacyBridgeRoot && !mcpReadinessRoot && !mcpPreflightRoot && !mcpActivationRoot && !mcpFetchGateRoot && !schedulerRoot && !matchReviewRoot && !migrationRoot && !approvalRoot && !deployRoot) return;
if (!root && !writerRoot && !cliRoot && !dbProbeRoot && !seedDiffRoot && !legacyBridgeRoot && !mcpReadinessRoot && !mcpPreflightRoot && !mcpActivationRoot && !mcpFetchGateRoot && !schedulerRoot && !matchReviewRoot && !opportunityRoot && !migrationRoot && !approvalRoot && !deployRoot) return;
const meta = root ? root.querySelector('[data-market-intel-preview-meta]') : null;
const body = root ? root.querySelector('[data-market-intel-preview-body]') : null;
@@ -646,6 +665,10 @@
const matchReviewBody = matchReviewRoot ? matchReviewRoot.querySelector('[data-market-intel-match-review-body]') : null;
const matchReviewRefresh = matchReviewRoot ? matchReviewRoot.querySelector('[data-market-intel-match-review-refresh]') : null;
const matchReviewEndpoint = "{{ url_for('market_intel.market_intel_match_review_plan') }}";
const opportunityMeta = opportunityRoot ? opportunityRoot.querySelector('[data-market-intel-opportunity-meta]') : null;
const opportunityBody = opportunityRoot ? opportunityRoot.querySelector('[data-market-intel-opportunity-body]') : null;
const opportunityRefresh = opportunityRoot ? opportunityRoot.querySelector('[data-market-intel-opportunity-refresh]') : null;
const opportunityEndpoint = "{{ url_for('market_intel.market_intel_opportunity_plan') }}";
const migrationMeta = migrationRoot ? migrationRoot.querySelector('[data-market-intel-migration-meta]') : null;
const migrationBody = migrationRoot ? migrationRoot.querySelector('[data-market-intel-migration-body]') : null;
const migrationRefresh = migrationRoot ? migrationRoot.querySelector('[data-market-intel-migration-refresh]') : null;
@@ -1549,6 +1572,108 @@
}
};
const renderOpportunityMeta = data => {
opportunityMeta.innerHTML = [
`mode=${data.mode || 'unknown'}`,
`ready=${data.ready_for_opportunity_queue ? 'yes' : 'no'}`,
`rules=${data.rule_count || 0}`,
`alerts=${data.threat_alert_dispatched ? 'sent' : 'blocked'}`,
`ai=${data.ai_summary_generated ? 'generated' : 'blocked'}`
].map(item => `<span class="market-intel-pill">${escapeHtml(item)}</span>`).join('');
};
const renderOpportunityBody = data => {
const blockers = (data.blocked_reasons || []).join(' / ');
const checks = Object.entries(data.gate_checks || {});
const rules = data.rules || [];
const sequence = data.operator_sequence || [];
const severity = Object.entries(data.severity_policy || {});
const renderCheck = ([name, passed]) => `
<div class="market-intel-check">
<div>
<strong>${escapeHtml(name)}</strong>
</div>
<span>${passed ? 'PASS' : 'BLOCK'}</span>
</div>
`;
const renderRule = item => `
<article class="market-intel-operation">
<strong>${escapeHtml(item.key)}</strong>
<small>${escapeHtml(item.label)} / severity=${escapeHtml(item.severity)}</small>
<small>${escapeHtml((item.minimum_evidence || []).join(' + '))}</small>
<small>${escapeHtml(item.action_hint)}</small>
</article>
`;
opportunityBody.innerHTML = `
<div class="market-intel-empty mb-3">目前只建立機會與威脅判讀規則;不建立 queue、不派送 Telegram、不產生 AI 摘要、不寫 DB。${blockers ? `阻擋:${escapeHtml(blockers)}` : ''}</div>
<div class="market-intel-deploy-grid">
<div data-market-intel-opportunity-checks>
<p class="market-intel-deploy-section-title">OPPORTUNITY GATES</p>
<div class="market-intel-check-list">${
checks.length
? checks.map(renderCheck).join('')
: '<div class="market-intel-empty">尚未提供機會判讀 gate。</div>'
}</div>
</div>
<div data-market-intel-opportunity-rules>
<p class="market-intel-deploy-section-title">RULES</p>
<div class="market-intel-operation-list">${
rules.length
? rules.map(renderRule).join('')
: '<div class="market-intel-empty">尚未提供規則。</div>'
}</div>
</div>
<div data-market-intel-opportunity-severity>
<p class="market-intel-deploy-section-title">SEVERITY POLICY</p>
<div class="market-intel-check-list">${
severity.length
? severity.map(([key, label]) => `
<div class="market-intel-check">
<div>
<strong>${escapeHtml(key)}</strong>
<small>${escapeHtml(label)}</small>
</div>
<span>POLICY</span>
</div>
`).join('')
: '<div class="market-intel-empty">尚未提供分級策略。</div>'
}</div>
</div>
<div data-market-intel-opportunity-sequence>
<p class="market-intel-deploy-section-title">OPERATOR SEQUENCE</p>
<div class="market-intel-check-list">${
sequence.length
? sequence.map((item, index) => `
<div class="market-intel-check">
<div>
<strong>${escapeHtml(`step_${index + 1}`)}</strong>
<small>${escapeHtml(item)}</small>
</div>
<span>REQUIRED</span>
</div>
`).join('')
: '<div class="market-intel-empty">尚未提供操作順序。</div>'
}</div>
</div>
</div>
`;
};
const loadOpportunity = async () => {
if (!opportunityMeta || !opportunityBody) return;
opportunityBody.innerHTML = '<div class="market-intel-empty">讀取市場機會與威脅計畫中...</div>';
try {
const response = await fetch(opportunityEndpoint, { credentials: 'same-origin' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
renderOpportunityMeta(data);
renderOpportunityBody(data);
} catch (error) {
opportunityMeta.innerHTML = '<span class="market-intel-pill">error</span>';
opportunityBody.innerHTML = `<div class="market-intel-empty">市場機會與威脅計畫讀取失敗:${escapeHtml(error.message)}</div>`;
}
};
const renderMigrationMeta = data => {
const seedWriter = data.command_plan && data.command_plan.seed_writer_command
? data.command_plan.seed_writer_command
@@ -1819,6 +1944,9 @@
if (matchReviewRefresh) {
matchReviewRefresh.addEventListener('click', loadMatchReview);
}
if (opportunityRefresh) {
opportunityRefresh.addEventListener('click', loadOpportunity);
}
if (migrationRefresh) {
migrationRefresh.addEventListener('click', loadMigration);
}
@@ -1840,6 +1968,7 @@
loadMcpFetchGate();
loadScheduler();
loadMatchReview();
loadOpportunity();
loadMigration();
loadApproval();
loadDeploy();