From 1c0c0f16bc4101146ec9d6c55a038b680ad82a2f Mon Sep 17 00:00:00 2001 From: CieanYau Date: Sat, 7 Nov 2026 06:21:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B8=83=E5=B1=80=EF=BC=9A?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E7=94=9F=E6=88=90=E8=A1=A8=E6=A0=BC=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E5=B9=B6=E7=A7=BB=E9=99=A4=E8=B7=A8=E5=B8=A7=E8=A1=A5?= =?UTF-8?q?=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过表格原生渲染统一生成手机布局标签,删除全页监听与后补扫描。动态等级列使用单一标题并保留颜色。 补齐首次插入、深链刷新、动态表头与首页延迟响应几何回归。全量214项通过,最终专项16项通过;不声明全站所有场景零位移。 --- app/components/ClientLevelDialog.vue | 27 +---- app/config/openvue.js | 11 ++ app/plugins/04-table-labels.client.js | 36 ------ tests/README.md | 11 ++ tests/review-content-geometry.e2e.test.mjs | 86 ++++++++++++++ tests/review-ui-foundation.e2e.test.mjs | 128 ++++++++++++++++++++- 6 files changed, 241 insertions(+), 58 deletions(-) delete mode 100644 app/plugins/04-table-labels.client.js create mode 100644 tests/review-content-geometry.e2e.test.mjs diff --git a/app/components/ClientLevelDialog.vue b/app/components/ClientLevelDialog.vue index 5ab75a8..04e24a0 100644 --- a/app/components/ClientLevelDialog.vue +++ b/app/components/ClientLevelDialog.vue @@ -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( diff --git a/app/config/openvue.js b/app/config/openvue.js index c4cf20f..6d337ab 100644 --- a/app/config/openvue.js +++ b/app/config/openvue.js @@ -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() : "", + }), + }, + }, + }, }; diff --git a/app/plugins/04-table-labels.client.js b/app/plugins/04-table-labels.client.js deleted file mode 100644 index 720bc45..0000000 --- a/app/plugins/04-table-labels.client.js +++ /dev/null @@ -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(); - }, -}); diff --git a/tests/README.md b/tests/README.md index c61c5f1..85324ec 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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 调用点;复选框、骨架及空标题操作列继续生成空标签。 +旧版在首次插入时缺标签的断言失败,修复后通过;这证明后补依赖已移除,不等于已证实或消除全站所有可见位移。 diff --git a/tests/review-content-geometry.e2e.test.mjs b/tests/review-content-geometry.e2e.test.mjs new file mode 100644 index 0000000..e7621ff --- /dev/null +++ b/tests/review-content-geometry.e2e.test.mjs @@ -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 }; + }), + ); +} diff --git a/tests/review-ui-foundation.e2e.test.mjs b/tests/review-ui-foundation.e2e.test.mjs index 8adf743..605a657 100644 --- a/tests/review-ui-foundation.e2e.test.mjs +++ b/tests/review-ui-foundation.e2e.test.mjs @@ -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, + }; + }), +);