From 9be44d27b1fa726732586f593649809b2cf5f33f Mon Sep 17 00:00:00 2001 From: OoO Date: Fri, 5 Jun 2026 15:48:38 +0800 Subject: [PATCH] =?UTF-8?q?V10.597=20=E5=85=A8=E7=AB=99=E9=9F=BF=E6=87=89?= =?UTF-8?q?=E5=BC=8F=E5=B7=A1=E6=AA=A2=E8=88=87=E8=BC=89=E5=85=A5=E5=84=AA?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 2 +- config.py | 2 +- routes/admin_observability_routes.py | 21 +- scripts/check_responsive_overflow.js | 82 +- templates/market_intel/disabled.html | 16129 +------------------------ tests/test_frontend_v2_assets.py | 31 + web/static/css/page-dashboard-v2.css | 17 +- web/static/js/ewoooc-base.js | 70 +- 8 files changed, 408 insertions(+), 15946 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 26dce5c..efa1d74 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,7 +4,7 @@ ================================================================================ 【已完成】 - - V10.594 重整 PChome 比價覆核工作台 UX:覆核頁不再沿用首頁商品表格,也不再把 `matcher_rescore`、`stored_status`、`rescore_accepted_current`、`HITL`、`COMPLETE` 等內部診斷/狀態碼輸出到前台或 tooltip;改為「商品 / MOMO、PChome 候選、覆核判讀、下一步、紀錄」六欄工作流。同步修正 catalog review status 的前台語義、決策信封中文標籤、局部 1540px 橫向工作台與手機版欄位 label,並補 `test_frontend_v2_assets.py` guard 防止 raw diagnostics 回流。 + - V10.597 重整 PChome 比價覆核工作台 UX 並補全站巡檢能力:覆核頁不再沿用首頁商品表格,也不再把 `matcher_rescore`、`stored_status`、`rescore_accepted_current`、`HITL`、`COMPLETE` 等內部診斷/狀態碼輸出到前台或 tooltip;改為「商品 / MOMO、PChome 候選、覆核判讀、下一步、紀錄」六欄工作流。同步修正 catalog review status 的前台語義、決策信封中文標籤、局部 1540px 橫向工作台、手機版欄位 label,並把覆核狀態分段列改為自適應 grid,避免 chip 造成桌面/平板/手機視覺溢出;`check_responsive_overflow.js` 改為逐頁輸出、HTTPS context、commit+body ready、timeout 後安全收尾,讓桌面/平板/手機全站 UX 巡檢可追蹤;topbar AI 觀測台 indicator 增加前端 60 秒 session cache / 2.5 秒 abort 與後端 30 秒 cache,避免每頁跳轉重複打 DB 查詢拖慢全站;`market_intel/disabled.html` 從 1MB 大型停用頁改為輕量狀態頁,保留狀態與正式操作入口,避免停用模組拖慢巡檢與使用者操作。 - V10.584 補 PChome Nick 去重與 stale recovery 單品窄門:`Nick` 先去 HTML / 行銷星號 / 重複品名,避免 `29g`、`100ml` 被同一商品副標重複計數成 `component_count_conflict`;同步新增 NIVEA 妮維雅霜 100ml、Schick 舒綺敏感肌除毛刀片 3 入、TS6 沁涼潔淨慕斯 100g 的具名 exact total-price alignment。IBL 沐浴精+洗髮精 vs 洗髮精仍保留 identity review,唇釉色號/目錄款與 Paula's Choice 效期/金蓋差異仍不自動寫正式價差。 - V10.583 補 Paula's Choice 身體乳 PChome Nick 具名 alignment:`2%水楊酸身體乳210ml二入` 可和 PChome `Nick` 補出的 `水楊酸身體乳雙入組 / 210ml x2` 對齊,進 `exact / total_price / price_alert_exact`;但 `118ml二入組(金蓋限定版)` 對上 PChome 效期品仍保留 `manual_review / identity_review`,不泛用放寬中文入數。 - V10.582 補 PChome 比價通知專業分級與 Nick 副標身份證據:NemoTron 價格決策信封現在保留 `momo_price`、`competitor_price`、`candidate_gap_pct` 與 `sales_7d_delta_pct`,EventRouter / Telegram 模板會把 `match_type / price_basis / alert_tier` 翻成「直接價格威脅、單位價覆核、身份覆核、壓制告警」與操作邊界;PChome crawler 會保留 `Nick` 副標為 `match_name` 給 matcher 使用,UI/DB 顯示仍維持原品名,讓容量、入數、濃度資訊可參與比對。 diff --git a/config.py b/config.py index b5c9e11..c20598e 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.594" +SYSTEM_VERSION = "V10.597" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py index 0d4a3b4..fa15210 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -19,6 +19,7 @@ Operation Ollama-First v5.0 / Phase 27 — Admin Observability Dashboard import logging import threading +import time from datetime import datetime, timedelta from flask import Blueprint, render_template, request, jsonify, send_file, url_for from sqlalchemy import text as sa_text @@ -37,6 +38,12 @@ admin_observability_bp = Blueprint( _PPT_AIDER_HEAL_LOCK = threading.Lock() _PPT_AIDER_HEAL_ACTIVE = {} +_HEALTH_INDICATOR_CACHE_LOCK = threading.Lock() +_HEALTH_INDICATOR_CACHE = { + 'expires_at': 0.0, + 'payload': None, +} +_HEALTH_INDICATOR_CACHE_TTL_SECONDS = 30 _GEMINI_BACKUP_CALLER_DISPLAY = { @@ -2169,6 +2176,12 @@ def health_indicator_api(): - 預算 ≥ 90% """ try: + now_ts = time.time() + with _HEALTH_INDICATOR_CACHE_LOCK: + cached_payload = _HEALTH_INDICATOR_CACHE.get('payload') + if cached_payload and now_ts < float(_HEALTH_INDICATOR_CACHE.get('expires_at') or 0): + return jsonify(dict(cached_payload)) + session = get_session() try: # 三主機最新狀態 @@ -2247,7 +2260,7 @@ def health_indicator_api(): + (1 if error_rate >= 30 else 0) + (1 if budget_alert else 0) ) - return jsonify({ + payload = { 'ok': True, 'alert_count': alert_count, 'host_unhealthy': host_unhealthy, @@ -2255,7 +2268,11 @@ def health_indicator_api(): 'error_rate_high': error_rate >= 30, 'budget_alert': budget_alert, 'tooltip': _build_indicator_tooltip(host_unhealthy, ep_pending, error_rate, budget_alert), - }) + } + with _HEALTH_INDICATOR_CACHE_LOCK: + _HEALTH_INDICATOR_CACHE['payload'] = dict(payload) + _HEALTH_INDICATOR_CACHE['expires_at'] = time.time() + _HEALTH_INDICATOR_CACHE_TTL_SECONDS + return jsonify(payload) finally: session.close() except Exception as e: diff --git a/scripts/check_responsive_overflow.js b/scripts/check_responsive_overflow.js index cc86d2c..d84e3da 100755 --- a/scripts/check_responsive_overflow.js +++ b/scripts/check_responsive_overflow.js @@ -105,6 +105,7 @@ function parseArgs(argv) { routes: [], viewports: DEFAULT_VIEWPORTS, timeoutMs: 30000, + waitUntil: 'commit', settleMs: 350, maxOverflow: 1, screenshotDir: '', @@ -128,6 +129,8 @@ function parseArgs(argv) { options.viewports = []; } else if (arg === '--timeout') { options.timeoutMs = parseInt(argv[++i], 10) * 1000; + } else if (arg === '--wait-until') { + options.waitUntil = argv[++i]; } else if (arg === '--settle-ms') { options.settleMs = parseInt(argv[++i], 10); } else if (arg === '--max-overflow') { @@ -165,6 +168,7 @@ Options: --viewport name=WxH Add a viewport --clear-default-viewports Use only custom --viewport entries --timeout SEC Navigation timeout, default 30 + --wait-until EVENT Playwright navigation event, default commit --settle-ms MS Fixed post-DOM layout settle wait, default 350 --max-overflow PX Allowed body overflow, default 1 --screenshot-dir DIR Save failure screenshots @@ -215,6 +219,32 @@ function safeName(input) { return input.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'root'; } +async function createViewportPage(context, viewport) { + const page = await context.newPage(); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + return page; +} + +async function closePage(page) { + if (!page) { + return; + } + await Promise.race([ + page.close().catch(() => {}), + new Promise((resolve) => setTimeout(resolve, 1000)), + ]); +} + +async function closeWithTimeout(target, timeoutMs = 1500) { + if (!target || typeof target.close !== 'function') { + return; + } + await Promise.race([ + target.close().catch(() => {}), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); +} + async function collectMetrics(page, maxOverflow) { return page.evaluate( ({ localScrollSelectors, maxOverflowPx }) => { @@ -269,6 +299,22 @@ async function collectMetrics(page, maxOverflow) { ); } +function formatResult(result) { + const status = result.passed ? 'PASS' : 'FAIL'; + const overflow = result.metrics ? `${result.metrics.overflow}px` : 'n/a'; + const localScroll = result.metrics ? result.metrics.localScroll.length : 0; + return `${status} ${result.viewport} ${result.route} overflow=${overflow} local_scroll=${localScroll}${result.error ? ` error=${result.error}` : ''}`; +} + +function printResult(result) { + console.log(formatResult(result)); + if (!result.passed && result.metrics && result.metrics.offenders.length) { + for (const offender of result.metrics.offenders) { + console.log(` offender ${offender.tag}.${offender.className} right=${offender.right} text="${offender.text}"`); + } + } +} + async function main() { const options = parseArgs(process.argv.slice(2)); const { chromium } = requirePlaywright(); @@ -283,15 +329,13 @@ async function main() { } const browser = await chromium.launch(launchOptions); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); const results = []; const pages = new Map(); try { for (const viewport of options.viewports) { - const page = await browser.newPage({ - viewport: { width: viewport.width, height: viewport.height }, - deviceScaleFactor: 1, - }); + const page = await createViewportPage(context, viewport); pages.set(viewport.name, page); } @@ -302,9 +346,11 @@ async function main() { let status = 0; let error = ''; let metrics = null; + let resetPage = false; try { - const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: options.timeoutMs }); + const response = await page.goto(url, { waitUntil: options.waitUntil, timeout: options.timeoutMs }); + await page.waitForSelector('body', { timeout: Math.min(options.timeoutMs, 5000) }).catch(() => {}); await page.evaluate(() => document.fonts && document.fonts.ready).catch(() => {}); if (options.settleMs > 0) { await page.waitForTimeout(options.settleMs); @@ -322,37 +368,35 @@ async function main() { } } catch (err) { error = err.message || String(err); + resetPage = true; } const passed = !error; const result = { route, viewport: viewport.name, status, passed, error, metrics }; results.push(result); + if (!options.json) { + printResult(result); + } if ((options.screenshotAll || !passed) && options.screenshotDir) { const file = `${safeName(route)}_${safeName(viewport.name)}.png`; await page.screenshot({ path: path.join(options.screenshotDir, file), fullPage: false }); } + + if (resetPage) { + await closePage(page); + pages.set(viewport.name, await createViewportPage(context, viewport)); + } } } } finally { - await Promise.all(Array.from(pages.values()).map((page) => page.close().catch(() => {}))); - await browser.close(); + await Promise.all(Array.from(pages.values()).map((page) => closePage(page))); + await closeWithTimeout(context); + await closeWithTimeout(browser); } if (options.json) { console.log(JSON.stringify(results, null, 2)); - } else { - for (const result of results) { - const status = result.passed ? 'PASS' : 'FAIL'; - const overflow = result.metrics ? `${result.metrics.overflow}px` : 'n/a'; - const localScroll = result.metrics ? result.metrics.localScroll.length : 0; - console.log(`${status} ${result.viewport} ${result.route} overflow=${overflow} local_scroll=${localScroll}${result.error ? ` error=${result.error}` : ''}`); - if (!result.passed && result.metrics && result.metrics.offenders.length) { - for (const offender of result.metrics.offenders) { - console.log(` offender ${offender.tag}.${offender.className} right=${offender.right} text="${offender.text}"`); - } - } - } } const failed = results.filter((result) => !result.passed); diff --git a/templates/market_intel/disabled.html b/templates/market_intel/disabled.html index 2f16777..982237d 100644 --- a/templates/market_intel/disabled.html +++ b/templates/market_intel/disabled.html @@ -4,15922 +4,243 @@ {% block extra_css %} {% endblock %} {% block ewooo_content %} -
-
-

