通过表格原生渲染统一生成手机布局标签,删除全页监听与后补扫描。动态等级列使用单一标题并保留颜色。 补齐首次插入、深链刷新、动态表头与首页延迟响应几何回归。全量214项通过,最终专项16项通过;不声明全站所有场景零位移。
This commit is contained in:
1 parent
30cbfd3a9c
commit
1c0c0f16bc
6 files changed
+241
-58
No files matched your search
@@ -326,32 +326,17 @@ var E = {
|
|||||||
o,
|
o,
|
||||||
{
|
{
|
||||||
key: t.id,
|
key: t.id,
|
||||||
|
header: t.name,
|
||||||
|
headerStyle: {
|
||||||
|
color:
|
||||||
|
t.background_color ||
|
||||||
|
void 0,
|
||||||
|
},
|
||||||
style: {
|
style: {
|
||||||
"min-width": `100px`,
|
"min-width": `100px`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: withCtx(
|
|
||||||
() => [
|
|
||||||
createElementVNode(
|
|
||||||
`span`,
|
|
||||||
{
|
|
||||||
style:
|
|
||||||
normalizeStyle(
|
|
||||||
{
|
|
||||||
color:
|
|
||||||
t.background_color ||
|
|
||||||
void 0,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
toDisplayString(
|
|
||||||
t.name,
|
|
||||||
),
|
|
||||||
5,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: withCtx(
|
body: withCtx(
|
||||||
({ data: n }) => [
|
({ data: n }) => [
|
||||||
createTextVNode(
|
createTextVNode(
|
||||||
|
|||||||
@@ -43,4 +43,15 @@ export default {
|
|||||||
ripple: true,
|
ripple: true,
|
||||||
locale,
|
locale,
|
||||||
theme: { preset, options: { darkModeSelector: ".app-dark" } },
|
theme: { preset, options: { darkModeSelector: ".app-dark" } },
|
||||||
|
// 列标题与单元格同次渲染,移除手机卡片对后补布局标签的依赖。
|
||||||
|
pt: {
|
||||||
|
datatable: {
|
||||||
|
column: {
|
||||||
|
bodyCell: ({ props }) => ({
|
||||||
|
"data-label":
|
||||||
|
typeof props.header === "string" ? props.header.trim() : "",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -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();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -41,6 +41,8 @@ CHROME_PATH=/absolute/path/to/chrome npm test
|
|||||||
- `review-theme.e2e.test.mjs`:认证页三态主题文案、持久化与跟随系统变化。
|
- `review-theme.e2e.test.mjs`:认证页三态主题文案、持久化与跟随系统变化。
|
||||||
- `review-startup.e2e.test.mjs`:冷刷新在入口脚本、公共配置和页面样式延迟时保留加载占位,业务启动前恢复主题;字体延迟不阻塞交互,配置失败可重试,会员深链接刷新及站内导航正常交接。
|
- `review-startup.e2e.test.mjs`:冷刷新在入口脚本、公共配置和页面样式延迟时保留加载占位,业务启动前恢复主题;字体延迟不阻塞交互,配置失败可重试,会员深链接刷新及站内导航正常交接。
|
||||||
- `review-ui-foundation.e2e.test.mjs`:OpenVue/Deni 的手机导航焦点约束、Escape/离页关闭、1024/1025 断点与桌面恢复、手机表格字段标签和可操作的窄屏编辑弹窗。
|
- `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-layout.e2e.test.mjs`:维护条随内容与视口变化、超长通知内部滚动、帮助/购物车/机型/配置摘要吸顶避让,退出布局后的偏移清理与导航单一键盘焦点;首页用户 ID 复制按钮在各宽度下不拆行(该问题只在 Firefox 出现,场景额外启动 Playwright Firefox,需先执行 `npx playwright install firefox`)。
|
||||||
- `review-style-fields.e2e.test.mjs`:深色商品说明对比度、提现样式隔离、实名认证与转账凭证上传名称;实际升级弹窗的窄屏网格。
|
- `review-style-fields.e2e.test.mjs`:深色商品说明对比度、提现样式隔离、实名认证与转账凭证上传名称;实际升级弹窗的窄屏网格。
|
||||||
- `review-style-motion.e2e.test.mjs`:实例监控 Canvas 初始/刷新/加载反馈跟随系统减动偏好,并验证偏好切换及离页后无运行时错误。
|
- `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`;仍需先构建当前源码。
|
可针对单组执行,例如 `node --test tests/review-routes.e2e.test.mjs`;仍需先构建当前源码。
|
||||||
随机密码场景只记录性质与是否一致,不把密码或安全随机值写入验收附件。
|
随机密码场景只记录性质与是否一致,不把密码或安全随机值写入验收附件。
|
||||||
真实服务器联调、真实支付渠道、短信/邮件投递、第三方 CAPTCHA 及完整安全挑战流程不在离线验收范围内。
|
真实服务器联调、真实支付渠道、短信/邮件投递、第三方 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 };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
/** OpenVue/Deni 迁移的实际导航、移动表格与弹窗行为,不断言包名或源码形状。 */
|
/** OpenVue/Deni 迁移的实际导航、移动表格与弹窗行为,不断言包名或源码形状。 */
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
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) =>
|
const navigation = (page) =>
|
||||||
page.getByRole("navigation", { name: "会员中心导航", exact: true });
|
page.getByRole("navigation", { name: "会员中心导航", exact: true });
|
||||||
@@ -170,6 +175,22 @@ test(
|
|||||||
scenario(t, {}, async (page) => {
|
scenario(t, {}, async (page) => {
|
||||||
await page.setViewportSize({ width: 375, height: 812 });
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
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");
|
await login(page, "/security/ssh-keys");
|
||||||
const name = page.getByText("audit-01", { exact: true });
|
const name = page.getByText("audit-01", { exact: true });
|
||||||
await name.waitFor();
|
await name.waitFor();
|
||||||
@@ -182,6 +203,25 @@ test(
|
|||||||
});
|
});
|
||||||
assert.equal(label.value, "名称");
|
assert.equal(label.value, "名称");
|
||||||
assert.equal(label.displayed.includes("名称"), true);
|
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
|
await page
|
||||||
.getByRole("button", { name: "编辑", exact: true })
|
.getByRole("button", { name: "编辑", exact: true })
|
||||||
.first()
|
.first()
|
||||||
@@ -202,8 +242,94 @@ test(
|
|||||||
assert.equal(fits, true);
|
assert.equal(fits, true);
|
||||||
return {
|
return {
|
||||||
tableFieldLabels: true,
|
tableFieldLabels: true,
|
||||||
|
initialTableGeometryReady: true,
|
||||||
|
refreshKeepsInitialTableGeometry: true,
|
||||||
editableDialogFits: true,
|
editableDialogFits: true,
|
||||||
pageHasNoHorizontalOverflow: 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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
Reference in new issue
Block a user