diff --git a/.gitignore b/.gitignore index 5c1c896..f824610 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,10 @@ data/excel_exports/ *.xlsx~ ~$*.xlsx +# 本機 QA / 頁面快取 +data/*_cache/ +data/ai_automation_smoke_history.jsonl + # 上傳檔案 web/static/uploads/ web/static/screenshots/ diff --git a/config.py b/config.py index c5e9f95..a730480 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.139" +SYSTEM_VERSION = "V10.140" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/scripts/check_responsive_overflow.js b/scripts/check_responsive_overflow.js index 4ac01a1..6eb96e8 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, + settleMs: 350, maxOverflow: 1, screenshotDir: '', json: false, @@ -126,6 +127,8 @@ function parseArgs(argv) { options.viewports = []; } else if (arg === '--timeout') { options.timeoutMs = parseInt(argv[++i], 10) * 1000; + } else if (arg === '--settle-ms') { + options.settleMs = parseInt(argv[++i], 10); } else if (arg === '--max-overflow') { options.maxOverflow = parseInt(argv[++i], 10); } else if (arg === '--screenshot-dir') { @@ -159,6 +162,7 @@ Options: --viewport name=WxH Add a viewport --clear-default-viewports Use only custom --viewport entries --timeout SEC Navigation timeout, default 30 + --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 --json Print JSON summary @@ -276,14 +280,20 @@ async function main() { const browser = await chromium.launch(launchOptions); 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, + }); + pages.set(viewport.name, page); + } + for (const route of options.routes) { for (const viewport of options.viewports) { - const page = await browser.newPage({ - viewport: { width: viewport.width, height: viewport.height }, - deviceScaleFactor: 1, - }); + const page = pages.get(viewport.name); const url = routeToUrl(options.baseUrl, route); let status = 0; let error = ''; @@ -291,7 +301,10 @@ async function main() { try { const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: options.timeoutMs }); - await page.waitForLoadState('networkidle', { 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); + } status = response ? response.status() : 0; metrics = await collectMetrics(page, options.maxOverflow); if (new URL(metrics.finalUrl).pathname === '/login') { @@ -315,11 +328,10 @@ async function main() { const file = `${safeName(route)}_${safeName(viewport.name)}.png`; await page.screenshot({ path: path.join(options.screenshotDir, file), fullPage: false }); } - - await page.close(); } } } finally { + await Promise.all(Array.from(pages.values()).map((page) => page.close().catch(() => {}))); await browser.close(); } diff --git a/web/static/css/page-edm.css b/web/static/css/page-edm.css index ffc475e..cfc22f1 100644 --- a/web/static/css/page-edm.css +++ b/web/static/css/page-edm.css @@ -540,19 +540,98 @@ } .edm-page .campaign-table-wrap::before { - content: '左右滑動查看完整商品列表'; - position: sticky; - left: 0; + content: none; + } + + .edm-page .campaign-table-wrap { + overflow-x: visible; + } + + .edm-page .campaign-table, + .edm-page .campaign-table tbody, + .edm-page .campaign-table tr, + .edm-page .campaign-table td { display: block; - width: fit-content; - max-width: calc(100vw - 28px); - margin: 0 0 8px; - padding: 6px 9px; - color: var(--momo-text-secondary); - background: var(--momo-bg-paper); + width: 100%; + } + + .edm-page .campaign-table { + min-width: 0; + border-collapse: separate; + border-spacing: 0; + } + + .edm-page .campaign-table thead { + display: none; + } + + .edm-page .campaign-table tbody { + display: grid; + gap: 10px; + padding: 12px; + } + + .edm-page .campaign-table tr { + overflow: hidden; border: 1px solid var(--momo-border-light); - border-radius: 4px; - font-size: 12px; - font-weight: 700; + border-radius: 8px; + background: var(--momo-bg-surface); + } + + .edm-page .campaign-table td { + display: grid; + grid-template-columns: minmax(72px, 0.32fr) minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 10px 12px; + border-bottom: 1px solid var(--momo-border-light); + text-align: left !important; + overflow-wrap: anywhere; + } + + .edm-page .campaign-table td:last-child { + border-bottom: 0; + } + + .edm-page .campaign-table td::before { + color: var(--momo-text-tertiary); + font-family: var(--momo-font-mono); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + } + + .edm-page .campaign-table td:nth-child(1)::before { content: '分類'; } + .edm-page .campaign-table td:nth-child(2)::before { content: '商品'; } + .edm-page .campaign-table td:nth-child(3)::before { content: '價格'; } + .edm-page .campaign-table td:nth-child(4)::before { content: '銷售'; } + .edm-page .campaign-table td:nth-child(5)::before { content: '追蹤'; } + + .edm-page .campaign-table td[colspan] { + display: block; + } + + .edm-page .campaign-table td[colspan]::before { + content: none; + } + + .edm-page .campaign-product-cell { + align-items: flex-start; + gap: 10px; + } + + .edm-page .campaign-product-thumb { + width: 48px; + height: 48px; + } + + .edm-page .campaign-history-button { + justify-content: flex-start; + text-align: left; + } + + .edm-page .campaign-sales-stack, + .edm-page .campaign-track-stack { + gap: 4px; } }