MARKET INTEL / {{ status.phase }}

-

市場情報尚未啟用

-

目前只載入安全骨架;爬蟲、正式寫入與排程都尚未掛載。

- -
-
- 模組開關 - {{ 'ON' if status.enabled else 'OFF' }} -
-
- 爬蟲開關 - {{ 'ON' if status.crawler_enabled else 'OFF' }} -
-
- 資料寫入 - {{ 'ON' if status.write_enabled else 'OFF' }} -
-
- 排程掛載 - {{ 'ON' if status.scheduler_attached else 'OFF' }} -
-
- DB 寫入許可 - {{ 'ON' if status.database_write_allowed else 'OFF' }} -
-
- 已註冊 Adapter - {{ adapter_count|default(0) }} -
-
- 手動 Fetch - {{ 'ON' if manual_fetch_allowed|default(false) else 'OFF' }} -
-
+
+
+
+

MARKET INTEL / {{ current_section|default('overview') }}

+

市場情報模組待啟用

+

+ 這個模組目前保留為競品情報擴充入口,正式操作先回到 PChome 比價工作台、PChome 爬蟲與 AI 觀測台,避免停用中的試驗流程混入日常決策。 +

+
{{ system_version|default('') }}
+
-
-
-
-

CANDIDATE PREVIEW / SAFE

