修复共享图表减动偏好并覆盖监控行为回归
This commit is contained in:
1 parent
b4915d0ae5
commit
56188874cb
2 files changed
+317
-2
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
/** 共用折线图容器:父级负责 option/数据,组件补充主题、可见尺寸和卸载清理。 */
|
/** 共用折线图容器:父级负责 option/数据,组件补充主题、系统动效偏好、可见尺寸和卸载清理。 */
|
||||||
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
|
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
|
||||||
import { use } from "echarts/core";
|
import { use } from "echarts/core";
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
@@ -29,8 +29,17 @@ const props = defineProps({
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const container = ref(null);
|
const container = ref(null);
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
|
const reducedMotion = ref(false);
|
||||||
let observer;
|
let observer;
|
||||||
|
let motionQuery;
|
||||||
|
const updateMotionPreference = (event) => {
|
||||||
|
reducedMotion.value = event.matches;
|
||||||
|
};
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Canvas 不受 CSS 减动规则控制;先读取系统偏好,再允许图表创建,并跟随运行中的偏好变更。
|
||||||
|
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
updateMotionPreference(motionQuery);
|
||||||
|
motionQuery.addEventListener("change", updateMotionPreference);
|
||||||
// 折叠面板初始尺寸可能为零;等容器可测量后创建图表,避免 ECharts 零宽初始化。
|
// 折叠面板初始尺寸可能为零;等容器可测量后创建图表,避免 ECharts 零宽初始化。
|
||||||
observer = new ResizeObserver(([entry]) => {
|
observer = new ResizeObserver(([entry]) => {
|
||||||
if (entry?.contentRect.width > 0 && entry.contentRect.height > 0)
|
if (entry?.contentRect.width > 0 && entry.contentRect.height > 0)
|
||||||
@@ -38,7 +47,10 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
if (container.value) observer.observe(container.value);
|
if (container.value) observer.observe(container.value);
|
||||||
});
|
});
|
||||||
onBeforeUnmount(() => observer?.disconnect());
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect();
|
||||||
|
motionQuery?.removeEventListener("change", updateMotionPreference);
|
||||||
|
});
|
||||||
const themedOption = computed(() => {
|
const themedOption = computed(() => {
|
||||||
// 在不修改父级 option 的前提下合成坐标轴主题,兼容单个轴与多轴数组。
|
// 在不修改父级 option 的前提下合成坐标轴主题,兼容单个轴与多轴数组。
|
||||||
const dark = theme.isDark.value;
|
const dark = theme.isDark.value;
|
||||||
@@ -56,6 +68,8 @@ const themedOption = computed(() => {
|
|||||||
return {
|
return {
|
||||||
textStyle: { color },
|
textStyle: { color },
|
||||||
...props.option,
|
...props.option,
|
||||||
|
// 减动时关闭绘制动画;恢复时显式还原父级设置/默认值,避免 ECharts 合并后残留 false。
|
||||||
|
animation: reducedMotion.value ? false : (props.option.animation ?? true),
|
||||||
xAxis: Array.isArray(props.option.xAxis)
|
xAxis: Array.isArray(props.option.xAxis)
|
||||||
? props.option.xAxis.map((value) => apply(value))
|
? props.option.xAxis.map((value) => apply(value))
|
||||||
: apply(props.option.xAxis),
|
: apply(props.option.xAxis),
|
||||||
@@ -68,10 +82,12 @@ const themedOption = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="container" class="chart-box" :style="{ height: `${height}px` }">
|
<div ref="container" class="chart-box" :style="{ height: `${height}px` }">
|
||||||
|
<!-- 保留加载遮罩和文字,减动时只隐藏独立于数据动画的 Canvas 转圈。 -->
|
||||||
<VChart
|
<VChart
|
||||||
v-if="visible"
|
v-if="visible"
|
||||||
:option="themedOption"
|
:option="themedOption"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
:loading-options="{ showSpinner: !reducedMotion }"
|
||||||
autoresize
|
autoresize
|
||||||
class="chart"
|
class="chart"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* 回归缺口:CSS 的减动规则不能关闭 Canvas 动画。通过真实登录、实例监控和刷新,
|
||||||
|
* 验证初始系统偏好、同一图表的偏好切换,以及离页后的监听清理。
|
||||||
|
* 仅替换外部 HTTP 与浏览器时钟;比较同轮静态数据的画面,不锁定像素、颜色或动画时长。
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import {
|
||||||
|
backendOrigin,
|
||||||
|
eventually,
|
||||||
|
login,
|
||||||
|
scenario,
|
||||||
|
} from "./helpers/console-browser.mjs";
|
||||||
|
|
||||||
|
async function monitorBoundary(page) {
|
||||||
|
const state = {
|
||||||
|
values: [10, 80, 30],
|
||||||
|
chartResponses: 0,
|
||||||
|
holdCharts: false,
|
||||||
|
pendingCharts: [],
|
||||||
|
};
|
||||||
|
await page
|
||||||
|
.context()
|
||||||
|
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname.replace(
|
||||||
|
/^\/console\/v1/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const reply = (data) =>
|
||||||
|
route.fulfill({ json: { status: 200, msg: "", data } });
|
||||||
|
if (path === "/host/5")
|
||||||
|
return reply({
|
||||||
|
host: {
|
||||||
|
id: 5,
|
||||||
|
product_id: 123,
|
||||||
|
module: "mf_cloud",
|
||||||
|
name: "本地图表验收实例",
|
||||||
|
status: "Active",
|
||||||
|
billing_cycle: "monthly",
|
||||||
|
upgrade_allowed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (path === "/host/5/view") return reply({ module: "mf_cloud" });
|
||||||
|
if (path === "/mf_cloud/5")
|
||||||
|
return reply({
|
||||||
|
image: { id: 6, name: "Ubuntu 24.04" },
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
cpu: 2,
|
||||||
|
memory: 4,
|
||||||
|
config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 },
|
||||||
|
});
|
||||||
|
if (path === "/mf_cloud/5/status")
|
||||||
|
return reply({ status: "on", rescue: false });
|
||||||
|
if (path === "/mf_cloud/5/disk") return reply({ list: [] });
|
||||||
|
if (path === "/mf_cloud/5/flow")
|
||||||
|
return reply({ total: 100, used: 20, leave: 80 });
|
||||||
|
if (path === "/mf_cloud/5/real_data")
|
||||||
|
return reply({ cpu: 10, memory: 20, disk: 0 });
|
||||||
|
if (path === "/mf_cloud/5/chart") {
|
||||||
|
state.chartResponses++;
|
||||||
|
// 在 HTTP 边界延迟真实刷新,不替换组件的 loading 状态或图表实例。
|
||||||
|
if (state.holdCharts)
|
||||||
|
await new Promise((resolve) => state.pendingCharts.push(resolve));
|
||||||
|
return reply({
|
||||||
|
list: state.values.map((value, index) => ({
|
||||||
|
time: 1793894400 + index * 60,
|
||||||
|
value,
|
||||||
|
total: 100,
|
||||||
|
used: value,
|
||||||
|
in_bw: value * 10,
|
||||||
|
out_bw: value * 5,
|
||||||
|
read_bytes: value * 10,
|
||||||
|
write_bytes: value * 5,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterMonitor(page) {
|
||||||
|
// 必须在首次导航前安装,否则图表库可能缓存未被控制的原生 requestAnimationFrame。
|
||||||
|
await page.clock.install();
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
const state = await monitorBoundary(page);
|
||||||
|
await login(page, "/servers/server?id=5");
|
||||||
|
await page.getByRole("tab", { name: "监控", exact: true }).waitFor();
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 50));
|
||||||
|
await page.getByRole("tab", { name: "监控", exact: true }).click();
|
||||||
|
await eventually(
|
||||||
|
async () => {
|
||||||
|
await page.clock.runFor(50);
|
||||||
|
return page.locator(".chart-viewport canvas").count();
|
||||||
|
},
|
||||||
|
(count) => count === 4,
|
||||||
|
"The real monitor must render all four charts",
|
||||||
|
);
|
||||||
|
// 先给无动画图表一个绘制帧,不把 Canvas 的创建本身误认为动画。
|
||||||
|
await page.clock.runFor(50);
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function observeFrames(page, state) {
|
||||||
|
const sample = () =>
|
||||||
|
page.locator(".chart-viewport canvas").evaluateAll((canvases) =>
|
||||||
|
canvases.map((canvas) => ({
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
painted: canvas
|
||||||
|
.getContext("2d")
|
||||||
|
.getImageData(0, 0, canvas.width, canvas.height)
|
||||||
|
.data.some((channel) => channel !== 0),
|
||||||
|
image: canvas.toDataURL(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const first = await sample();
|
||||||
|
assert.equal(
|
||||||
|
first.length > 0 && first.every((frame) => frame.painted),
|
||||||
|
true,
|
||||||
|
"Stable frames must contain rendered chart feedback, not empty canvases",
|
||||||
|
);
|
||||||
|
const responses = state.chartResponses;
|
||||||
|
let changed = false;
|
||||||
|
for (const step of [100, 250, 800, 1000]) {
|
||||||
|
await page.clock.runFor(step);
|
||||||
|
const next = await sample();
|
||||||
|
assert.deepEqual(
|
||||||
|
next.map(({ width, height }) => ({ width, height })),
|
||||||
|
first.map(({ width, height }) => ({ width, height })),
|
||||||
|
"Frame comparisons require unchanged canvas dimensions",
|
||||||
|
);
|
||||||
|
changed ||= next.some((frame, index) => frame.image !== first[index].image);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
state.chartResponses,
|
||||||
|
responses,
|
||||||
|
"Stable frame samples must not include another data response",
|
||||||
|
);
|
||||||
|
return { changed, chartCount: first.length, dataUnchanged: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMonitor(page, state, values) {
|
||||||
|
state.values = values;
|
||||||
|
const before = state.chartResponses;
|
||||||
|
await page.getByRole("button", { name: "刷新", exact: true }).click();
|
||||||
|
await eventually(
|
||||||
|
() => state.chartResponses,
|
||||||
|
(count) => count === before + 4,
|
||||||
|
"Refresh must consume new data through all four monitor HTTP requests",
|
||||||
|
);
|
||||||
|
await eventually(
|
||||||
|
() => page.getByRole("button", { name: "刷新", exact: true }).isEnabled(),
|
||||||
|
Boolean,
|
||||||
|
"Frame sampling must begin after the refresh response is displayed",
|
||||||
|
);
|
||||||
|
await page.clock.runFor(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
"monitor renders stable initial frames for reduced motion",
|
||||||
|
{ timeout: 45000 },
|
||||||
|
(t) =>
|
||||||
|
scenario(t, {}, async (page) => {
|
||||||
|
const state = await enterMonitor(page);
|
||||||
|
try {
|
||||||
|
const observed = await observeFrames(page, state);
|
||||||
|
assert.equal(
|
||||||
|
observed.changed,
|
||||||
|
false,
|
||||||
|
"Reduced motion must not animate already available chart data",
|
||||||
|
);
|
||||||
|
return { initiallyReduced: true, ...observed };
|
||||||
|
} finally {
|
||||||
|
await page.clock.resume();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"monitor loading feedback follows reduced motion without losing refresh state",
|
||||||
|
{ timeout: 45000 },
|
||||||
|
(t) =>
|
||||||
|
scenario(t, {}, async (page) => {
|
||||||
|
const state = await enterMonitor(page);
|
||||||
|
try {
|
||||||
|
await page.clock.runFor(2000);
|
||||||
|
state.holdCharts = true;
|
||||||
|
const refresh = page.getByRole("button", { name: "刷新", exact: true });
|
||||||
|
await refresh.click();
|
||||||
|
await eventually(
|
||||||
|
() => state.pendingCharts.length,
|
||||||
|
(count) => count === 4,
|
||||||
|
"Loading feedback must be driven by real pending monitor HTTP requests",
|
||||||
|
);
|
||||||
|
assert.equal(await refresh.isDisabled(), true);
|
||||||
|
await page.clock.runFor(50);
|
||||||
|
const reduced = await observeFrames(page, state);
|
||||||
|
assert.equal(
|
||||||
|
reduced.changed,
|
||||||
|
false,
|
||||||
|
"Reduced motion must also keep the canvas loading feedback still",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||||
|
await page.clock.runFor(50);
|
||||||
|
const normal = await observeFrames(page, state);
|
||||||
|
assert.equal(
|
||||||
|
normal.changed,
|
||||||
|
true,
|
||||||
|
"Normal loading feedback may animate",
|
||||||
|
);
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
await page.clock.runFor(50);
|
||||||
|
const reducedAgain = await observeFrames(page, state);
|
||||||
|
assert.equal(
|
||||||
|
reducedAgain.changed,
|
||||||
|
false,
|
||||||
|
"Loading feedback must react when reduced motion is enabled again",
|
||||||
|
);
|
||||||
|
assert.equal(await refresh.isDisabled(), true);
|
||||||
|
|
||||||
|
state.holdCharts = false;
|
||||||
|
for (const release of state.pendingCharts.splice(0)) release();
|
||||||
|
await eventually(
|
||||||
|
() => refresh.isEnabled(),
|
||||||
|
Boolean,
|
||||||
|
"Releasing the HTTP responses must finish the real refresh",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
loadingWhileRequestsPending: true,
|
||||||
|
reducedLoadingChangedFrames: reduced.changed,
|
||||||
|
normalLoadingChangedFrames: normal.changed,
|
||||||
|
reducedAgainLoadingChangedFrames: reducedAgain.changed,
|
||||||
|
completedRefresh: true,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
state.holdCharts = false;
|
||||||
|
for (const release of state.pendingCharts.splice(0)) release();
|
||||||
|
await page.clock.resume();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"monitor follows motion preference changes and safely unmounts",
|
||||||
|
{ timeout: 45000 },
|
||||||
|
(t) =>
|
||||||
|
scenario(t, {}, async (page) => {
|
||||||
|
const state = await enterMonitor(page);
|
||||||
|
try {
|
||||||
|
await page.clock.runFor(2000);
|
||||||
|
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||||
|
await page.clock.runFor(50);
|
||||||
|
await refreshMonitor(page, state, [80, 20, 60]);
|
||||||
|
const normal = await observeFrames(page, state);
|
||||||
|
assert.equal(
|
||||||
|
normal.changed,
|
||||||
|
true,
|
||||||
|
"Removing reduced motion must restore the normal chart animation",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
await page.clock.runFor(50);
|
||||||
|
await refreshMonitor(page, state, [20, 60, 10]);
|
||||||
|
const reducedAgain = await observeFrames(page, state);
|
||||||
|
assert.equal(
|
||||||
|
reducedAgain.changed,
|
||||||
|
false,
|
||||||
|
"Changing the preference back must stop animating refreshed chart data",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 走真实路由离开实例页,再发系统偏好事件;公共场景收集器会拒绝运行时/控制台错误。
|
||||||
|
await page
|
||||||
|
.getByRole("link", { name: "首页", exact: true })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await eventually(
|
||||||
|
() => page.locator(".chart-viewport canvas").count(),
|
||||||
|
(count) => count === 0,
|
||||||
|
"Leaving the instance must unmount the monitor charts",
|
||||||
|
);
|
||||||
|
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||||
|
await page.clock.runFor(50);
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
await page.clock.runFor(50);
|
||||||
|
return {
|
||||||
|
normalMotionChangedFrames: normal.changed,
|
||||||
|
reducedMotionChangedFrames: reducedAgain.changed,
|
||||||
|
unmountedWithoutErrors: true,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await page.clock.resume();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
Reference in new issue
Block a user