diff --git a/app/assets/styles/OperationToasts.DfA_b8om.css b/app/assets/styles/OperationToasts.DfA_b8om.css index f07e142..d8228ed 100644 --- a/app/assets/styles/OperationToasts.DfA_b8om.css +++ b/app/assets/styles/OperationToasts.DfA_b8om.css @@ -2,7 +2,7 @@ /* 导入方:app/components/OperationToasts.vue、nuxt.config.ts。 */ .operation-toasts.p-toast { width: min(380px, 100% - 24px); - top: 76px !important; + top: calc(var(--console-top-offset, 56px) + 20px) !important; right: 20px !important; } .operation-toasts .p-toast-message { @@ -64,7 +64,7 @@ /* 视口条件 (width<=600px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */ @media (width<=600px) { .operation-toasts.p-toast { - top: 68px !important; + top: calc(var(--console-top-offset, 56px) + 12px) !important; right: 12px !important; } } diff --git a/app/assets/styles/cart.CSS2vTmS.css b/app/assets/styles/cart.CSS2vTmS.css index 6b12d03..8cdad86 100644 --- a/app/assets/styles/cart.CSS2vTmS.css +++ b/app/assets/styles/cart.CSS2vTmS.css @@ -187,7 +187,7 @@ gap: 18px; display: flex; position: sticky; - top: 76px; + top: calc(var(--console-top-offset, 56px) + 20px); } .cart-foot h3[data-v-11d8a39a] { margin: 0; diff --git a/app/assets/styles/dedicated-server.CiJfMYtk.css b/app/assets/styles/dedicated-server.CiJfMYtk.css index 549fa4f..027b9f2 100644 --- a/app/assets/styles/dedicated-server.CiJfMYtk.css +++ b/app/assets/styles/dedicated-server.CiJfMYtk.css @@ -326,7 +326,7 @@ summary[data-v-e16eb167]:focus-visible { @media (width>=1024px) { .catalog-lines[data-v-e16eb167] { position: sticky; - top: 76px; + top: calc(var(--console-top-offset, 56px) + 20px); } } /* 视口条件 (width<=1199px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */ diff --git a/app/assets/styles/default.DFUVVR1i.css b/app/assets/styles/default.DFUVVR1i.css index e0bbf72..486a1d5 100644 --- a/app/assets/styles/default.DFUVVR1i.css +++ b/app/assets/styles/default.DFUVVR1i.css @@ -4,23 +4,30 @@ background: var(--console-page-bg); min-height: 100dvh; color: var(--p-text-color, #334155); - padding-top: 56px; -} -.console-layout.has-banner[data-v-1a9c3b51] { - padding-top: 94px; + padding-top: var(--console-top-offset, 56px); } .maintenance-banner[data-v-1a9c3b51] { z-index: 150; color: #92400e; background: #fef3c7; - align-items: center; + align-items: flex-start; gap: 8px; - height: 38px; - padding: 0 14px; + min-height: 38px; + max-height: 40dvh; + padding: 8px 14px; + overflow-y: auto; display: flex; position: fixed; inset: 0 0 auto; } +.maintenance-banner > span[data-v-1a9c3b51] { + min-width: 0; + overflow-wrap: anywhere; +} +.maintenance-banner > i[data-v-1a9c3b51] { + flex-shrink: 0; + margin-top: 3px; +} /* 桌面侧栏与移动端导航共享的外壳;断点覆盖会改变其宽度/可见性。 */ .sidebar[data-v-1a9c3b51] { z-index: 30; @@ -28,7 +35,7 @@ border-right: 1px solid var(--console-panel-border); width: 210px; position: fixed; - top: 56px; + top: var(--console-top-offset, 56px); bottom: 0; left: 0; } @@ -146,22 +153,20 @@ .main[data-v-1a9c3b51] { flex-direction: column; min-width: 0; - min-height: calc(100dvh - 56px); + min-height: calc(100dvh - var(--console-top-offset, 56px)); margin-left: 210px; display: flex; } -.has-banner .main[data-v-1a9c3b51] { - min-height: calc(100dvh - 94px); -} /* 顶栏与维护提示、主内容定位配合,避免固定元素遮挡可操作内容。 */ .topbar[data-v-1a9c3b51] { z-index: 35; background: var(--console-panel-bg); border-bottom: 1px solid var(--console-panel-border); gap: 0; - height: 56px; + height: var(--console-header-height, 56px); position: fixed; inset: 0 0 auto; + top: var(--console-banner-height, 0px); } .brand-group[data-v-1a9c3b51], .topbar[data-v-1a9c3b51] { @@ -435,12 +440,6 @@ .skip-link[data-v-1a9c3b51]:focus { transform: translateY(0); } -.has-banner .topbar[data-v-1a9c3b51] { - top: 38px; -} -.has-banner .sidebar[data-v-1a9c3b51] { - top: 94px; -} /* 视口条件 (width<=1535px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */ @media (width<=1535px) { .main[data-v-1a9c3b51] { @@ -451,7 +450,7 @@ border-right: 0; width: 280px; transition: transform 0.2s; - top: 0; + top: var(--console-banner-height, 0px); transform: translate(-100%); } .sidebar.open[data-v-1a9c3b51] { @@ -466,9 +465,6 @@ .brand-group[data-v-1a9c3b51] { flex: none; } - .has-banner .sidebar[data-v-1a9c3b51] { - top: 0; - } } /* 视口条件 (width<=767px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */ @media (width<=767px) { diff --git a/app/assets/styles/entry.Cs9kvuGi.css b/app/assets/styles/entry.Cs9kvuGi.css index ec5cc88..5b0cf01 100644 --- a/app/assets/styles/entry.Cs9kvuGi.css +++ b/app/assets/styles/entry.Cs9kvuGi.css @@ -1472,7 +1472,7 @@ video { min-width: 0; max-width: 100%; padding: 16px 14px; - scroll-margin-top: 75px; + scroll-margin-top: calc(var(--console-top-offset, 56px) + 19px); box-shadow: 0 1px 3px #0000001a; } .config-checkout .section-title { @@ -1690,7 +1690,7 @@ video { @media (width>=1024px) { .config-summary-wrap { position: sticky; - top: 76px; + top: calc(var(--console-top-offset, 56px) + 20px); } } /* 视口条件 (width<=1199px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */ diff --git a/app/assets/styles/help.BSOB30LE.css b/app/assets/styles/help.BSOB30LE.css index a7e272f..9542aea 100644 --- a/app/assets/styles/help.BSOB30LE.css +++ b/app/assets/styles/help.BSOB30LE.css @@ -40,11 +40,11 @@ .help-nav[data-v-2e1fbf8d] { flex-direction: column; gap: 18px; - max-height: calc(100dvh - 104px); + max-height: calc(100dvh - var(--console-top-offset, 56px) - 48px); padding: 20px 14px 16px; display: flex; position: sticky; - top: 78px; + top: calc(var(--console-top-offset, 56px) + 22px); } .nav-heading[data-v-2e1fbf8d] { justify-content: space-between; diff --git a/app/components/instance/InstanceNetwork.vue b/app/components/instance/InstanceNetwork.vue index 8f4732b..c23ff9b 100644 --- a/app/components/instance/InstanceNetwork.vue +++ b/app/components/instance/InstanceNetwork.vue @@ -777,6 +777,7 @@ var ce = { { default: withCtx(() => [ createVNode(o, { + as: `span`, label: `前往安全组管理`, size: `small`, severity: `secondary`, @@ -857,6 +858,7 @@ var ce = { { default: withCtx(() => [ createVNode(o, { + as: `span`, label: `前往域名白名单`, size: `small`, severity: `secondary`, diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 3a4ec65..6f26da0 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -137,6 +137,28 @@ var Ce = { O = useRoute(), k = useTheme_t(), A = useSiteConfig_t(); + // 固定层共用实际维护条高度,避免换行、字体加载或窗口变化后仍按单行常量占位。 + const maintenanceBanner = ref(null); + const bannerHeight = ref(0); + let bannerObserver; + watch( + maintenanceBanner, + (element) => { + bannerObserver?.disconnect(); + const measure = () => { + bannerHeight.value = element + ? Math.ceil(element.getBoundingClientRect().height) + : 0; + }; + measure(); + if (element) { + bannerObserver = new ResizeObserver(measure); + bannerObserver.observe(element); + } + }, + { flush: "post" }, + ); + onBeforeUnmount(() => bannerObserver?.disconnect()); onMounted(() => { (A.load().catch(() => {}), w.isLoggedIn && !w.overview && w.fetchOverview().catch(() => {}), @@ -548,6 +570,15 @@ var Ce = { return ( useHead(() => ({ title: `${$.value.at(-1) || `会员中心`} - ${A.siteName.value}`, + // 通知 Teleport 在 body,变量发布到 html;Unhead 随布局卸载逐属性清理,不影响主题样式。 + htmlAttrs: { + style: { + "--console-header-height": "56px", + "--console-banner-height": `${A.maintenance.value ? bannerHeight.value : 0}px`, + "--console-top-offset": + "calc(var(--console-header-height) + var(--console-banner-height))", + }, + }, })), // 导航、面包屑和账户/消息浮层来自同一布局状态;移动侧栏需保留焦点约束与滚动锁清理。 (e, r) => { @@ -591,25 +622,29 @@ var Ce = { : createCommentVNode(``, !0), unref(A).maintenance.value ? (openBlock(), - createElementBlock(`div`, Ce, [ - (r[6] ||= createElementVNode( - `i`, - { - class: `pi pi-exclamation-triangle`, - }, - null, - -1, - )), - createElementVNode( - `span`, - null, - toDisplayString( - unref(A).config.value?.maintenance_mode_message || - `站点维护中,部分功能可能不可用。`, + createElementBlock( + `div`, + { ...Ce, ref: maintenanceBanner }, + [ + (r[6] ||= createElementVNode( + `i`, + { + class: `pi pi-exclamation-triangle`, + }, + null, + -1, + )), + createElementVNode( + `span`, + null, + toDisplayString( + unref(A).config.value?.maintenance_mode_message || + `站点维护中,部分功能可能不可用。`, + ), + 1, ), - 1, - ), - ])) + ], + )) : createCommentVNode(``, !0), createElementVNode( `aside`, diff --git a/app/pages/cloud-servers/index.vue b/app/pages/cloud-servers/index.vue index af1a008..0ed83af 100644 --- a/app/pages/cloud-servers/index.vue +++ b/app/pages/cloud-servers/index.vue @@ -75,6 +75,8 @@ var m = defineComponent({ { default: withCtx(() => [ createVNode(g, { + // 导航只保留外层链接的焦点;内层保留按钮外观,不再生成第二个交互元素。 + as: `span`, label: `订购云服务器`, icon: `pi pi-plus`, }), diff --git a/app/pages/dedicated-servers/index.vue b/app/pages/dedicated-servers/index.vue index bc35e69..32b5325 100644 --- a/app/pages/dedicated-servers/index.vue +++ b/app/pages/dedicated-servers/index.vue @@ -75,6 +75,8 @@ var m = defineComponent({ { default: withCtx(() => [ createVNode(g, { + // 外层 NuxtLink 独占导航语义,避免链接里再次嵌套按钮焦点。 + as: `span`, label: `订购物理服务器`, icon: `pi pi-plus`, }), diff --git a/app/pages/tickets/index.vue b/app/pages/tickets/index.vue index 040c5c3..db6f23e 100644 --- a/app/pages/tickets/index.vue +++ b/app/pages/tickets/index.vue @@ -227,6 +227,7 @@ var ie = { { default: withCtx(() => [ createVNode(f, { + as: `span`, label: `创建工单`, icon: `pi pi-plus`, }), @@ -398,6 +399,7 @@ var ie = { { default: withCtx(() => [ createVNode(f, { + as: `span`, label: `创建工单`, icon: `pi pi-plus`, size: `small`, diff --git a/tests/review-style-layout.e2e.test.mjs b/tests/review-style-layout.e2e.test.mjs new file mode 100644 index 0000000..4554d6b --- /dev/null +++ b/tests/review-style-layout.e2e.test.mjs @@ -0,0 +1,350 @@ +/** + * 布局回归补齐旧业务 E2E 的视觉盲区:长维护文案、吸顶避让、导航的单一焦点与布局切换清理。 + * 所有内容来自生产已有的本地 HTTP 边界;尺寸断言读取实际 DOM,不检查源码形状。 + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + appOrigin, + backendOrigin, + eventually, + login, + scenario, +} from "./helpers/console-browser.mjs"; + +const maintenanceMessage = + "系统正在升级维护,部分业务暂时不可用,请稍后再试。如有紧急问题请联系客服,我们正在尽快恢复服务。"; + +async function layoutBoundary( + page, + { maintenance = true, content = "help" } = {}, +) { + await page + .context() + .route(`${backendOrigin}/console/v1/**`, async (route) => { + const path = new URL(route.request().url()).pathname.replace( + /^\/console\/v1/, + "", + ); + const reply = (data) => + route.fulfill({ json: { status: 200, msg: "", data } }); + if (path === "/common") { + const response = await route.fetch(); + const body = await response.json(); + Object.assign(body.data, { + maintenance_mode: Number(maintenance), + maintenance_mode_message: maintenanceMessage, + }); + return route.fulfill({ response, json: body }); + } + if (path === "/menu") + return reply({ + menu: [ + { + id: 10, + name: "查看官网", + menu_type: "custom", + url: "/official-site", + }, + ], + }); + if (path === "/official_site_cms/content") return reply({ content: {} }); + if (path === "/help") + return reply({ + list: [ + { + id: 1, + name: "产品帮助", + helps: [{ id: 1, title: "维护期间的使用说明" }], + }, + ], + }); + if (path === "/help/1") + return reply({ + help: { + id: 1, + title: "维护期间的使用说明", + content: + "
这是一段正常帮助正文,用于验证长文章滚动后的布局。
".repeat( + 80, + ), + create_time: 1700000000, + }, + }); + if (path === "/cart") + return reply({ + list: Array.from({ length: 14 }, (_, index) => ({ + product_id: 123, + qty: 1, + name: `云产品 ${index + 1}`, + config_options: { cpu: index + 1 }, + customfield: {}, + self_defined_field: {}, + })), + }); + if (path === "/product/123/config_option") + return reply({ + price_total: "10.00", + preview: [{ name: "CPU", value: "2 核" }], + }); + if (path === "/purchase_limit/check") + return reply({ allowed: true, message: "" }); + if (path === "/product/123") + return reply({ + product: { + id: 123, + name: "可配置产品", + module: "idcsmart_common", + pay_type: "free", + description: "", + }, + }); + if (path === "/idcsmart_common/product/123/configoption") + return reply({ + common_product: { pay_type: "free" }, + configoptions: Array.from({ length: 18 }, (_, index) => ({ + id: index + 1, + option_name: `扩展资源 ${index + 1}`, + option_type: "quantity", + is_show: 1, + qty_min: 1, + qty_max: 10, + qty_change: 1, + })), + }); + if (path === "/product/123/self_defined_field/order_page") + return reply({ data: [] }); + if (path === "/product" && content === "dedicated") + return reply({ + list: [ + { + id: 123, + name: "物理服务器", + module: "mf_dcim", + price: "100.00", + stock_control: 0, + }, + ], + count: 1, + }); + if (path === "/product/123/mf_dcim/order_page") + return reply({ + model_config: Array.from({ length: 12 }, (_, index) => ({ + id: index + 1, + name: `机型 ${index + 1}`, + cpu: "Xeon", + memory: "32GB", + disk: "1TB", + })), + data_center: [ + { + name: "中国", + city: [ + { + name: "上海", + area: [{ name: "一区", line: [{ id: 1, name: "BGP" }] }], + }, + ], + }, + ], + }); + if (path === "/product/123/mf_dcim/line/1") return reply({}); + return route.fallback(); + }); +} + +async function bannerGeometry(page) { + return page.locator(".maintenance-banner").evaluate((banner) => { + const box = banner.getBoundingClientRect(), + range = document.createRange(); + range.selectNodeContents(banner.querySelector("span")); + const text = range.getBoundingClientRect(), + bar = document.querySelector(".topbar").getBoundingClientRect(); + return { + top: box.top, + bottom: box.bottom, + height: box.height, + textTop: text.top, + textBottom: text.bottom, + topbarTop: bar.top, + topbarBottom: bar.bottom, + viewport: innerWidth, + scrollWidth: document.documentElement.scrollWidth, + }; + }); +} + +test( + "maintenance text and page offsets follow content height and viewport changes", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.setViewportSize({ width: 320, height: 740 }); + await layoutBoundary(page); + await login(page, "/security/ssh-keys"); + await page.locator(".maintenance-banner").waitFor(); + const sizes = []; + for (const width of [320, 375, 1440, 320]) { + await page.setViewportSize({ width, height: 740 }); + const state = await eventually( + () => bannerGeometry(page), + (state) => + state.textTop >= state.top - 0.5 && + state.textBottom <= state.bottom + 0.5 && + state.topbarTop >= state.bottom - 0.5, + "The full notice and topbar must occupy separate visible regions", + ); + assert.equal( + state.scrollWidth, + state.viewport, + "A maintenance notice must not widen the document", + ); + sizes.push({ width, noticeFits: true, headerBelowNotice: true }); + } + await page.getByRole("button", { name: "打开菜单", exact: true }).click(); + const menuVisible = await page + .getByRole("button", { name: "关闭菜单", exact: true }) + .evaluate((button) => { + const r = button.getBoundingClientRect(), + hit = document.elementFromPoint( + r.left + r.width / 2, + r.top + r.height / 2, + ); + return button.contains(hit); + }); + assert.ok( + menuVisible, + "The notice must not cover the drawer close button", + ); + await page.keyboard.press("Escape"); + return { sizes, drawerCloseReachable: true }; + }), +); + +for (const [name, path, selector] of [ + ["help", "/help/article?id=1", ".help-nav"], + ["cart", "/cart", ".cart-foot"], + ["dedicated", "/products/dedicated-server", ".catalog-lines"], + ["configuration", "/products/123", ".config-summary-wrap"], +]) { + test( + `maintenance sticky ${name} clears the actual fixed header`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.setViewportSize({ width: 1440, height: 800 }); + await layoutBoundary(page, { content: name }); + await login(page, path); + if (name === "dedicated") + await page + .getByRole("button", { name: "选择线路", exact: true }) + .first() + .click(); + await page.locator(selector).waitFor(); + await page.evaluate(() => window.scrollTo(0, 500)); + const state = await eventually( + () => + page.locator(selector).evaluate((el) => { + const r = el.getBoundingClientRect(), + bar = document.querySelector(".topbar").getBoundingClientRect(); + const hit = document.elementFromPoint(r.left + 10, r.top + 5); + return { + scrollY, + top: r.top, + headerBottom: bar.bottom, + unobscured: el.contains(hit), + sticky: getComputedStyle(el).position, + }; + }), + (state) => + state.scrollY > 0 && + state.top >= state.headerBottom - 0.5 && + state.unobscured, + "Sticky content must remain below, not behind, the header", + ); + assert.equal( + state.sticky, + "sticky", + "The test must exercise the sticky state", + ); + return { + stickyReached: true, + headerCleared: true, + hitTestUnobscured: true, + }; + }), + ); +} + +test( + "console layout releases its measured offsets when navigating to the official site", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.setViewportSize({ width: 1680, height: 900 }); + await layoutBoundary(page); + await page.goto(`${appOrigin}/official-site`); + await page.locator(".official-site").waitFor(); + const toastTop = () => + page + .locator(".operation-toasts") + .evaluate((el) => parseFloat(getComputedStyle(el).top)); + const outsideConsoleTop = await toastTop(); + await login(page, "/help/article?id=1"); + const consoleTop = await eventually( + toastTop, + (top) => top > outsideConsoleTop, + "Global notifications must clear the maintenance header", + ); + await page.getByRole("link", { name: "查看官网", exact: true }).click(); + await page.locator(".official-site").waitFor(); + await eventually( + toastTop, + (top) => Math.abs(top - outsideConsoleTop) < 0.5, + "Console geometry must not leak after the console layout unmounts", + ); + return { + notificationClearedConsole: consoleTop > outsideConsoleTop, + officialLayoutRestored: true, + }; + }), +); + +for (const [path, name] of [ + ["/cloud-servers", "订购云服务器"], + ["/dedicated-servers", "订购物理服务器"], + ["/tickets", "创建工单"], +]) { + test( + `navigation action has one keyboard stop on ${path}`, + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await login(page, path); + const links = page.getByRole("link", { name, exact: true }); + await links.first().waitFor(); + for (const link of await links.all()) { + assert.equal( + await link + .locator("button,a[href],input,select,textarea,[tabindex='0']") + .count(), + 0, + "A navigation action must not contain another focusable control", + ); + await link.focus(); + await page.keyboard.press("Tab"); + assert.equal( + await link.evaluate((el) => el.contains(document.activeElement)), + false, + "Tab must move on from the action, not enter it a second time", + ); + } + return { + navigationActions: await links.count(), + singleKeyboardStop: true, + }; + }), + ); +}