1 parent
1c0c0f16bc
commit
90989f8f8a
6 files changed
+475
-50
No files matched your search
@@ -1252,19 +1252,15 @@ video {
|
|||||||
.p-datatable-header-cell {
|
.p-datatable-header-cell {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.p-datatable-tbody > tr > td {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
/* 视口条件 (width<=768px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */
|
/* 视口条件 (width<=768px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */
|
||||||
@media (width<=768px) {
|
@media (width<=768px) {
|
||||||
.p-datatable-table:not(:has(.p-datatable-empty-message)) {
|
/* 空表、加载与有数据共用断点布局,不能随接口结果切换表头和最小宽度。 */
|
||||||
|
.p-datatable-table {
|
||||||
min-width: 680px;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -1961,9 +1957,6 @@ video {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.list-table:has(.p-datatable-mask):has(.p-datatable-empty-message) {
|
|
||||||
min-height: 100dvh;
|
|
||||||
}
|
|
||||||
.list-table .p-datatable-header-cell {
|
.list-table .p-datatable-header-cell {
|
||||||
color: var(--p-text-color);
|
color: var(--p-text-color);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -2049,6 +2042,9 @@ video {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.p-datatable .row-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.host-list-page .host-row-placeholder {
|
.host-list-page .host-row-placeholder {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -2070,16 +2066,6 @@ video {
|
|||||||
}
|
}
|
||||||
/* 视口条件 (width<=768px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */
|
/* 视口条件 (width<=768px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */
|
||||||
@media (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 {
|
.list-head {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-26
@@ -11,7 +11,6 @@ import "~/assets/styles/HostList.B3U15sJm.css";
|
|||||||
import { nextTick } from "vue";
|
import { nextTick } from "vue";
|
||||||
import { toDisplayString } from "vue";
|
import { toDisplayString } from "vue";
|
||||||
import { renderList } from "vue";
|
import { renderList } from "vue";
|
||||||
import { onMounted } from "vue";
|
|
||||||
import { normalizeClass } from "vue";
|
import { normalizeClass } from "vue";
|
||||||
import { onBeforeUnmount } from "vue";
|
import { onBeforeUnmount } from "vue";
|
||||||
import { renderSlot } from "vue";
|
import { renderSlot } from "vue";
|
||||||
@@ -667,7 +666,9 @@ var Ye = {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
((gn.value = T?.page ?? 1), (I.value = T?.limit ?? 20));
|
((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(() =>
|
bn = computed(() =>
|
||||||
Array.from(
|
Array.from(
|
||||||
{
|
{
|
||||||
@@ -705,19 +706,18 @@ var Ye = {
|
|||||||
(onBeforeUnmount(() => {
|
(onBeforeUnmount(() => {
|
||||||
(O++, En(), (Tn = null));
|
(O++, En(), (Tn = null));
|
||||||
}),
|
}),
|
||||||
onMounted(async () => {
|
// 表格与列定义已真实渲染后恢复位置;短加载态不能提前消费长列表的滚动快照。
|
||||||
(await nextTick(),
|
watch(
|
||||||
requestAnimationFrame(() =>
|
() => schemaReady.value && hn.value && !P.value,
|
||||||
requestAnimationFrame(() => {
|
async (ready) => {
|
||||||
(Tn !== null &&
|
if (!ready || Tn === null) return;
|
||||||
window.scrollTo({
|
await nextTick();
|
||||||
top: Tn,
|
if (Tn === null || !schemaReady.value || !hn.value) return;
|
||||||
behavior: `instant`,
|
window.scrollTo({ top: Tn, behavior: `instant` });
|
||||||
}),
|
Tn = null;
|
||||||
(Tn = null));
|
},
|
||||||
}),
|
{ flush: `post` },
|
||||||
));
|
),
|
||||||
}),
|
|
||||||
watch(
|
watch(
|
||||||
() => w.token,
|
() => w.token,
|
||||||
(e) => {
|
(e) => {
|
||||||
@@ -1574,37 +1574,43 @@ var Ye = {
|
|||||||
`div`,
|
`div`,
|
||||||
{
|
{
|
||||||
class: `list-table`,
|
class: `list-table`,
|
||||||
"aria-busy": unref(P) || unref(L),
|
"aria-busy":
|
||||||
|
unref(P) || (!unref(schemaReady) && !unref(F)),
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
unref(L)
|
unref(L)
|
||||||
? (openBlock(),
|
? (openBlock(),
|
||||||
createElementBlock(`span`, nt, `正在读取服务器列表`))
|
createElementBlock(`span`, nt, `正在读取服务器列表`))
|
||||||
: createCommentVNode(``, !0),
|
: createCommentVNode(``, !0),
|
||||||
unref(F) && !unref(hn) && !unref(P)
|
!unref(schemaReady) ||
|
||||||
|
(unref(F) && !unref(hn) && !unref(P))
|
||||||
? (openBlock(),
|
? (openBlock(),
|
||||||
createBlock(
|
createBlock(
|
||||||
p,
|
p,
|
||||||
{
|
{
|
||||||
key: 1,
|
key: 1,
|
||||||
icon: `pi pi-exclamation-circle`,
|
icon: unref(F)
|
||||||
title: `服务器加载失败`,
|
? `pi pi-exclamation-circle`
|
||||||
description: unref(F),
|
: `pi pi-spin pi-spinner`,
|
||||||
|
title: unref(F) ? `服务器加载失败` : `加载中…`,
|
||||||
|
description: unref(F) || `正在读取服务器列表`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: withCtx(() => [
|
default: withCtx(() => [
|
||||||
createVNode(u, {
|
unref(F)
|
||||||
|
? createVNode(u, {
|
||||||
label: `重新加载`,
|
label: `重新加载`,
|
||||||
icon: `pi pi-refresh`,
|
icon: `pi pi-refresh`,
|
||||||
severity: `secondary`,
|
severity: `secondary`,
|
||||||
size: `small`,
|
size: `small`,
|
||||||
onClick: Dn,
|
onClick: Dn,
|
||||||
}),
|
})
|
||||||
|
: createCommentVNode(``, !0),
|
||||||
]),
|
]),
|
||||||
_: 1,
|
_: 1,
|
||||||
},
|
},
|
||||||
8,
|
8,
|
||||||
[`description`],
|
[`icon`, `title`, `description`],
|
||||||
))
|
))
|
||||||
: (openBlock(),
|
: (openBlock(),
|
||||||
createBlock(
|
createBlock(
|
||||||
@@ -2800,7 +2806,7 @@ var Ye = {
|
|||||||
8,
|
8,
|
||||||
tt,
|
tt,
|
||||||
),
|
),
|
||||||
unref(xn).length
|
unref(schemaReady) && unref(xn).length
|
||||||
? (openBlock(),
|
? (openBlock(),
|
||||||
createElementBlock(`p`, Nt, [
|
createElementBlock(`p`, Nt, [
|
||||||
...(r[21] ||= [
|
...(r[21] ||= [
|
||||||
@@ -2885,7 +2891,8 @@ var Ye = {
|
|||||||
"host-list-foot-empty": !unref(L) && !unref(mn),
|
"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, [
|
createElementVNode(`div`, Ft, [
|
||||||
@@ -2952,7 +2959,7 @@ var Ye = {
|
|||||||
10,
|
10,
|
||||||
Pt,
|
Pt,
|
||||||
),
|
),
|
||||||
unref(F) && unref(hn)
|
unref(F) && unref(hn) && unref(schemaReady)
|
||||||
? (openBlock(),
|
? (openBlock(),
|
||||||
createBlock(
|
createBlock(
|
||||||
Pe,
|
Pe,
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
/** OpenVue 客户端统一配置:中文交互文案、蓝色主色以及 .app-dark 暗色切换。 */
|
/** OpenVue 客户端统一配置:中文交互文案、蓝色主色以及 .app-dark 暗色切换。 */
|
||||||
import { definePreset } from "@openvue/themes";
|
import { definePreset } from "@openvue/themes";
|
||||||
import Aura from "@openvue/themes/aura";
|
import Aura from "@openvue/themes/aura";
|
||||||
|
import { normalizeStyle } from "vue";
|
||||||
import locale from "./locale.zh-CN.json";
|
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 配色。
|
// 使用 MIT 许可的主题包,并采用控制台的 blue/slate/zinc 配色。
|
||||||
// 只覆盖 Aura 的语义色阶,保留组件自身设计令牌;浅色用 slate,深色用 zinc。
|
// 只覆盖 Aura 的语义色阶,保留组件自身设计令牌;浅色用 slate,深色用 zinc。
|
||||||
const preset = definePreset(Aura, {
|
const preset = definePreset(Aura, {
|
||||||
@@ -46,8 +59,24 @@ export default {
|
|||||||
// 列标题与单元格同次渲染,移除手机卡片对后补布局标签的依赖。
|
// 列标题与单元格同次渲染,移除手机卡片对后补布局标签的依赖。
|
||||||
pt: {
|
pt: {
|
||||||
datatable: {
|
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: {
|
column: {
|
||||||
|
headerCell: ({ props }) => ({
|
||||||
|
style: { width: columnWidth(props) },
|
||||||
|
}),
|
||||||
bodyCell: ({ props }) => ({
|
bodyCell: ({ props }) => ({
|
||||||
|
style: { width: columnWidth(props) },
|
||||||
"data-label":
|
"data-label":
|
||||||
typeof props.header === "string" ? props.header.trim() : "",
|
typeof props.header === "string" ? props.header.trim() : "",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -62,3 +62,21 @@ CHROME_PATH=/absolute/path/to/chrome npm test
|
|||||||
产品、工单、订单、日志、SSH 的卡片样式与全局手机标签规则保留,动态等级列改用单一标题来源。
|
产品、工单、订单、日志、SSH 的卡片样式与全局手机标签规则保留,动态等级列改用单一标题来源。
|
||||||
另核对了 24 个 DataTable 组件、211 个 Column 调用点;复选框、骨架及空标题操作列继续生成空标签。
|
另核对了 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`,不是业务断言失败。未跳过此项,也不能据此宣称全套通过;发布产物另行记录实际回归结果。
|
||||||
@@ -1,7 +1,347 @@
|
|||||||
/** 回归缺口:异步列表替换骨架时,已显示的相邻卡片不应因同样五行内容而上下跳动。 */
|
/** 回归缺口:刷新时列宽、响应式结构和动态列不能猜测;同样五行内容替换骨架不应改变相邻卡片位置。 */
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
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]) {
|
for (const width of [1440, 390]) {
|
||||||
test(
|
test(
|
||||||
|
|||||||
@@ -164,6 +164,29 @@ async function measure(page) {
|
|||||||
articleCount: document.querySelectorAll(".news-card, .notice-item")
|
articleCount: document.querySelectorAll(".news-card, .notice-item")
|
||||||
.length,
|
.length,
|
||||||
tableCount: document.querySelectorAll(".list-table .p-datatable").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,
|
rowDisplay: row ? getComputedStyle(row).display : null,
|
||||||
actionLabelLines: [
|
actionLabelLines: [
|
||||||
...document.querySelectorAll(".row-actions .p-button-label"),
|
...document.querySelectorAll(".row-actions .p-button-label"),
|
||||||
@@ -283,6 +306,28 @@ for (const [name, routePath, kind, visibleText] of pages) {
|
|||||||
if (empty.emptyIcon?.fontSize !== "16px")
|
if (empty.emptyIcon?.fontSize !== "16px")
|
||||||
violations.push("empty: icon density");
|
violations.push("empty: icon density");
|
||||||
if (kind === "table") {
|
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)
|
if (filled.tableCount !== 1)
|
||||||
violations.push("filled: missing actual table");
|
violations.push("filled: missing actual table");
|
||||||
if (filled.header?.background !== "rgba(0, 0, 0, 0)")
|
if (filled.header?.background !== "rgba(0, 0, 0, 0)")
|
||||||
|
|||||||
Reference in new issue
Block a user