修复布局:同步生成表格标签并移除跨帧补写
前端验证 / verify (push) Failing after 3m34s

通过表格原生渲染统一生成手机布局标签,删除全页监听与后补扫描。动态等级列使用单一标题并保留颜色。

补齐首次插入、深链刷新、动态表头与首页延迟响应几何回归。全量214项通过,最终专项16项通过;不声明全站所有场景零位移。
This commit is contained in:
cieanyau committed 2026-11-07 06:21:31 +08:00
1 parent 30cbfd3a9c
commit 1c0c0f16bc
6 files changed
+241 -58

No files matched your search

+6 -21
View File
@@ -326,32 +326,17 @@ var E = {
o,
{
key: t.id,
header: t.name,
headerStyle: {
color:
t.background_color ||
void 0,
},
style: {
"min-width": `100px`,
},
},
{
header: withCtx(
() => [
createElementVNode(
`span`,
{
style:
normalizeStyle(
{
color:
t.background_color ||
void 0,
},
),
},
toDisplayString(
t.name,
),
5,
),
],
),
body: withCtx(
({ data: n }) => [
createTextVNode(
+11
View File
@@ -43,4 +43,15 @@ export default {
ripple: true,
locale,
theme: { preset, options: { darkModeSelector: ".app-dark" } },
// 列标题与单元格同次渲染,移除手机卡片对后补布局标签的依赖。
pt: {
datatable: {
column: {
bodyCell: ({ props }) => ({
"data-label":
typeof props.header === "string" ? props.header.trim() : "",
}),
},
},
},
};
-36
View File
@@ -1,36 +0,0 @@
/** 窄屏把表格行堆叠成卡片时,用列标题给每个单元格补 data-label,供 CSS 显示字段名。 */
import { defineNuxtPlugin } from "#app";
function labelTable(table) {
const names = [...table.querySelectorAll(":scope > thead > tr > th")].map(
(th) =>
th.querySelector(".p-datatable-column-title")?.textContent.trim() ?? "",
);
for (const row of table.querySelectorAll(":scope > tbody > tr")) {
[...row.children].forEach((cell, index) => {
const name = names[index] ?? "";
if (cell.dataset.label !== name) cell.dataset.label = name;
});
}
}
export default defineNuxtPlugin({
name: "table-cell-labels",
setup() {
let queued = false;
const run = () => {
queued = false;
document.querySelectorAll("table.p-datatable-table").forEach(labelTable);
};
const schedule = () => {
if (queued) return;
queued = true;
requestAnimationFrame(run);
};
new MutationObserver(schedule).observe(document.body, {
childList: true,
subtree: true,
});
schedule();
},
});
+11
View File
@@ -41,6 +41,8 @@ CHROME_PATH=/absolute/path/to/chrome npm test
- `review-theme.e2e.test.mjs`:认证页三态主题文案、持久化与跟随系统变化。
- `review-startup.e2e.test.mjs`:冷刷新在入口脚本、公共配置和页面样式延迟时保留加载占位,业务启动前恢复主题;字体延迟不阻塞交互,配置失败可重试,会员深链接刷新及站内导航正常交接。
- `review-ui-foundation.e2e.test.mjs`:OpenVue/Deni 的手机导航焦点约束、Escape/离页关闭、1024/1025 断点与桌面恢复、手机表格字段标签和可操作的窄屏编辑弹窗。
- 手机表格额外检查首次插入和深链接刷新时标签已完整,无需等下一帧补写;动态等级表头同时验证手机字段名、桌面单一标题与原有颜色。
- `review-content-geometry.e2e.test.mjs`:延迟首页工单和公告响应,验证桌面/手机在同样五行数据替换骨架后,卡片高度与相邻位置不变;不代表任意列表长度或外部图片都零位移。
- `review-style-layout.e2e.test.mjs`:维护条随内容与视口变化、超长通知内部滚动、帮助/购物车/机型/配置摘要吸顶避让,退出布局后的偏移清理与导航单一键盘焦点;首页用户 ID 复制按钮在各宽度下不拆行(该问题只在 Firefox 出现,场景额外启动 Playwright Firefox,需先执行 `npx playwright install firefox`)。
- `review-style-fields.e2e.test.mjs`:深色商品说明对比度、提现样式隔离、实名认证与转账凭证上传名称;实际升级弹窗的窄屏网格。
- `review-style-motion.e2e.test.mjs`:实例监控 Canvas 初始/刷新/加载反馈跟随系统减动偏好,并验证偏好切换及离页后无运行时错误。
@@ -51,3 +53,12 @@ CHROME_PATH=/absolute/path/to/chrome npm test
可针对单组执行,例如 `node --test tests/review-routes.e2e.test.mjs`;仍需先构建当前源码。
随机密码场景只记录性质与是否一致,不把密码或安全随机值写入验收附件。
真实服务器联调、真实支付渠道、短信/邮件投递、第三方 CAPTCHA 及完整安全挑战流程不在离线验收范围内。
### 布局标签修复边界
几何布局所需的字段应与内容同次渲染,不能由全页观察器在下一帧补写。
本次使用 OpenVue 原生单元格配置生成 `data-label`,删除后补插件;不增加等待、遮罩或 DOM 回退。
可用 `rg -n 'data-label|dataset\.label|table-cell-labels' app` 复核唯一生产者及 CSS 消费者:
产品、工单、订单、日志、SSH 的卡片样式与全局手机标签规则保留,动态等级列改用单一标题来源。
另核对了 24 个 DataTable 组件、211 个 Column 调用点;复选框、骨架及空标题操作列继续生成空标签。
旧版在首次插入时缺标签的断言失败,修复后通过;这证明后补依赖已移除,不等于已证实或消除全站所有可见位移。
@@ -0,0 +1,86 @@
/** 回归缺口:异步列表替换骨架时,已显示的相邻卡片不应因同样五行内容而上下跳动。 */
import assert from "node:assert/strict";
import { test } from "node:test";
import { backendOrigin, login, scenario } from "./helpers/console-browser.mjs";
for (const width of [1440, 390]) {
test(
`home list reserves its rendered row geometry at ${width}px`,
{ timeout: 30000 },
(t) =>
scenario(t, {}, async (page) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
let release;
const held = new Promise((resolve) => {
release = resolve;
});
await page.route(`${backendOrigin}/console/v1/**`, async (route) => {
const path = new URL(route.request().url()).pathname;
if (![/\/ticket$/, /\/announcement$/].some((end) => end.test(path)))
return route.fallback();
await held;
return route.fulfill({
json: {
status: 200,
msg: "",
data: {
count: 5,
list: Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
title: `本地布局验收 ${index + 1}`,
create_time: 1730000000,
post_time: 1730000000,
status: "待回复",
color: "#0284c7",
})),
},
},
});
});
const geometry = () =>
page.locator(".home-tickets, .home-notices").evaluateAll((cards) =>
cards.map((card) => {
const box = card.getBoundingClientRect();
return {
card: card.classList.contains("home-tickets")
? "tickets"
: "notices",
top: box.top + scrollY,
height: box.height,
};
}),
);
let before;
try {
await login(page, "/");
await page
.locator(".home-tickets .home-row-skeleton")
.first()
.waitFor();
await page.evaluate(() => document.fonts.ready);
// 只比较本次控制的两个响应,避免混入其他独立面板的正常数据替换。
await page.waitForFunction(
() =>
!document.querySelector(
".home-servers .home-row-skeleton, .home-expiring .home-row-skeleton, .home-referral .p-skeleton",
),
);
before = await geometry();
} finally {
release();
}
await page.locator(".home-notices .notice-row").last().waitFor();
await page.locator(".home-tickets .home-row").last().waitFor();
const after = await geometry();
for (let index = 0; index < before.length; index++) {
for (const dimension of ["height", "top"])
assert.ok(
Math.abs(before[index][dimension] - after[index][dimension]) <= 1,
`${before[index].card} ${dimension} changed: ${before[index][dimension]} -> ${after[index][dimension]}`,
);
}
return { width, sameRowGeometryAfterData: true };
}),
);
}
+127 -1
View File
@@ -1,7 +1,12 @@
/** OpenVue/Deni 迁移的实际导航、移动表格与弹窗行为,不断言包名或源码形状。 */
import assert from "node:assert/strict";
import { test } from "node:test";
import { eventually, login, scenario } from "./helpers/console-browser.mjs";
import {
backendOrigin,
eventually,
login,
scenario,
} from "./helpers/console-browser.mjs";
const navigation = (page) =>
page.getByRole("navigation", { name: "会员中心导航", exact: true });
@@ -170,6 +175,22 @@ test(
scenario(t, {}, async (page) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.addInitScript(() => {
// 读取浏览器收到的首批单元格,不等待后续帧补写属性,也不改动产品 DOM。
window.initialTableCells = null;
const observer = new MutationObserver(() => {
const cells = document.querySelectorAll(
".p-datatable-tbody > tr:not(.p-datatable-empty-message) > td",
);
if (!cells.length) return;
window.initialTableCells = [...cells].map((cell) => ({
label: cell.getAttribute("data-label"),
visible: getComputedStyle(cell).display !== "none",
}));
observer.disconnect();
});
observer.observe(document, { childList: true, subtree: true });
});
await login(page, "/security/ssh-keys");
const name = page.getByText("audit-01", { exact: true });
await name.waitFor();
@@ -182,6 +203,25 @@ test(
});
assert.equal(label.value, "名称");
assert.equal(label.displayed.includes("名称"), true);
const initial = await page.evaluate(() => window.initialTableCells);
assert.ok(initial?.length > 0);
assert.equal(
initial.every((cell) => cell.label !== null),
true,
"Mobile cells must carry layout labels when inserted, without another frame",
);
assert.ok(
initial.some((cell) => cell.label === "名称" && cell.visible),
"The primary field must be visible in the initial card geometry",
);
await page.reload();
await name.waitFor();
const refreshed = await page.evaluate(() => window.initialTableCells);
assert.deepEqual(
refreshed,
initial,
"A deep-link refresh must insert the same complete mobile card geometry",
);
await page
.getByRole("button", { name: "编辑", exact: true })
.first()
@@ -202,8 +242,94 @@ test(
assert.equal(fits, true);
return {
tableFieldLabels: true,
initialTableGeometryReady: true,
refreshKeepsInitialTableGeometry: true,
editableDialogFits: true,
pageHasNoHorizontalOverflow: true,
};
}),
);
test(
"dynamic columns share a single colored title and mobile field label",
{ timeout: 30000 },
(t) =>
scenario(t, {}, async (page) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.route(`${backendOrigin}/console/v1/**`, async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/account")) {
const response = await route.fetch();
const json = await response.json();
json.data.account.customfield = {
idcsmart_client_level: { id: 1, name: "本地会员" },
};
return route.fulfill({ response, json });
}
if (path.endsWith("/country"))
return route.fulfill({
json: { status: 200, msg: "", data: { list: [] } },
});
if (!path.endsWith("/client_level")) return route.fallback();
return route.fulfill({
json: {
status: 200,
msg: "",
data: {
level_id: 1,
levels: [
{
id: 1,
name: "本地会员",
background_color: "#0284c7",
discount_list: [
{ product_name: "本地云产品", discount_percent: 95 },
],
},
],
},
},
});
});
await login(page, "/account");
await page.getByRole("button", { name: "本地会员", exact: true }).click();
const dialog = page.getByRole("dialog", {
name: "等级策略",
exact: true,
});
// 手机字段名来自 ::before,参与无障碍名称;按实际折扣文字定位单元格再核对标签。
const discount = dialog.locator("td").filter({ hasText: /^95%$/ });
await discount.waitFor();
assert.equal(await discount.getAttribute("data-label"), "本地会员");
// 手机表头隐藏,切回桌面核对用户实际看到的自定义标题,而非隐藏元素的无障碍名称。
await page.setViewportSize({ width: 1440, height: 900 });
const header = dialog.getByRole("columnheader", {
name: "本地会员",
exact: true,
});
await header.waitFor();
assert.equal(
await header.count(),
1,
"Dynamic columns must display a single title",
);
assert.equal(
await header.evaluate((element) => getComputedStyle(element).color),
"rgb(2, 132, 199)",
"The server-provided level color must remain visible",
);
assert.equal(
await dialog
.locator("td")
.filter({ hasText: /^本地云产品$/ })
.getAttribute("data-label"),
"商品",
);
return {
dynamicHeaderLabel: true,
singleTitle: true,
levelColorPreserved: true,
};
}),
);