-

候選預覽

-
- -
-
- loading -
-
-
讀取候選預覽中...
-
+
+

Runtime Status

+

目前狀態

+
+
+ 模組 + {{ 'ON' if status.enabled else 'OFF' }} +
+
+ 爬蟲 + {{ 'ON' if status.crawler_enabled else 'OFF' }} +
+
+ 寫入 + {{ 'ON' if status.write_enabled else 'OFF' }} +
+
+ 排程 + {{ 'ON' if status.scheduler_attached else 'OFF' }} +
+
+ Adapter + {{ adapter_count|default(0) }} +
+
+ 手動 Fetch + {{ 'ON' if manual_fetch_allowed|default(false) else 'OFF' }} +
+
-
-
-
-

SEED WRITER / DRY RUN

-

平台種子寫入預覽

-
- -
-
- loading -
-
-
讀取寫入預覽中...
-
+
+

Decision Flow

+

目前應使用的操作入口

+
+
+

PChome 比價覆核

+

處理候選同款、單位價、既有保護與低信心候選,這是目前正式決策主入口。

+
+
+

PChome 爬蟲

+

檢查搜尋、候選取得與資料新鮮度,避免市場情報模組重複做同一件事。

+
+
+

AI 觀測台

+

監看 AI 呼叫、主機健康、RAG 命中與 PPT 產線,作為跨模組營運狀態來源。

