修复表格刷新布局跳动并补齐多端回归验收
前端验证 / verify (push) Failing after 4m40s

This commit is contained in:
cieanyau committed 2026-11-07 07:08:48 +08:00
1 parent 1c0c0f16bc
commit 90989f8f8a
6 files changed
+480 -55

No files matched your search

+18
View File
@@ -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`,不是业务断言失败。未跳过此项,也不能据此宣称全套通过;发布产物另行记录实际回归结果。
+342 -2
View File
@@ -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(
+45
View File
@@ -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)")