feat: 添加移动端适配和测试功能
refactor(layout): 优化页脚布局和备案信息展示 feat(constants): 添加ICP备案和公安备案信息 feat(header): 实现移动端加载时的骨架屏效果 style(globals): 调整文字颜色和添加移动端响应样式 feat(breadcrumb): 增加返回按钮和响应式优化 feat(e2e): 添加移动端测试工具和测试用例 docs: 添加页脚重设计文档
This commit is contained in:
@@ -450,4 +450,142 @@ export class BasePage {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async tapElement(selector: string): Promise<void> {
|
||||
const element = this.page.locator(selector);
|
||||
const box = await element.boundingBox();
|
||||
|
||||
if (box) {
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await this.page.touchscreen.tap(x, y);
|
||||
} else {
|
||||
await element.click();
|
||||
}
|
||||
}
|
||||
|
||||
async swipe(start: { x: number; y: number }, end: { x: number; y: number }): Promise<void> {
|
||||
await this.page.touchscreen.tap(start.x, start.y);
|
||||
await this.page.touchscreen.touchMove(end.x, end.y);
|
||||
await this.page.touchscreen.touchEnd();
|
||||
}
|
||||
|
||||
async longPress(selector: string, duration: number = 1000): Promise<void> {
|
||||
const element = this.page.locator(selector);
|
||||
const box = await element.boundingBox();
|
||||
|
||||
if (box) {
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await this.page.touchscreen.tap(x, y);
|
||||
await this.page.waitForTimeout(duration);
|
||||
} else {
|
||||
await element.click();
|
||||
}
|
||||
}
|
||||
|
||||
async pinchZoom(selector: string, scale: number = 1.5): Promise<void> {
|
||||
const element = this.page.locator(selector);
|
||||
const box = await element.boundingBox();
|
||||
|
||||
if (box) {
|
||||
const centerX = box.x + box.width / 2;
|
||||
const centerY = box.y + box.height / 2;
|
||||
|
||||
const finger1 = { x: centerX - 50, y: centerY };
|
||||
const finger2 = { x: centerX + 50, y: centerY };
|
||||
|
||||
await this.page.touchscreen.tap(finger1.x, finger1.y);
|
||||
await this.page.touchscreen.tap(finger2.x, finger2.y);
|
||||
|
||||
const newFinger1 = { x: centerX - 50 / scale, y: centerY };
|
||||
const newFinger2 = { x: centerX + 50 / scale, y: centerY };
|
||||
|
||||
await this.page.touchscreen.touchMove(newFinger1.x, newFinger1.y);
|
||||
await this.page.touchscreen.touchMove(newFinger2.x, newFinger2.y);
|
||||
|
||||
await this.page.touchscreen.touchEnd();
|
||||
await this.page.touchscreen.touchEnd();
|
||||
}
|
||||
}
|
||||
|
||||
async waitForMobileLoad(): Promise<void> {
|
||||
await this.page.waitForLoadState('domcontentloaded');
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async checkTouchTarget(selector: string): Promise<boolean> {
|
||||
const element = this.page.locator(selector);
|
||||
const box = await element.boundingBox();
|
||||
|
||||
if (box) {
|
||||
return box.width >= 44 && box.height >= 44;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async captureMobileScreenshot(name: string): Promise<void> {
|
||||
const screenshotDir = 'test-results/mobile-screenshots';
|
||||
if (!fs.existsSync(screenshotDir)) {
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${timestamp}-${name}.png`;
|
||||
await this.page.screenshot({
|
||||
path: path.join(screenshotDir, filename),
|
||||
fullPage: true
|
||||
});
|
||||
}
|
||||
|
||||
async measureLCP(): Promise<number> {
|
||||
const vitals = await this.getCoreWebVitals();
|
||||
return vitals.largestContentfulPaint;
|
||||
}
|
||||
|
||||
async measureFID(): Promise<number> {
|
||||
const vitals = await this.getCoreWebVitals();
|
||||
return vitals.firstInputDelay;
|
||||
}
|
||||
|
||||
async measureCLS(): Promise<number> {
|
||||
const vitals = await this.getCoreWebVitals();
|
||||
return vitals.cumulativeLayoutShift;
|
||||
}
|
||||
|
||||
async measureTTI(): Promise<number> {
|
||||
const metrics = await this.measurePerformance();
|
||||
return metrics.domContentLoaded;
|
||||
}
|
||||
|
||||
async measureTTFB(): Promise<number> {
|
||||
const timing = await this.page.evaluate(() => {
|
||||
return performance.timing;
|
||||
});
|
||||
return timing.responseStart - timing.navigationStart;
|
||||
}
|
||||
|
||||
async handleError(error: Error, context: string): Promise<void> {
|
||||
const errorInfo = {
|
||||
timestamp: new Date().toISOString(),
|
||||
context,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
url: this.page.url(),
|
||||
};
|
||||
|
||||
await this.log(`Error in ${context}: ${error.message}`, 'error');
|
||||
await this.captureMobileScreenshot(`error-${context}`);
|
||||
}
|
||||
|
||||
async logAction(action: string, details: any): Promise<void> {
|
||||
const logInfo = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action,
|
||||
details,
|
||||
url: this.page.url(),
|
||||
};
|
||||
|
||||
await this.log(`Action: ${action}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class MobilePage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async handleMobileMenu(): Promise<void> {
|
||||
const menuButton = this.page.locator('button[aria-label="打开菜单"], button[aria-label="关闭菜单"]');
|
||||
|
||||
if (await menuButton.isVisible()) {
|
||||
await this.tapElement('button[aria-label="打开菜单"], button[aria-label="关闭菜单"]');
|
||||
await this.page.waitForSelector('#mobile-menu', { state: 'visible' });
|
||||
}
|
||||
}
|
||||
|
||||
async closeMobileMenu(): Promise<void> {
|
||||
const closeButton = this.page.locator('button[aria-label="关闭菜单"]');
|
||||
|
||||
if (await closeButton.isVisible()) {
|
||||
await this.tapElement('button[aria-label="关闭菜单"]');
|
||||
await this.page.waitForSelector('#mobile-menu', { state: 'hidden' });
|
||||
}
|
||||
}
|
||||
|
||||
async handleMobileNavigation(linkText: string): Promise<void> {
|
||||
await this.handleMobileMenu();
|
||||
|
||||
const link = this.page.locator(`#mobile-menu a:has-text("${linkText}")`);
|
||||
await this.tapElement(`#mobile-menu a:has-text("${linkText}")`);
|
||||
|
||||
await this.closeMobileMenu();
|
||||
}
|
||||
|
||||
async handleMobileForm(formData: Record<string, string>): Promise<void> {
|
||||
for (const [fieldName, value] of Object.entries(formData)) {
|
||||
const field = this.page.locator(`input[name="${fieldName}"], textarea[name="${fieldName}"]`);
|
||||
|
||||
if (await field.isVisible()) {
|
||||
await field.fill(value);
|
||||
await this.page.waitForTimeout(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleMobileScroll(direction: 'up' | 'down' = 'down'): Promise<void> {
|
||||
const viewportSize = this.page.viewportSize();
|
||||
|
||||
if (viewportSize) {
|
||||
const startY = direction === 'down' ? viewportSize.height * 0.8 : viewportSize.height * 0.2;
|
||||
const endY = direction === 'down' ? viewportSize.height * 0.2 : viewportSize.height * 0.8;
|
||||
const centerX = viewportSize.width / 2;
|
||||
|
||||
await this.swipe(
|
||||
{ x: centerX, y: startY },
|
||||
{ x: centerX, y: endY }
|
||||
);
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
async handleMobileGestures(): Promise<void> {
|
||||
await this.logAction('Testing mobile gestures', {});
|
||||
|
||||
const testElement = this.page.locator('body');
|
||||
const box = await testElement.boundingBox();
|
||||
|
||||
if (box) {
|
||||
const centerX = box.x + box.width / 2;
|
||||
const centerY = box.y + box.height / 2;
|
||||
|
||||
await this.swipe(
|
||||
{ x: centerX, y: centerY + 100 },
|
||||
{ x: centerX, y: centerY - 100 }
|
||||
);
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
await this.swipe(
|
||||
{ x: centerX - 100, y: centerY },
|
||||
{ x: centerX + 100, y: centerY }
|
||||
);
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyMobileMenuOpen(): Promise<boolean> {
|
||||
const mobileMenu = this.page.locator('#mobile-menu');
|
||||
return await mobileMenu.isVisible();
|
||||
}
|
||||
|
||||
async verifyMobileMenuClosed(): Promise<boolean> {
|
||||
const mobileMenu = this.page.locator('#mobile-menu');
|
||||
return !(await mobileMenu.isVisible());
|
||||
}
|
||||
|
||||
async getMobileMenuItems(): Promise<string[]> {
|
||||
const items = this.page.locator('#mobile-menu a');
|
||||
const count = await items.count();
|
||||
const itemTexts: string[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await items.nth(i).textContent();
|
||||
if (text) {
|
||||
itemTexts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return itemTexts;
|
||||
}
|
||||
|
||||
async tapMobileMenuItem(itemText: string): Promise<void> {
|
||||
const menuItem = this.page.locator(`#mobile-menu a:has-text("${itemText}")`);
|
||||
await this.tapElement(`#mobile-menu a:has-text("${itemText}")`);
|
||||
}
|
||||
|
||||
async isMobileMenuVisible(): Promise<boolean> {
|
||||
const menuButton = this.page.locator('button[aria-label="打开菜单"]');
|
||||
return await menuButton.isVisible();
|
||||
}
|
||||
|
||||
async waitForMobileMenuAnimation(): Promise<void> {
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user