+
- -
-
-
-

SEED CLI / TRANSACTION PREVIEW

-

Seed CLI 交易預覽

-
- -
-
- loading -
-
-
讀取交易預覽中...
-
-
- -
-
-
-

DB SCHEMA / READ ONLY PROBE

-

正式 DB Schema 探針

-
- -
-
- loading -
-
-
讀取 DB 探針中...
-
-
- -
-
-
-

PLATFORM SEED / READ ONLY DIFF

-

平台 Seed DB 差異探針

-
- -
-
- loading -
-
-
讀取 Seed 差異探針中...
-
-
- -
-
-
-

LEGACY SOURCE / BRIDGE PREVIEW

-

既有資料橋接預覽

-
- -
-
- loading -
-
-
讀取既有資料橋接預覽中...
-
-
- -
-
-
-

MCP / READINESS PREVIEW

-

MCP 整合就緒度

-
- -
-
- loading -
-
-
讀取 MCP 整合就緒度中...
-
-
- -
-
-
-

MCP / EXTERNAL DEPLOY PREFLIGHT

-

外部 MCP 部署預檢

-
- -
-
- loading -
-
-
讀取 MCP 部署預檢中...
-
-
- -
-
-
-

MCP / ACTIVATION RUNBOOK

-

MCP 啟用 Runbook

-
- -
-
- loading -
-
-
讀取 MCP 啟用 Runbook 中...
-
-
- -
-
-
-

MCP / FETCH GATE

-

人工 Fetch 安全閘門

-
- -
-
- loading -
-
-
讀取人工 Fetch 安全閘門中...
-
-
- -
-
-
-

MCP / COMPLETION AUDIT

-

MCP 完整度稽核

-
- -
-
- loading -
-
-
讀取 MCP 完整度稽核中...
-
-
- -
-
-
-

MCP / ACTIVATION EVIDENCE

-

MCP 啟用證據審核

-
- -
-
- loading -
-
-
讀取 MCP 啟用證據審核中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / RUNTIME SMOKE RECEIPT

-

