diff --git a/app/assets/styles/entry.Cs9kvuGi.css b/app/assets/styles/entry.Cs9kvuGi.css index 75e5363..31ba733 100644 --- a/app/assets/styles/entry.Cs9kvuGi.css +++ b/app/assets/styles/entry.Cs9kvuGi.css @@ -1252,19 +1252,15 @@ video { .p-datatable-header-cell { white-space: nowrap; } +.p-datatable-tbody > tr > td { + overflow-wrap: anywhere; +} /* 视口条件 (width<=768px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */ @media (width<=768px) { - .p-datatable-table:not(:has(.p-datatable-empty-message)) { + /* 空表、加载与有数据共用断点布局,不能随接口结果切换表头和最小宽度。 */ + .p-datatable-table { min-width: 680px; } - .p-datatable-table:has(.p-datatable-empty-message) { - table-layout: fixed; - width: 100%; - min-width: 0 !important; - } - .p-datatable-table:has(.p-datatable-empty-message) thead { - display: none; - } } /* 尊重减少动态效果偏好:保留状态信息,降低或取消非必要动画。 */ @media (prefers-reduced-motion: reduce) { @@ -1961,9 +1957,6 @@ video { max-width: 100%; overflow: hidden; } -.list-table:has(.p-datatable-mask):has(.p-datatable-empty-message) { - min-height: 100dvh; -} .list-table .p-datatable-header-cell { color: var(--p-text-color); white-space: nowrap; @@ -2049,6 +2042,9 @@ video { position: absolute; overflow: hidden; } +.p-datatable .row-actions { + flex-wrap: wrap; +} .host-list-page .host-row-placeholder { flex-direction: column; justify-content: center; @@ -2070,16 +2066,6 @@ video { } /* 视口条件 (width<=768px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */ @media (width<=768px) { - .list-table:has(.p-datatable-mask) - .p-datatable-table:has(.p-datatable-empty-message) { - table-layout: auto; - min-width: 680px !important; - } - .list-table:has(.p-datatable-mask) - .p-datatable-table:has(.p-datatable-empty-message) - thead { - display: table-header-group; - } .list-head { gap: 10px; } diff --git a/app/components/HostList.vue b/app/components/HostList.vue index 3549c7c..57689ef 100644 --- a/app/components/HostList.vue +++ b/app/components/HostList.vue @@ -11,7 +11,6 @@ import "~/assets/styles/HostList.B3U15sJm.css"; import { nextTick } from "vue"; import { toDisplayString } from "vue"; import { renderList } from "vue"; -import { onMounted } from "vue"; import { normalizeClass } from "vue"; import { onBeforeUnmount } from "vue"; import { renderSlot } from "vue"; @@ -667,7 +666,9 @@ var Ye = { }), }); ((gn.value = T?.page ?? 1), (I.value = T?.limit ?? 20)); - let L = computed(() => !hn.value && !F.value), + // 列定义与列表响应一起确认;缓存只有行数据,不能据此猜测本次动态列。 + let schemaReady = computed(() => D.value !== null), + L = computed(() => (!hn.value || !schemaReady.value) && !F.value), bn = computed(() => Array.from( { @@ -705,19 +706,18 @@ var Ye = { (onBeforeUnmount(() => { (O++, En(), (Tn = null)); }), - onMounted(async () => { - (await nextTick(), - requestAnimationFrame(() => - requestAnimationFrame(() => { - (Tn !== null && - window.scrollTo({ - top: Tn, - behavior: `instant`, - }), - (Tn = null)); - }), - )); - }), + // 表格与列定义已真实渲染后恢复位置;短加载态不能提前消费长列表的滚动快照。 + watch( + () => schemaReady.value && hn.value && !P.value, + async (ready) => { + if (!ready || Tn === null) return; + await nextTick(); + if (Tn === null || !schemaReady.value || !hn.value) return; + window.scrollTo({ top: Tn, behavior: `instant` }); + Tn = null; + }, + { flush: `post` }, + ), watch( () => w.token, (e) => { @@ -1574,37 +1574,43 @@ var Ye = { `div`, { class: `list-table`, - "aria-busy": unref(P) || unref(L), + "aria-busy": + unref(P) || (!unref(schemaReady) && !unref(F)), }, [ unref(L) ? (openBlock(), createElementBlock(`span`, nt, `正在读取服务器列表`)) : createCommentVNode(``, !0), - unref(F) && !unref(hn) && !unref(P) + !unref(schemaReady) || + (unref(F) && !unref(hn) && !unref(P)) ? (openBlock(), createBlock( p, { key: 1, - icon: `pi pi-exclamation-circle`, - title: `服务器加载失败`, - description: unref(F), + icon: unref(F) + ? `pi pi-exclamation-circle` + : `pi pi-spin pi-spinner`, + title: unref(F) ? `服务器加载失败` : `加载中…`, + description: unref(F) || `正在读取服务器列表`, }, { default: withCtx(() => [ - createVNode(u, { - label: `重新加载`, - icon: `pi pi-refresh`, - severity: `secondary`, - size: `small`, - onClick: Dn, - }), + unref(F) + ? createVNode(u, { + label: `重新加载`, + icon: `pi pi-refresh`, + severity: `secondary`, + size: `small`, + onClick: Dn, + }) + : createCommentVNode(``, !0), ]), _: 1, }, 8, - [`description`], + [`icon`, `title`, `description`], )) : (openBlock(), createBlock( @@ -2800,7 +2806,7 @@ var Ye = { 8, tt, ), - unref(xn).length + unref(schemaReady) && unref(xn).length ? (openBlock(), createElementBlock(`p`, Nt, [ ...(r[21] ||= [ @@ -2885,7 +2891,8 @@ var Ye = { "host-list-foot-empty": !unref(L) && !unref(mn), }, ]), - inert: unref(L) || !unref(mn) || void 0, + inert: + !unref(schemaReady) || unref(L) || !unref(mn) || void 0, }, [ createElementVNode(`div`, Ft, [ @@ -2952,7 +2959,7 @@ var Ye = { 10, Pt, ), - unref(F) && unref(hn) + unref(F) && unref(hn) && unref(schemaReady) ? (openBlock(), createBlock( Pe, diff --git a/app/config/openvue.js b/app/config/openvue.js index 6d337ab..d89152d 100644 --- a/app/config/openvue.js +++ b/app/config/openvue.js @@ -1,8 +1,21 @@ /** OpenVue 客户端统一配置:中文交互文案、蓝色主色以及 .app-dark 暗色切换。 */ import { definePreset } from "@openvue/themes"; import Aura from "@openvue/themes/aura"; +import { normalizeStyle } from "vue"; import locale from "./locale.zh-CN.json"; +// 列宽只取声明,不让接口数据、骨架或空态参与浏览器的自动列宽分配。 +// 百分比宽度原来用于自动布局的伸缩提示;固定布局改用原有最小宽度或统一默认宽度。 +function columnWidth(props) { + const style = normalizeStyle([props.style, props.headerStyle]) ?? {}; + const width = + style.width && !String(style.width).includes("%") ? style.width : undefined; + const minimum = style.minWidth ?? style["min-width"]; + return width && minimum + ? `max(${width}, ${minimum})` + : width || minimum || "12rem"; +} + // 使用 MIT 许可的主题包,并采用控制台的 blue/slate/zinc 配色。 // 只覆盖 Aura 的语义色阶,保留组件自身设计令牌;浅色用 slate,深色用 zinc。 const preset = definePreset(Aura, { @@ -46,8 +59,24 @@ export default { // 列标题与单元格同次渲染,移除手机卡片对后补布局标签的依赖。 pt: { datatable: { + table: ({ instance }) => ({ + style: { + tableLayout: "fixed", + width: "100%", + minWidth: `calc(${ + (instance.columns ?? []) + .filter((column) => !column.props?.hidden) + .map((column) => columnWidth(column.props ?? {})) + .join(" + ") || "0px" + })`, + }, + }), column: { + headerCell: ({ props }) => ({ + style: { width: columnWidth(props) }, + }), bodyCell: ({ props }) => ({ + style: { width: columnWidth(props) }, "data-label": typeof props.header === "string" ? props.header.trim() : "", }), diff --git a/tests/README.md b/tests/README.md index 85324ec..5850bc1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -62,3 +62,21 @@ CHROME_PATH=/absolute/path/to/chrome npm test 产品、工单、订单、日志、SSH 的卡片样式与全局手机标签规则保留,动态等级列改用单一标题来源。 另核对了 24 个 DataTable 组件、211 个 Column 调用点;复选框、骨架及空标题操作列继续生成空标签。 旧版在首次插入时缺标签的断言失败,修复后通过;这证明后补依赖已移除,不等于已证实或消除全站所有可见位移。 + +### 表格刷新布局修复边界 + +加载、空表和有数据不能切换表格布局模型;列宽只由列声明决定,不能由本次返回的内容重新分配。 +OpenVue 全局 PT 统一使用固定布局,合计现有列宽作为内部横滚的最小宽度,保留手机卡片与 `.no-stack`。 +原百分比列宽用于自动伸缩,现在采用该列已有最小宽度或统一 `12rem`,因此部分宽表的列比例和横滚距离会改变。 +HostList 的列集合随接口响应确定:首次响应前显示可见加载反馈,不先猜全列;刷新已有结果时保留当前表格。 +仅有缓存行而没有列定义时,失败必须可重试,不能永久加载;滚动快照在真实表格渲染后恢复。 + +本批次缺陷与覆盖清单: + +- 已修复:加载空态的 `100dvh` 高度、空态专用表格模型、自动列宽分配、动态列先猜后改,以及加载态提前消费滚动快照。 +- `review-content-geometry.e2e.test.mjs`:390/640/768/1440 四种宽度的 SSH 和订单深链接刷新、延迟响应、搜索完成、列位置/宽度与文档横向溢出;390/1440 的 HostList 动态列、空列表、旧接口缺少字段配置、失败重试、同表刷新和站内返回恢复 500px 滚动位置。 +- `review-list-density.e2e.test.mjs`:既有 44 种手机/桌面与浅暗主题场景,增加空表到有数据的列几何与单元格内按钮不溢出检查;按钮断言不涵盖所有链接或输入。 +- 只读审查覆盖本批次五个源码/测试文件全部变更、OpenVue 配置全文及 HostList、共享 CSS、列表缓存和实际 OpenVue PT 的相邻接缝;不是全仓代码审计完成声明。 +- 可复核扫描:`rg -n 'DataTable|createBlock\(.*DataTable' app`、`rg -n 'p-datatable-empty-message|p-datatable-mask|table-layout|row-actions' app/assets/styles`、`rg -n 'columnWidth|schemaReady|Tn' app/config/openvue.js app/components/HostList.vue`;24 个 Vue 文件中的 39 张表复用共享配置,空态布局分支已移除,手机卡片分支属于保留的响应式约定。 +- 有界待验:手机端既有隐藏字段、任意数据长度、第三方接口与真实支付/短信/实例控制,不由这些离线场景证明。 +- 本轮本地产物全套结果为 226 项中的 225 项通过;唯一未完成项为 Firefox 在启动空白页时即报 `Could not find profile folder`,不是业务断言失败。未跳过此项,也不能据此宣称全套通过;发布产物另行记录实际回归结果。 diff --git a/tests/review-content-geometry.e2e.test.mjs b/tests/review-content-geometry.e2e.test.mjs index e7621ff..838d12d 100644 --- a/tests/review-content-geometry.e2e.test.mjs +++ b/tests/review-content-geometry.e2e.test.mjs @@ -1,7 +1,347 @@ -/** 回归缺口:异步列表替换骨架时,已显示的相邻卡片不应因同样五行内容而上下跳动。 */ +/** 回归缺口:刷新时列宽、响应式结构和动态列不能猜测;同样五行内容替换骨架不应改变相邻卡片位置。 */ import assert from "node:assert/strict"; import { test } from "node:test"; -import { backendOrigin, login, scenario } from "./helpers/console-browser.mjs"; +import { + backendOrigin, + eventually, + login, + scenario, +} from "./helpers/console-browser.mjs"; + +// 通过外部 HTTP 延迟实际列表,首屏不得先猜测表头;成功后的刷新则保留原表与数据。 +for (const width of [390, 1440]) { + test( + `host columns wait for their schema and survive refresh at ${width}px`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.setViewportSize({ width, height: 900 }); + await page.emulateMedia({ reducedMotion: "reduce" }); + let release; + let held = new Promise((resolve) => { + release = resolve; + }); + let reads = 0; + let fail = false; + await page.route( + (url) => + url.origin === backendOrigin && + /^\/console\/v1\/mf_cloud\/7[0-9]{2}(?:\/flow)?$/.test( + url.pathname, + ), + (route) => + route.fulfill({ + json: { + status: 200, + msg: "", + data: { + image: { name: "Ubuntu 24.04" }, + total: 100, + used: 20, + leave: 80, + }, + }, + }), + ); + await page.route( + `${backendOrigin}/console/v1/home/host?*`, + async (route) => { + reads++; + await held; + return route.fulfill({ + json: { + status: fail ? 400 : 200, + msg: fail ? "本地列配置读取失败" : "", + data: { + count: 20, + list: Array.from({ length: 20 }, (_, index) => ({ + id: 701 + index, + name: `audit-host-${701 + index}`, + product_name: "本地验收云服务器", + module: "mf_cloud", + status: "Unpaid", + self_defined_field: { 101: "青岛" }, + })), + select_field: ["name", "product_name", "status"], + self_defined_field: [ + { id: 101, field_name: "部署区域", field_type: "text" }, + ], + }, + }, + }); + }, + ); + try { + await login(page, "/cloud-servers?m=73"); + await page.locator(".host-list-page").waitFor(); + await page.locator("#__nuxt-loader").waitFor({ state: "detached" }); + assert.equal(await page.locator(".list-table table").count(), 0); + await page.locator(".list-table .list-empty-title").waitFor(); + assert.equal( + await page.locator(".list-table").getAttribute("aria-busy"), + "true", + ); + release(); + await page.getByText("audit-host-701", { exact: true }).waitFor(); + const table = page.locator(".list-table table"); + const headers = () => table.locator("th").allTextContents(); + const accepted = await headers(); + assert.ok(accepted.includes("部署区域")); + assert.ok(accepted.includes("名称")); + for (const absent of [ + "IP", + "地域", + "计费方式", + "自动续费", + "基础信息", + ]) + assert.equal(accepted.includes(absent), false); + const identity = await table.elementHandle(); + held = new Promise((resolve) => { + release = resolve; + }); + await page.getByRole("button", { name: "搜索", exact: true }).click(); + await eventually( + () => reads, + (count) => count === 2, + "Refresh reaches HTTP boundary", + ); + assert.equal( + await identity.evaluate( + (element) => + element === document.querySelector(".list-table table"), + ), + true, + ); + assert.deepEqual(await headers(), accepted); + assert.equal( + await page.getByText("audit-host-701", { exact: true }).isVisible(), + true, + ); + release(); + await page.locator(".p-datatable-mask").waitFor({ state: "hidden" }); + await page.evaluate(() => + window.scrollTo({ top: 500, behavior: "instant" }), + ); + const savedScroll = await page.evaluate(() => scrollY); + assert.ok(savedScroll > 300); + // 真实站内链接离页再返回,复用行缓存但重新取得列元数据;失败不能永久停留在加载态。 + await page + .locator('nav a[href="/security/ssh-keys"]') + .evaluate((element) => element.click()); + await page.waitForURL((url) => url.pathname === "/security/ssh-keys"); + fail = true; + await page.goBack(); + await page + .getByRole("button", { name: "重新加载", exact: true }) + .waitFor(); + assert.equal(await page.locator(".list-table table").count(), 0); + assert.equal( + await page.locator(".list-table").getAttribute("aria-busy"), + "false", + ); + assert.equal( + await page.locator(".host-list-foot").getAttribute("inert"), + "", + ); + fail = false; + await page + .getByRole("button", { name: "重新加载", exact: true }) + .click(); + await page.getByText("audit-host-701", { exact: true }).waitFor(); + assert.deepEqual(await headers(), accepted); + await eventually( + () => page.evaluate(() => scrollY), + (position) => Math.abs(position - savedScroll) <= 1, + "Returning after metadata retry restores the saved list position", + ); + return { + width, + speculativeTable: false, + refreshRetainsTable: true, + metadataFailureCanRetry: true, + }; + } finally { + release(); + } + }), + ); +} + +for (const routePath of ["/cloud-servers", "/cloud-servers?m=74"]) { + test( + `empty host response without select_field resolves columns at ${routePath}`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.route( + `${backendOrigin}/console/v1/${routePath.includes("?") ? "home/host" : "host"}?*`, + (route) => + route.fulfill({ + json: { + status: 200, + msg: "", + data: { list: [], count: 0, server_page: "classified-v1" }, + }, + }), + ); + await login(page, routePath); + await page.locator(".list-table table").waitFor(); + await page.locator(".list-table .list-empty-title").waitFor(); + assert.equal( + await page.locator(".list-table").getAttribute("aria-busy"), + "false", + ); + const headers = await page.locator(".list-table th").allTextContents(); + assert.ok(headers.includes("IP")); + assert.equal(headers.includes("计费方式"), routePath.includes("?")); + return { + routePath, + emptySchemaResolved: true, + legacyColumnsRetained: true, + }; + }), + ); +} + +for (const [routePath, endpoint, loadedText] of [ + ["/security/ssh-keys", "ssh_key", "audit-01"], + ["/orders", "order", "本地订单布局验收"], +]) + for (const width of [390, 640, 768, 1440]) { + test( + `${routePath} refresh preserves its responsive layout while data loads at ${width}px`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.setViewportSize({ width, height: 900 }); + await page.emulateMedia({ reducedMotion: "reduce" }); + if (endpoint === "order") + await page.route(`${backendOrigin}/console/v1/order?*`, (route) => + route.fulfill({ + json: { + status: 200, + msg: "", + data: { + count: 1, + list: [ + { + id: 7, + create_time: 1730000000, + type: "new", + name: loadedText, + gateway_name: "本地支付方式", + status: "Unpaid", + amount: "20.00", + }, + ], + }, + }, + }), + ); + await login(page, routePath); + await page.getByText(loadedText, { exact: true }).waitFor(); + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route( + `${backendOrigin}/console/v1/${endpoint}?*`, + async (route) => { + await held; + return route.fallback(); + }, + ); + const geometry = () => + page.locator(".list-table").evaluate((panel) => { + const table = panel.querySelector("table"); + const header = table.querySelector("thead"); + return { + panelMinHeight: getComputedStyle(panel).minHeight, + panelHeight: panel.getBoundingClientRect().height, + tableWidth: table.getBoundingClientRect().width, + tableMinWidth: getComputedStyle(table).minWidth, + tableDisplay: getComputedStyle(table).display, + headerDisplay: getComputedStyle(header).display, + columns: [...header.querySelectorAll("th")].map((cell) => ({ + title: cell.textContent.trim(), + left: cell.getBoundingClientRect().left, + width: cell.getBoundingClientRect().width, + })), + viewport: innerWidth, + documentWidth: document.documentElement.scrollWidth, + }; + }); + let loading; + try { + // 真正刷新已登录的深链接;响应挂起时读取用户能看见的加载布局。 + await page.reload(); + await page.locator(".p-datatable-mask").waitFor(); + await page.locator("#__nuxt-loader").waitFor({ state: "detached" }); + loading = await geometry(); + } finally { + release(); + } + await page.getByText(loadedText, { exact: true }).waitFor(); + await page + .locator(".p-datatable-mask") + .waitFor({ state: "detached" }); + const ready = await geometry(); + t.diagnostic(JSON.stringify({ width, loading, ready })); + for (const dimension of [ + "panelMinHeight", + "tableDisplay", + "headerDisplay", + ]) + assert.equal( + loading[dimension], + ready[dimension], + `Loading must not switch ${dimension} before the same table receives data`, + ); + if (width < 640) { + assert.ok( + loading.tableWidth <= width, + "The loading card must fit the phone", + ); + assert.equal(loading.headerDisplay, "none"); + } else { + assert.equal(loading.columns.length, ready.columns.length); + for (let index = 0; index < loading.columns.length; index++) { + const before = loading.columns[index]; + const after = ready.columns[index]; + assert.equal(before.title, after.title); + for (const dimension of ["left", "width"]) + assert.ok( + Math.abs(before[dimension] - after[dimension]) <= 1, + `${before.title} ${dimension} changed: ${before[dimension]} -> ${after[dimension]}`, + ); + } + } + assert.equal(loading.documentWidth, loading.viewport); + assert.equal(ready.documentWidth, ready.viewport); + // 再次查询复用已显示的数据,不能恢复视口高度占位或另一套表格模式。 + if (endpoint === "ssh_key") + await page + .getByRole("button", { name: "搜索", exact: true }) + .click(); + else + await page + .getByRole("textbox", { name: "搜索订单", exact: true }) + .press("Enter"); + await eventually( + () => page.locator(".p-datatable-mask").count(), + (count) => count === 0, + "Refreshing a populated table completes", + ); + return { + routePath, + width, + loadingLayoutMatchesReady: true, + phoneFits: width >= 640 || loading.tableWidth <= width, + }; + }), + ); + } for (const width of [1440, 390]) { test( diff --git a/tests/review-list-density.e2e.test.mjs b/tests/review-list-density.e2e.test.mjs index 1304f86..5eb9518 100644 --- a/tests/review-list-density.e2e.test.mjs +++ b/tests/review-list-density.e2e.test.mjs @@ -164,6 +164,29 @@ async function measure(page) { articleCount: document.querySelectorAll(".news-card, .notice-item") .length, tableCount: document.querySelectorAll(".list-table .p-datatable").length, + columns: [...document.querySelectorAll(".p-datatable-thead th")].map( + (cell) => ({ + title: cell.textContent.trim(), + left: cell.getBoundingClientRect().left, + width: cell.getBoundingClientRect().width, + }), + ), + overflowingButtons: [ + ...document.querySelectorAll(".p-datatable-tbody td button"), + ] + .filter((button) => { + const box = button.getBoundingClientRect(); + const cell = button.closest("td").getBoundingClientRect(); + return ( + box.width > 0 && + box.height > 0 && + (box.left < cell.left - 1 || box.right > cell.right + 1) + ); + }) + .map( + (button) => + button.textContent.trim() || button.getAttribute("aria-label"), + ), rowDisplay: row ? getComputedStyle(row).display : null, actionLabelLines: [ ...document.querySelectorAll(".row-actions .p-button-label"), @@ -283,6 +306,28 @@ for (const [name, routePath, kind, visibleText] of pages) { if (empty.emptyIcon?.fontSize !== "16px") violations.push("empty: icon density"); if (kind === "table") { + if (filled.overflowingButtons.length) + violations.push( + `filled: buttons outside cell ${filled.overflowingButtons.join(", ")}`, + ); + if (width >= 640) { + if (empty.columns.length !== filled.columns.length) + violations.push("filled: column set changed"); + for (let index = 0; index < empty.columns.length; index++) { + const before = empty.columns[index]; + const after = filled.columns[index]; + if ( + !after || + ["left", "width"].some( + (dimension) => + Math.abs(before[dimension] - after[dimension]) > 1, + ) + ) + violations.push( + `filled: column geometry changed ${before.title}`, + ); + } + } if (filled.tableCount !== 1) violations.push("filled: missing actual table"); if (filled.header?.background !== "rgba(0, 0, 0, 0)")