rebuild(stage-13): redesign mobile information architecture

This commit is contained in:
leefer
2026-07-30 08:02:47 +08:00
parent 4125ae2740
commit bec0878494
16 changed files with 449 additions and 42 deletions
+130
View File
@@ -0,0 +1,130 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-13");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page) {
await page.goto("/");
await page.getByLabel("账号名").fill("stage13admin");
await page.getByLabel("密码").fill("Stage13-pass-123!");
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
}
}
function summary() {
return {
context: { requested_date: "2026-07-30", actual_date: "2026-07-30", previous_date: "2026-07-29", observed_at: "2026-07-30T15:00:00+08:00", state: "final", carried_forward: false, message: "" },
values: { temperature: 42, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 },
};
}
function pool() {
const items = Array.from({ length: 4 }, (_, index) => ({
identifier: `00000${index + 1}.SZ`, code: `00000${index + 1}`, name: `移动样本${index + 1}`,
streak: index + 1, change: 9.9, price: 10 + index, sector: "机器人",
first_time: "09:35", last_time: "14:20", open_times: index, turnover_rate: 8 + index,
amount: 300000000, seal_amount: 50000000, reason: "产业链催化与资金承接",
}));
return { trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", carried_forward: false, message: "", overview: { up_count: 2960, down_count: 1980, flat_count: 112, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 }, items };
}
async function mockMarket(page) {
await page.route("**/api/market/summary", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(summary()) }));
await page.route("**/api/review/alerts?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ items: [], unread_count: 0, as_of: "2026-07-30" }) }));
await page.route("**/api/market/workspaces/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(pool()) }));
}
async function assertViewport(page, width, height) {
await page.setViewportSize({ width, height });
await expect(page.getByLabel("切换行情页面")).toBeVisible();
const metrics = await page.evaluate(() => {
const visible = (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
};
const undersized = [...document.querySelectorAll("button")]
.filter(visible)
.map((element) => ({ label: element.getAttribute("aria-label") || element.textContent.trim(), rect: element.getBoundingClientRect() }))
.filter((item) => item.rect.width < 43.5 || item.rect.height < 43.5)
.map((item) => item.label);
return { overflow: document.documentElement.scrollWidth - innerWidth, undersized };
});
expect(metrics.overflow).toBeLessThanOrEqual(0);
expect(metrics.undersized).toEqual([]);
}
test("mobile information architecture, touch targets and responsive tables", async ({ page }) => {
const consoleErrors = [];
page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); });
await mockMarket(page);
await authenticate(page);
await page.setViewportSize({ width: 320, height: 568 });
await page.getByLabel("切换行情页面").selectOption("pool");
await expect(page.getByRole("heading", { name: "涨停池" })).toBeVisible();
await expect(page.locator(".mobile-date-control .date-input")).toHaveValue("2026-07-30");
await expect(page.getByRole("navigation", { name: "移动端主导航" }).getByText("行情", { exact: true })).toHaveAttribute("aria-current", "page");
await expect(page.locator(".data-table tbody tr")).toHaveCount(4);
await expect(page.locator(".data-table tbody td").filter({ hasText: "移动样本1" })).toHaveAttribute("data-label", "股票");
expect(await page.locator(".data-table tbody tr").first().evaluate((row) => row.scrollWidth <= row.clientWidth)).toBe(true);
for (const viewport of [[320, 568], [375, 667], [390, 844], [430, 932], [768, 1024], [844, 390]]) {
await assertViewport(page, viewport[0], viewport[1]);
}
for (const viewport of [[1024, 768], [1280, 720], [1440, 900], [1536, 864], [1920, 1080], [3840, 2160]]) {
await page.setViewportSize({ width: viewport[0], height: viewport[1] });
await expect(page.getByLabel("切换行情页面")).toBeHidden();
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(0);
}
await page.setViewportSize({ width: 320, height: 568 });
await page.screenshot({ path: path.join(evidence, "pool-light-320x568.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("button", { name: "夜间" }).click();
await page.setViewportSize({ width: 844, height: 390 });
await page.screenshot({ path: path.join(evidence, "pool-dark-landscape-844x390.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
test("keyboard focus, dialog return and reduced motion remain available", async ({ page }) => {
await mockMarket(page);
await page.emulateMedia({ reducedMotion: "reduce" });
await authenticate(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.keyboard.press("Tab");
await expect(page.getByRole("link", { name: "跳到主要内容" })).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator("#main-content")).toBeFocused();
const search = page.getByRole("button", { name: "全局搜索" });
await search.focus();
await search.click();
await expect(page.getByRole("dialog", { name: "全局搜索" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(search).toBeFocused();
await expect(page.locator(".emotion-dot")).toHaveCSS("animation-duration", "0.001s");
});
test("all sixteen workspaces remain reachable without page overflow", async ({ page }) => {
await page.route("**/api/market/summary", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(summary()) }));
await page.route("**/api/review/alerts?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ items: [], unread_count: 0, as_of: "2026-07-30" }) }));
await page.route("**/api/market/workspaces/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "", message: "移动端空态验收" }) }));
await page.route("**/api/market/insights/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "", message: "移动端空态验收" }) }));
await authenticate(page);
await page.setViewportSize({ width: 320, height: 568 });
const keys = ["emotion", "pool", "broken", "limit-down", "yesterday", "performance", "ladder", "rotation", "auction", "themes", "popularity", "dragon-list", "screener", "mentor", "heaven", "review"];
for (const key of keys) {
await page.goto(`/workspace/${key}`);
await expect(page.locator(".page-frame")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), key).toBeLessThanOrEqual(0);
await expect(page.locator(".mobile-date-control .date-input")).toBeVisible();
if (key === "review") {
await expect(page.getByRole("button", { name: "提醒中心" })).toBeVisible();
await expect(page.getByRole("button", { name: "复盘助手" })).toBeVisible();
}
}
});