MCP Runtime Smoke 收據

-
- -
-
- loading -
-
-
讀取 MCP Runtime Smoke 收據中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / RUNTIME PROMOTION

-

MCP Runtime Promotion 審核

-
- -
-
- loading -
-
-
讀取 MCP Runtime Promotion 審核中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / MANUAL FETCH HANDOFF

-

MCP Manual Fetch Handoff 審核

-
- -
-
- loading -
-
-
讀取 MCP Manual Fetch Handoff 審核中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / FETCH TARGET REVIEW

-

MCP Fetch Target Review 審核

-
- -
-
- loading -
-
-
讀取 MCP Fetch Target Review 審核中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / FETCH RUN PACKAGE

-

MCP Fetch Run Package 審核

-
- -
-
- loading -
-
-
讀取 MCP Fetch Run Package 審核中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / FETCH RUN READINESS

-

MCP Fetch Run Readiness 審核

-
- -
-
- loading -
-
-
讀取 MCP Fetch Run Readiness 審核中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / FETCH RUN RECEIPT

-

MCP Fetch Run Receipt 審核

-
- -
-
- loading -
-
-
讀取 MCP Fetch Run Receipt 審核中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / FETCH RESULT PARSER

-

MCP Fetch Result Parser 審核

-
- -
-
- loading -
-
-
讀取 MCP Fetch Result Parser 審核中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / FETCH CANDIDATE HANDOFF

-

MCP Fetch Candidate Handoff 審核

-
- -
-
- loading -
-
-
讀取 MCP Fetch Candidate Handoff 審核中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / FETCH CANDIDATE QUEUE

-

MCP Fetch Candidate Queue 審核

-
- -
-
- loading -
-
-
讀取 MCP Fetch Candidate Queue 審核中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / QUEUE WRITER PREFLIGHT

-

MCP Candidate Queue Writer Preflight

-
- -
-
- loading -
-
-
讀取 MCP Candidate Queue Writer Preflight 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / WRITER CLI REVIEW

-

MCP Candidate Queue Writer CLI Review

-
- -
-
- loading -
-
-
讀取 MCP Writer CLI Review 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / WRITER RUN PACKAGE REVIEW

-

MCP Candidate Queue Writer Run Package Review

-
- -
-
- loading -
-
-
讀取 MCP Writer Run Package Review 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / WRITER RUN READINESS

-

MCP Candidate Queue Writer Run Readiness

-
- -
-
- loading -
-
-
讀取 MCP Writer Run Readiness 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / WRITER RUN RECEIPT

-

MCP Candidate Queue Writer Run Receipt Review

-
- -
-
- loading -
-
-
讀取 MCP Writer Run Receipt Review 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / WRITER RUN CLOSEOUT

-

MCP Candidate Queue Writer Run Closeout Review

-
- -
-
- loading -
-
-
讀取 MCP Writer Run Closeout Review 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / POST-CLOSEOUT INVENTORY

-

MCP Candidate Queue Writer Post-Closeout Inventory Review

-
- -
-
- loading -
-
-
讀取 MCP Writer Post-Closeout Inventory Review 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / REVIEW HANDOFF

-

MCP Candidate Queue Writer Review Handoff

-
- -
-
- loading -
-
-
讀取 MCP Writer Review Handoff 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / REVIEW INVENTORY

-

MCP Candidate Queue Writer Review Inventory

-
- -
-
- loading -
-
-
讀取 MCP Writer Review Inventory 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / REVIEW DECISION

-

MCP Candidate Queue Writer Review Decision

-
- -
-
- loading -
-
-
讀取 MCP Writer Review Decision 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / DECISION APPROVAL

-

MCP Candidate Queue Writer Review Decision Approval

-
- -
-
- loading -
-
-
讀取 MCP Writer Review Decision Approval 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / APPROVAL PREFLIGHT

-

MCP Decision Approval Writer Preflight

-
- -
-
- loading -
-
-
讀取 MCP Decision Approval Writer Preflight 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / SOURCE GOVERNANCE

-

Professional Source Governance

-
- -
-
- loading -
-
-
讀取 Professional Source Governance 中...
-
-
- -
- -
-
-
- -
-
-
-

MCP / SOURCE GOVERNED TARGET

-

MCP Fetch Target Source Governance Review

