diff --git a/frontend/pages/sentiment/foundation.css b/frontend/pages/sentiment/foundation.css index 6accf89..8300c5d 100644 --- a/frontend/pages/sentiment/foundation.css +++ b/frontend/pages/sentiment/foundation.css @@ -1011,9 +1011,9 @@ border-radius: 6px; - background: var(--r2-ink); + background: var(--sentiment-tooltip-bg); - color: var(--text-inverse); + color: var(--sentiment-tooltip-fg); font-size: 11px; @@ -1021,6 +1021,8 @@ } .sentiment-chart-tooltip b { + color: inherit; + font-weight: 700; } @@ -1568,10 +1570,6 @@ #sentimentCycleView .sentiment-component-item { padding: 4px 0px; } - - #sentimentCycleView .sentiment-component-item small { - display: none; - } } @media (min-width: 768px) { @@ -1764,3 +1762,72 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +@media (min-width: 1024px) { + #sentimentCycleView .redesigned-emotion-grid { + align-items: stretch; + + height: var(--sentiment-analysis-height); + + max-height: var(--sentiment-analysis-height); + + overflow: hidden; + } + + .redesigned-sentiment-view .sentiment-analysis-main, + .redesigned-sentiment-view .sentiment-analysis-rail { + align-self: stretch; + + height: 100%; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-trend-panel { + display: flex; + + flex: 1 1 auto; + + flex-direction: column; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-chart-shell { + flex: 1 1 auto; + + height: auto; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-chart-shell canvas { + height: 100%; + } + + .redesigned-sentiment-view .sentiment-cycle-summary { + flex: 0 0 auto; + } + + .redesigned-sentiment-view .sentiment-components-panel { + display: flex; + + flex: 1 1 auto; + + flex-direction: column; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-component-list { + display: flex; + + flex: 1 1 auto; + + flex-direction: column; + + justify-content: space-evenly; + + min-height: 0px; + } +} diff --git a/frontend/pages/sentiment/page.js b/frontend/pages/sentiment/page.js index 135f5bb..d514b29 100644 --- a/frontend/pages/sentiment/page.js +++ b/frontend/pages/sentiment/page.js @@ -1,10 +1,39 @@ let sentimentChartAnimationFrame = null; +let sentimentChartResizeObserver = null; +let sentimentChartLastSize = ""; window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], { bind: bindSentimentEvents, enter: ["loadSentiment"], }); +function sentimentChartSizeKey(target) { + if (!target) return ""; + const rect = target.getBoundingClientRect(); + return `${Math.round(rect.width)}x${Math.round(rect.height)}x${window.devicePixelRatio || 1}`; +} + +function observeSentimentTrendChart() { + const shell = document.querySelector("#sentimentCycleView .sentiment-chart-shell"); + if (!shell) return; + if (!sentimentChartResizeObserver) { + sentimentChartResizeObserver = new ResizeObserver(() => { + if (sentimentChartAnimationFrame) return; + if (state.activeView !== "sentimentCycleView") return; + const rows = state.sentimentHistory?.rows; + if (!rows?.length) return; + const current = document.querySelector("#sentimentCycleView .sentiment-chart-shell"); + const nextKey = sentimentChartSizeKey(current); + if (!nextKey || nextKey === sentimentChartLastSize) return; + drawSentimentTrendChart(rows, 1); + }); + } else { + sentimentChartResizeObserver.disconnect(); + } + sentimentChartLastSize = sentimentChartSizeKey(shell); + sentimentChartResizeObserver.observe(shell); +} + async function loadSentimentHistory(force = false) { if (!state.dashboard || state.sentimentLoading) return; const key = `${elements.tradeDate.value}:${state.sentimentRange}`; @@ -126,6 +155,7 @@ function animateSentimentTrendChart(rows) { if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame); if (!motionEnabled()) { drawSentimentTrendChart(rows, 1); + observeSentimentTrendChart(); return; } const startedAt = performance.now(); @@ -135,7 +165,10 @@ function animateSentimentTrendChart(rows) { const progress = 1 - (1 - rawProgress) ** 3; drawSentimentTrendChart(rows, progress); if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame); - else sentimentChartAnimationFrame = null; + else { + sentimentChartAnimationFrame = null; + observeSentimentTrendChart(); + } }; sentimentChartAnimationFrame = requestAnimationFrame(frame); } @@ -144,9 +177,9 @@ function drawSentimentTrendChart(rows, progress = 1) { const canvas = document.querySelector("#sentimentTrendChart"); if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return; const rect = canvas.getBoundingClientRect(); - if (!rect.width) return; - const width = Math.max(320, rect.width); - const height = Math.max(220, rect.height); + if (rect.width < 8 || rect.height < 8) return; + const width = rect.width; + const height = rect.height; const ratio = window.devicePixelRatio || 1; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); @@ -258,6 +291,7 @@ function drawSentimentTrendChart(rows, progress = 1) { const dateText = displayCompactDate(row.trade_date).slice(5); context.fillText(dateText, x(index), height - padding.bottom + 10); }); + sentimentChartLastSize = sentimentChartSizeKey(canvas.closest(".sentiment-chart-shell")); } function bindSentimentChartTooltip(rows) { diff --git a/frontend/shared/tokens.css b/frontend/shared/tokens.css index 5391ed9..e7c2b1f 100644 --- a/frontend/shared/tokens.css +++ b/frontend/shared/tokens.css @@ -210,6 +210,9 @@ --pool-table-max-height: calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap)); --sentiment-history-max-height: 510px; --sentiment-history-min-height: 220px; + --sentiment-analysis-height: 600px; + --sentiment-tooltip-bg: var(--text-primary); + --sentiment-tooltip-fg: var(--text-inverse); --primary-share: 1.45fr; --secondary-share: .75fr; --mobile-nav-height: 64px; @@ -505,6 +508,8 @@ --chart-repair: #e2ad58; --chart-ma-10: #d39a45; --chart-ma-20: #9aa5af; + --sentiment-tooltip-bg: #26293e; + --sentiment-tooltip-fg: #e8eaed; --on-action: #101418; --warning-line: #6d5a38; --warning-line-strong: #66502d; diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 9ceaa8b..3f049c9 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -47,6 +47,72 @@ function session(role = "admin", subscribed = true) { }; } +function sentimentHistoryPayload(days = 20) { + const phases = ["冰点", "修复", "发酵", "高潮", "分化", "退潮"]; + const rows = Array.from({ length: days }, (_, index) => { + const score = 28 + ((index * 7) % 55); + return { + trade_date: `2026-08-${String(index + 1).padStart(2, "0")}`, + score, + label: "情绪观察", + phase: phases[index % phases.length], + direction: index % 2 ? "升温" : "降温", + day_change: index % 2 ? 3.2 : -2.1, + seal_rate: 71.5, + limit_up_count: 40 + index, + first_board_count: 18, + second_board_count: 8, + three_plus_count: 4, + max_height: 5, + broken_count: 12, + limit_down_count: 3, + previous_limit_count: 38, + previous_positive_count: 22, + previous_positive_rate: 57.9, + average_previous_change: 1.2, + normalization: "固定锚点", + components: { + breadth: { label: "市场宽度", score: 55.8, weight: 20, summary: "红盘家数回升" }, + limit: { label: "涨停连板", score: 79.6, weight: 25, summary: "连板生态改善" }, + profit: { label: "赚钱效应", score: 67.0, weight: 30, summary: "昨日反馈尚可" }, + ladder: { label: "涨幅结构", score: 81.5, weight: 15, summary: "高度仍在扩张" }, + amount: { label: "成交活跃度", score: 46.1, weight: 10, summary: "量能略低于均值" }, + }, + }; + }); + return { available_days: days, rows }; +} + +async function renderSentimentFixture(page, days = 20) { + await page.evaluate((payload) => { + state.sentimentHistory = payload; + renderSentimentHistory(); + }, sentimentHistoryPayload(days)); + await page.locator('[data-view="sentimentCycleView"]').first().click(); + await page.waitForTimeout(900); +} + +function readSentimentLayout() { + const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect(); + const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect(); + const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect(); + const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect(); + const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect(); + const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect(); + const table = document.querySelector(".sentiment-history-frame").getBoundingClientRect(); + const small = document.querySelector(".sentiment-component-item small"); + return { + topDelta: Math.abs(trend.top - summary.top), + bottomDelta: Math.abs(trend.bottom - components.bottom), + analysisHeight: analysis.height, + chartHeight: chart.height, + detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom) - 0.5, + tableVisible: table.top < window.innerHeight && table.bottom > detail.bottom, + smallVisible: small ? getComputedStyle(small).display !== "none" : false, + overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + }; +} + function waitForApplicationRuntime(page) { return expect(page.locator("body")).toHaveAttribute("data-runtime-ready", "true"); } @@ -721,6 +787,7 @@ test("collapsed overview and sentiment layout keep a single current reading", as await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0); await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。"); const alignment = await page.evaluate(() => { + const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect(); const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect(); const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect(); const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect(); @@ -732,6 +799,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text")); return { columnsAligned: Math.abs(trend.top - summary.top) < 1, + bottomsAligned: Math.abs(trend.bottom - components.bottom) < 1, + analysisHeight: analysis.height, railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom, detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom), chartHeight: chart.height, @@ -743,6 +812,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as }; }); expect(alignment.columnsAligned).toBe(true); + expect(alignment.bottomsAligned).toBe(true); + expect(Math.abs(alignment.analysisHeight - 600)).toBeLessThanOrEqual(1); expect(alignment.railAligned).toBe(true); expect(alignment.detailAfterAnalysis).toBe(true); expect(alignment.chartHeight).toBeGreaterThanOrEqual(340); @@ -780,6 +851,68 @@ test("collapsed overview and sentiment layout keep a single current reading", as } }); +test("sentiment cycle keeps 600px equal-height layout across zoom viewports", async ({ page }, testInfo) => { + const shotDir = testInfo.outputPath("hel-221-shots"); + await mockApplication(page, session("user", true)); + const viewports = [ + { name: "zoom-100", width: 2560, height: 1440 }, + { name: "zoom-110", width: 2327, height: 1309 }, + { name: "zoom-125", width: 2048, height: 1152 }, + ]; + + for (const viewport of viewports) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto("/index.html"); + await renderSentimentFixture(page, 20); + const layout = await page.evaluate(readSentimentLayout); + expect(layout.topDelta, viewport.name).toBeLessThanOrEqual(1); + expect(layout.bottomDelta, viewport.name).toBeLessThanOrEqual(1); + expect(Math.abs(layout.analysisHeight - 600), viewport.name).toBeLessThanOrEqual(1); + expect(layout.chartHeight, viewport.name).toBeGreaterThanOrEqual(450); + expect(layout.detailAfterAnalysis, viewport.name).toBe(true); + expect(layout.tableVisible, viewport.name).toBe(true); + expect(layout.smallVisible, viewport.name).toBe(true); + expect(layout.overflowX, viewport.name).toBe(false); + await page.screenshot({ + path: `${shotDir}/day-${viewport.name}.png`, + fullPage: true, + }); + } + + await page.setViewportSize({ width: 2560, height: 1440 }); + await page.locator("#themeToggle").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await page.waitForTimeout(200); + const nightLayout = await page.evaluate(readSentimentLayout); + expect(nightLayout.topDelta).toBeLessThanOrEqual(1); + expect(nightLayout.bottomDelta).toBeLessThanOrEqual(1); + expect(Math.abs(nightLayout.analysisHeight - 600)).toBeLessThanOrEqual(1); + await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } }); + const tooltip = page.locator("#sentimentChartTooltip"); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText("温度"); + const tooltipStyle = await tooltip.evaluate((node) => { + const style = getComputedStyle(node); + const bold = getComputedStyle(node.querySelector("b") || node); + return { background: style.backgroundColor, color: style.color, bold: bold.color }; + }); + expect(tooltipStyle.background).toBe("rgb(38, 41, 62)"); + expect(tooltipStyle.color).toBe("rgb(232, 234, 237)"); + expect(tooltipStyle.bold).toBe("rgb(232, 234, 237)"); + await page.screenshot({ path: `${shotDir}/night-zoom-100.png`, fullPage: true }); + + await page.locator("#themeToggle").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } }); + await expect(tooltip).toBeVisible(); + const lightTooltip = await tooltip.evaluate((node) => { + const style = getComputedStyle(node); + return { background: style.backgroundColor, color: style.color }; + }); + expect(lightTooltip.background).toBe("rgb(31, 35, 41)"); + expect(lightTooltip.color).toBe("rgb(255, 255, 255)"); +}); + test("limit-up pool separates stock identity and restores the reason column", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); diff --git a/tests/test_frontend_contract.py b/tests/test_frontend_contract.py index ab8f0fd..e2657b7 100644 --- a/tests/test_frontend_contract.py +++ b/tests/test_frontend_contract.py @@ -344,6 +344,22 @@ class FrontendContractTests(unittest.TestCase): self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles) self.assertIn("overflow: auto;", self.sentiment_styles) + def test_sentiment_equal_height_and_tooltip_tokens(self): + self.assertIn("--sentiment-analysis-height: 600px;", self.tokens) + self.assertIn("--sentiment-tooltip-bg: var(--text-primary);", self.tokens) + self.assertIn("--sentiment-tooltip-fg: var(--text-inverse);", self.tokens) + self.assertIn("--sentiment-tooltip-bg: #26293e;", self.tokens) + self.assertIn("--sentiment-tooltip-fg: #e8eaed;", self.tokens) + self.assertIn("height: var(--sentiment-analysis-height);", self.sentiment_styles) + self.assertIn("max-height: var(--sentiment-analysis-height);", self.sentiment_styles) + self.assertIn("justify-content: space-evenly;", self.sentiment_styles) + self.assertIn("background: var(--sentiment-tooltip-bg);", self.sentiment_styles) + self.assertIn("color: var(--sentiment-tooltip-fg);", self.sentiment_styles) + self.assertIn("new ResizeObserver", self.script) + self.assertIn("#sentimentCycleView .redesigned-emotion-grid {", self.sentiment_styles) + self.assertNotIn("height: 100vh", self.sentiment_styles) + self.assertNotIn("min-height: 100%", self.sentiment_styles) + def test_mentor_final_visual_fix_contract(self): shell_styles = (STATIC_DIR / "shared" / "shell.css").read_text(encoding="utf-8") mentor_html = (STATIC_DIR / "pages" / "mentor" / "page.html").read_text(encoding="utf-8")