-
- -
-
- loading -
-
-
讀取 Fetch Target Source Governance Review 中...
-
-
- -
- -
-
-
- -
-
-
-

MANUAL SAMPLE / FETCH PLAN

-

人工樣本 Fetch 計畫

-
- -
-
- loading -
-
-
讀取人工樣本 Fetch 計畫中...
-
-
- -
-
-
-

MANUAL SAMPLE / ACCEPTANCE

-

樣本結果驗收契約

-
- -
-
- loading -
-
-
讀取樣本結果驗收契約中...
-
-
- -
-
-
-

MANUAL SAMPLE / REVIEW

-

樣本結果審核預覽

-
- -
-
- loading -
-
-
讀取樣本結果審核預覽中...
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- -
-
-
-

SCHEDULER / ATTACH PLAN

-

排程掛載計畫

-
- -
-
- loading -
-
-
讀取排程掛載計畫中...
-
-
- -
-
-
-

MATCH REVIEW / PLAN

-

商品比對審核計畫

-
- -
-
- loading -
-
-
讀取商品比對審核計畫中...
-
-
- -
-
-
-

OPPORTUNITY / THREAT PLAN

-

市場機會與威脅計畫

-
- -
-
- loading -
-
-
讀取市場機會與威脅計畫中...
-
-
- -
-
-
-

OPPORTUNITY / SCORING MODEL

-

機會威脅分數模型

-
- -
-
- loading -
-
-
讀取機會威脅分數模型中...
-
-
- -
-
-
-

OPPORTUNITY / EVIDENCE BUNDLE

-

機會威脅證據包

-
- -
-
- loading -
-
-
讀取機會威脅證據包中...
-
-
- -
-
-
-

OPPORTUNITY / ALERT CANDIDATE

-

機會威脅告警候選

-
- -
-
- loading -
-
-
讀取機會威脅告警候選中...
-
-
- -
-
-
-

MIGRATION / BLUEPRINT

-

Schema migration 草案

-
- -
-
- loading -
-
-
讀取 migration 草案中...
-
-
- -
-
-
-

MIGRATION / APPLY DRILL

-

Migration 套用演練

-
- -
-
- loading -
-
-
讀取 migration 套用演練中...
-
-
- -
-
-
-

MIGRATION / CATALOG REVIEW

-

正式 DB catalog 判讀

-
- -
-
- loading -
-
-
讀取正式 DB catalog 判讀中...
-
-
- -
-
-
-

MIGRATION / LIVE SMOKE

-

正式 DB 只讀 smoke

-
- -
-
- loading -
-
-
讀取正式 DB 只讀 smoke 中...
-
-
- -
-
-
-

DB INVENTORY / READ ONLY

-

正式 DB 庫存總覽

-
- -
-
- loading -
-
-
讀取正式 DB 庫存總覽中...
-
-
- -
-
-
-

WRITE APPROVAL / RUNBOOK

-

正式寫入批准檢查

-
- -
-
- loading -
-
-
讀取批准檢查中...
-
-
- -
-
-
-

DEPLOYMENT / READINESS

-

推版準備檢查

-
- -
-
- loading -
-
-
讀取推版準備中...
-
+ +
{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index bd77891..e8ed652 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -47,6 +47,36 @@ def test_frontend_v2_shell_uses_real_runtime_context(): assert all(marker not in combined for marker in forbidden_markers) +def test_topbar_observability_indicator_is_cached_and_timeout_bounded(): + base_js = (ROOT / "web/static/js/ewoooc-base.js").read_text(encoding="utf-8") + observability_route = (ROOT / "routes/admin_observability_routes.py").read_text(encoding="utf-8") + + assert "momoObsHealthIndicator:v1" in base_js + assert "sessionStorage.getItem(cacheKey)" in base_js + assert "sessionStorage.setItem(cacheKey" in base_js + assert "const cacheTtlMs = 60000" in base_js + assert "new AbortController()" in base_js + assert "setTimeout(() => controller.abort(), 2500)" in base_js + assert "setInterval(() => refresh(false), 60000)" in base_js + assert "_HEALTH_INDICATOR_CACHE_LOCK" in observability_route + assert "_HEALTH_INDICATOR_CACHE_TTL_SECONDS = 30" in observability_route + assert "return jsonify(dict(cached_payload))" in observability_route + + +def test_market_intel_disabled_page_stays_lightweight_and_action_oriented(): + template_path = ROOT / "templates/market_intel/disabled.html" + template = template_path.read_text(encoding="utf-8") + + assert template_path.stat().st_size < 40000 + assert "市場情報模組待啟用" in template + assert "比價覆核" in template + assert "PChome 爬蟲" in template + assert "AI 觀測台" in template + assert "data-market-intel-preview" not in template + assert "/api/market_intel/" not in template + assert "讀取候選預覽中" not in template + + def test_frontend_v2_syncs_latest_momo_pro_prototype_tokens_and_shell(): tokens = (ROOT / "web/static/css/ewoooc-tokens.css").read_text(encoding="utf-8") shell = (ROOT / "web/static/css/ewoooc-shell.css").read_text(encoding="utf-8") @@ -325,6 +355,7 @@ def test_pchome_review_export_and_diagnostics_use_real_queue_data(): assert ".dashboard-review-envelope" in dashboard_css assert ".dashboard-review-actions" in dashboard_css assert ".dashboard-review-action.is-research" in dashboard_css + assert "grid-template-columns: repeat(auto-fit, minmax(128px, 1fr))" in dashboard_css def test_ai_intelligence_uses_v2_shell_and_real_runtime_apis(): diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css index 01c86e0..af83c1a 100644 --- a/web/static/css/page-dashboard-v2.css +++ b/web/static/css/page-dashboard-v2.css @@ -632,21 +632,23 @@ } .dashboard-review-segments { - display: flex; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 8px; padding: 12px 20px; - overflow-x: auto; + overflow: visible; border-bottom: 1px solid var(--momo-border-light); - -webkit-overflow-scrolling: touch; } .dashboard-review-segments a { display: inline-flex; align-items: center; + justify-content: space-between; gap: 8px; - flex: 0 0 auto; + min-width: 0; min-height: 30px; padding: 6px 10px; + overflow: hidden; color: var(--momo-text-secondary); background: var(--momo-bg-paper); border: 1px solid var(--momo-border-light); @@ -656,6 +658,13 @@ text-decoration: none; } + .dashboard-review-segments a span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .dashboard-review-segments a.is-active { color: var(--momo-text-inverse); background: var(--momo-ink); diff --git a/web/static/js/ewoooc-base.js b/web/static/js/ewoooc-base.js index a56f102..dbf69fe 100644 --- a/web/static/js/ewoooc-base.js +++ b/web/static/js/ewoooc-base.js @@ -76,23 +76,63 @@ const link = document.getElementById('momo-obs-link'); const badge = document.getElementById('momo-obs-badge'); if (!link || !badge) return; - async function refresh() { + + const cacheKey = 'momoObsHealthIndicator:v1'; + const cacheTtlMs = 60000; + + function applyIndicator(d) { + if (!d || !d.ok) return; + link.title = d.tooltip || 'AI 觀測台'; + if (d.alert_count > 0) { + badge.textContent = d.alert_count; + badge.hidden = false; + link.classList.add('is-alert'); + } else { + badge.hidden = true; + link.classList.remove('is-alert'); + } + } + + function readCachedIndicator() { try { - const r = await fetch('/observability/api/health_indicator', { credentials: 'same-origin' }); - if (!r.ok) return; - const d = await r.json(); - if (!d.ok) return; - link.title = d.tooltip || 'AI 觀測台'; - if (d.alert_count > 0) { - badge.textContent = d.alert_count; - badge.hidden = false; - link.classList.add('is-alert'); - } else { - badge.hidden = true; - link.classList.remove('is-alert'); - } + const cached = JSON.parse(sessionStorage.getItem(cacheKey) || 'null'); + if (!cached || !cached.data || Date.now() - cached.ts > cacheTtlMs) return null; + return cached.data; + } catch (e) { + return null; + } + } + + function writeCachedIndicator(data) { + try { + sessionStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data })); } catch (e) {} } + + async function refresh(useCache = true) { + if (useCache) { + const cached = readCachedIndicator(); + if (cached) { + applyIndicator(cached); + return; + } + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2500); + try { + const r = await fetch('/observability/api/health_indicator', { + credentials: 'same-origin', + signal: controller.signal, + }); + if (!r.ok) return; + const d = await r.json(); + writeCachedIndicator(d); + applyIndicator(d); + } catch (e) { + } finally { + clearTimeout(timer); + } + } refresh(); - setInterval(refresh, 60000); + setInterval(() => refresh(false), 60000); })();