From 6797c24b5c82e126178b1c8f9b1377ac433e97ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E7=BF=94?= Date: Thu, 5 Mar 2026 11:40:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E9=80=82=E9=85=8D=E5=92=8C=E6=B5=8B=E8=AF=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(layout): 优化页脚布局和备案信息展示 feat(constants): 添加ICP备案和公安备案信息 feat(header): 实现移动端加载时的骨架屏效果 style(globals): 调整文字颜色和添加移动端响应样式 feat(breadcrumb): 增加返回按钮和响应式优化 feat(e2e): 添加移动端测试工具和测试用例 docs: 添加页脚重设计文档 --- .../2026-03-04-footer-redesign-design.md | 253 +++++++++++ e2e/src/pages/BasePage.ts | 138 ++++++ e2e/src/pages/MobilePage.ts | 128 ++++++ e2e/src/tests/mobile/mobile-contact.spec.ts | 403 ++++++++++++++++++ .../tests/mobile/mobile-functionality.spec.ts | 358 ++++++++++++++++ e2e/src/utils/DeviceMatrix.ts | 127 ++++++ e2e/src/utils/MobileHelper.ts | 270 ++++++++++++ e2e/src/utils/PerformanceMonitor.ts | 197 +++++++++ e2e/src/utils/TestDataGenerator.ts | 343 +++++++++++++++ .../images/qrcode_for_gh_a297181ff548_258.jpg | Bin 0 -> 28754 bytes src/app/globals.css | 32 +- src/components/layout/breadcrumb.tsx | 23 +- src/components/layout/footer.tsx | 36 ++ src/components/layout/header.tsx | 20 +- src/lib/constants.ts | 2 + 15 files changed, 2320 insertions(+), 10 deletions(-) create mode 100644 docs/plans/2026-03-04-footer-redesign-design.md create mode 100644 e2e/src/pages/MobilePage.ts create mode 100644 e2e/src/tests/mobile/mobile-contact.spec.ts create mode 100644 e2e/src/tests/mobile/mobile-functionality.spec.ts create mode 100644 e2e/src/utils/DeviceMatrix.ts create mode 100644 e2e/src/utils/MobileHelper.ts create mode 100644 public/images/qrcode_for_gh_a297181ff548_258.jpg diff --git a/docs/plans/2026-03-04-footer-redesign-design.md b/docs/plans/2026-03-04-footer-redesign-design.md new file mode 100644 index 0000000..44f0c18 --- /dev/null +++ b/docs/plans/2026-03-04-footer-redesign-design.md @@ -0,0 +1,253 @@ +# Footer 全面升级设计方案 + +**日期:** 2026-03-04 +**设计风格:** 浅色现代风格 +**布局结构:** 4列布局 + +--- + +## 一、整体布局与视觉风格 + +### 1.1 背景与色彩体系 + +**背景设计:** +- 主背景:保持浅色背景 `#F5F5F5` +- 渐变效果:从上到下微妙渐变(`#F5F5F5` → `#FAFAFA`) +- 顶部阴影:`shadow-[0_-4px_20px_rgba(0,0,0,0.03)]` + +**色彩体系:** +- 强调色:品牌深红 `#C41E3A`(图标、链接悬停、分隔线) +- 文字层次: + - 标题:`#1C1C1C` 深色 + - 正文:`#3D3D3D` 中灰色 + - 辅助文字:`#5C5C5C` 浅灰色 + +### 1.2 布局结构 + +**4列布局:** +``` +[品牌信息+二维码] [快速链接] [服务项目] [联系方式] +``` + +**间距优化:** +- 列间距:`gap-8 lg:gap-12` +- 内边距:`py-16`(增加呼吸感) +- 元素间距:统一使用 `space-y-4` 或 `space-y-6` + +--- + +## 二、品牌信息区域设计(第一列) + +### 2.1 品牌展示区(顶部) + +**Logo区域:** +- Logo尺寸:保持当前 `h-10` +- 悬停效果:`hover:scale-105 hover:shadow-md transition-all duration-200` +- 公司名称:Logo右侧添加"睿新致遠",`font-semibold text-lg text-[#1C1C1C]` +- Slogan:名称下方添加"智连未来,成长伙伴",`text-sm text-[#5C5C5C] mt-1` + +**公司描述:** +- 保持当前内容 +- 优化行高:`leading-relaxed` + +### 2.2 二维码区域(中部) + +**分隔线:** +- 使用 `border-t border-[#E5E5E5]` 与上方内容区分 + +**二维码展示:** +- 标题:"关注公众号",`text-sm font-medium text-[#1C1C1C] mb-3` +- 卡片样式: + - 白色背景 + 边框 + - 阴影:`shadow-sm hover:shadow-md transition-shadow` + - 内边距:`p-3` +- 尺寸:140x140px +- 引导文字:"扫码关注获取最新资讯",`text-xs text-[#718096] mt-2` + +--- + +## 三、其他三列设计 + +### 3.1 第二列:快速链接 + +**标题设计:** +- 文字:"快速链接" +- 样式:`font-semibold text-lg text-[#1C1C1C] mb-6` +- 左侧装饰:品牌色竖线 `w-1 h-6 bg-[#C41E3A] rounded-full` + +**链接列表:** +- 保持当前导航项 +- 样式优化: + - 默认:`text-[#3D3D3D] text-sm` + - 悬停:`hover:text-[#C41E3A] hover:translate-x-1 transition-all duration-200` + - 间距:`space-y-3` +- 图标前缀:小圆点 `w-1.5 h-1.5 bg-[#C41E3A] rounded-full` + +### 3.2 第三列:服务项目 + +**标题设计:** +- 文字:"服务项目" +- 样式:与快速链接标题保持一致 + +**服务列表:** +- 保持当前服务项 +- 样式:与快速链接保持一致 + +### 3.3 第四列:联系方式 + +**标题设计:** +- 文字:"联系方式" +- 样式:与其他列标题保持一致 + +**联系信息:** +- 保持图标 + 文字布局 +- 样式优化: + - 图标:`w-5 h-5 text-[#C41E3A]` + - 文字:`text-[#3D3D3D] text-sm` + - 悬停:`group-hover:translate-x-1` + - 间距:`space-y-4` + +--- + +## 四、底部信息区域设计 + +### 4.1 版权与合规信息区 + +**分隔线:** +- 使用 `border-t border-[#E5E5E5] mt-12 pt-8` + +**布局:** +- `flex flex-col md:flex-row justify-between items-center gap-4` + +**左侧:** +- 版权信息:`© 2026 四川睿新致远科技有限公司 All rights reserved.` +- 样式:`text-[#5C5C5C] text-sm` + +**右侧:** +- 隐私政策、服务条款链接 +- 样式:`text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors` +- 间距:`gap-6` + +### 4.2 备案信息 + +**位置:** +- 版权信息下方 +- 样式:`text-center mt-4 pt-4 border-t border-[#E5E5E5]` + +**内容:** +- ICP备案号:`蜀ICP备XXXXXXXX号-1` +- 公安备案:`川公网安备 XXXXXXXXXXX号` +- 样式:`text-xs text-[#718096]` +- 链接:备案号可链接到工信部查询页面 + +--- + +## 五、响应式设计 + +### 5.1 移动端(< 768px) + +**布局:** +- 单列堆叠:`grid-cols-1` +- 间距:`gap-8` +- 内边距:`py-12` + +**顺序:** +1. 品牌信息 + 二维码 +2. 快速链接 +3. 服务项目 +4. 联系方式 + +**二维码:** +- 居中显示:`mx-auto` + +### 5.2 平板端(768px - 1024px) + +**布局:** +- 2列网格:`md:grid-cols-2` +- 间距:`gap-8` + +### 5.3 桌面端(> 1024px) + +**布局:** +- 4列网格:`lg:grid-cols-4` +- 间距:`gap-12` +- 内边距:`py-16` + +--- + +## 六、交互细节优化 + +### 6.1 链接悬停效果 + +- 快速链接、服务项目:`hover:text-[#C41E3A] hover:translate-x-1 transition-all duration-200` +- 联系方式:整个项目悬停时轻微右移 +- 底部链接:`hover:text-[#C41E3A] transition-colors` + +### 6.2 二维码交互 + +- 悬停效果:阴影增强 `hover:shadow-lg` +- 可选:点击放大功能(使用Dialog组件) + +### 6.3 Logo悬停 + +- 轻微放大:`hover:scale-105` +- 添加阴影:`hover:shadow-md` +- 过渡效果:`transition-all duration-200` + +### 6.4 可访问性优化 + +- 所有链接添加 `aria-label` +- 图标添加 `aria-hidden="true"` +- 确保颜色对比度符合 WCAG AA 标准 +- 键盘导航友好 + +--- + +## 七、性能优化 + +### 7.1 图片优化 + +- Logo:使用 `priority` 属性(首屏加载) +- 二维码:使用 `loading="lazy"`(懒加载) +- 添加 `sizes` 属性优化响应式图片 + +### 7.2 CSS优化 + +- 使用 Tailwind CSS 的 JIT 模式 +- 避免重复的类名组合 +- 使用组件化的样式方案 + +--- + +## 八、实施计划 + +### 8.1 实施步骤 + +1. 更新 `footer.tsx` 组件代码 +2. 添加必要的图标组件(如果需要) +3. 更新 `constants.ts` 添加备案信息 +4. 测试响应式布局 +5. 验证可访问性 +6. 性能优化检查 + +### 8.2 预期效果 + +- ✅ 视觉层次更清晰 +- ✅ 品牌形象更突出 +- ✅ 用户体验更友好 +- ✅ 符合金融级标准 +- ✅ 响应式适配完善 +- ✅ 可访问性达标 + +--- + +## 九、未来扩展 + +### 9.1 预留区域 + +- 合作伙伴展示区(已设计,暂时隐藏) +- 认证资质标识区(已设计,暂时隐藏) + +### 9.2 扩展方式 + +需要时取消注释相关代码即可启用。 diff --git a/e2e/src/pages/BasePage.ts b/e2e/src/pages/BasePage.ts index 65aeb91..fd4386c 100644 --- a/e2e/src/pages/BasePage.ts +++ b/e2e/src/pages/BasePage.ts @@ -450,4 +450,142 @@ export class BasePage { }); }); } + + async tapElement(selector: string): Promise { + 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 { + 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 { + 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 { + 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 { + await this.page.waitForLoadState('domcontentloaded'); + await this.page.waitForTimeout(500); + } + + async checkTouchTarget(selector: string): Promise { + 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 { + 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 { + const vitals = await this.getCoreWebVitals(); + return vitals.largestContentfulPaint; + } + + async measureFID(): Promise { + const vitals = await this.getCoreWebVitals(); + return vitals.firstInputDelay; + } + + async measureCLS(): Promise { + const vitals = await this.getCoreWebVitals(); + return vitals.cumulativeLayoutShift; + } + + async measureTTI(): Promise { + const metrics = await this.measurePerformance(); + return metrics.domContentLoaded; + } + + async measureTTFB(): Promise { + const timing = await this.page.evaluate(() => { + return performance.timing; + }); + return timing.responseStart - timing.navigationStart; + } + + async handleError(error: Error, context: string): Promise { + 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 { + const logInfo = { + timestamp: new Date().toISOString(), + action, + details, + url: this.page.url(), + }; + + await this.log(`Action: ${action}`, 'info'); + } } diff --git a/e2e/src/pages/MobilePage.ts b/e2e/src/pages/MobilePage.ts new file mode 100644 index 0000000..29fa6fc --- /dev/null +++ b/e2e/src/pages/MobilePage.ts @@ -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 { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + const mobileMenu = this.page.locator('#mobile-menu'); + return await mobileMenu.isVisible(); + } + + async verifyMobileMenuClosed(): Promise { + const mobileMenu = this.page.locator('#mobile-menu'); + return !(await mobileMenu.isVisible()); + } + + async getMobileMenuItems(): Promise { + 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 { + const menuItem = this.page.locator(`#mobile-menu a:has-text("${itemText}")`); + await this.tapElement(`#mobile-menu a:has-text("${itemText}")`); + } + + async isMobileMenuVisible(): Promise { + const menuButton = this.page.locator('button[aria-label="打开菜单"]'); + return await menuButton.isVisible(); + } + + async waitForMobileMenuAnimation(): Promise { + await this.page.waitForTimeout(300); + } +} diff --git a/e2e/src/tests/mobile/mobile-contact.spec.ts b/e2e/src/tests/mobile/mobile-contact.spec.ts new file mode 100644 index 0000000..f7f95c7 --- /dev/null +++ b/e2e/src/tests/mobile/mobile-contact.spec.ts @@ -0,0 +1,403 @@ +import { test, expect, Page } from '@playwright/test'; +import { MobilePage } from '../pages/MobilePage'; +import { TestDataGenerator } from '../utils/TestDataGenerator'; +import { PerformanceMonitor } from '../utils/PerformanceMonitor'; + +test.describe('联系页移动端测试套件 @mobile', () => { + let mobilePage: MobilePage; + let performanceMonitor: PerformanceMonitor; + + test.beforeEach(async ({ page }) => { + mobilePage = new MobilePage(page); + performanceMonitor = new PerformanceMonitor(page); + await page.goto('/contact'); + await page.waitForLoadState('domcontentloaded'); + }); + + test.describe('表单字段测试', () => { + test('姓名字段移动端输入正常', async ({ page }) => { + const nameInput = page.locator('input[name="name"], input[placeholder*="姓名"], input[placeholder*="名字"]'); + + if (await nameInput.count() > 0) { + const name = TestDataGenerator.generateName(); + await nameInput.first().fill(name); + + const value = await nameInput.first().inputValue(); + expect(value).toBe(name); + + const box = await nameInput.first().boundingBox(); + expect(box).toBeTruthy(); + expect(box!.width).toBeGreaterThanOrEqual(200); + } + }); + + test('电话字段移动端输入正常', async ({ page }) => { + const phoneInput = page.locator('input[name="phone"], input[placeholder*="电话"], input[placeholder*="手机"]'); + + if (await phoneInput.count() > 0) { + const phone = TestDataGenerator.generatePhone(); + await phoneInput.first().fill(phone); + + const value = await phoneInput.first().inputValue(); + expect(value).toBe(phone); + + const inputType = await phoneInput.first().getAttribute('type'); + expect(['tel', 'text', 'number']).toContain(inputType); + } + }); + + test('邮箱字段移动端输入正常', async ({ page }) => { + const emailInput = page.locator('input[name="email"], input[placeholder*="邮箱"], input[placeholder*="email"]'); + + if (await emailInput.count() > 0) { + const email = TestDataGenerator.generateEmail(); + await emailInput.first().fill(email); + + const value = await emailInput.first().inputValue(); + expect(value).toBe(email); + + const inputType = await emailInput.first().getAttribute('type'); + expect(inputType).toBe('email'); + } + }); + + test('主题字段移动端输入正常', async ({ page }) => { + const subjectInput = page.locator('input[name="subject"], input[placeholder*="主题"], select[name="subject"]'); + + if (await subjectInput.count() > 0) { + const tagName = await subjectInput.first().evaluate((el) => el.tagName.toLowerCase()); + + if (tagName === 'select') { + await subjectInput.first().selectOption({ index: 1 }); + } else { + const subject = TestDataGenerator.generateSubject(); + await subjectInput.first().fill(subject); + + const value = await subjectInput.first().inputValue(); + expect(value).toBe(subject); + } + } + }); + + test('消息字段移动端输入正常', async ({ page }) => { + const messageInput = page.locator('textarea[name="message"], textarea[placeholder*="消息"], textarea[placeholder*="内容"]'); + + if (await messageInput.count() > 0) { + const message = TestDataGenerator.generateMessage(); + await messageInput.first().fill(message); + + const value = await messageInput.first().inputValue(); + expect(value).toBe(message); + + const box = await messageInput.first().boundingBox(); + expect(box).toBeTruthy(); + expect(box!.height).toBeGreaterThanOrEqual(80); + } + }); + }); + + test.describe('表单验证测试', () => { + test('必填字段验证正常', async ({ page }) => { + const submitButton = page.locator('button[type="submit"], button:has-text("提交"), button:has-text("发送")'); + + if (await submitButton.count() > 0) { + await submitButton.first().click(); + await page.waitForTimeout(500); + + const errorMessages = page.locator('[class*="error"], [class*="invalid"], [role="alert"]'); + const errorCount = await errorMessages.count(); + + expect(errorCount).toBeGreaterThanOrEqual(0); + } + }); + + test('邮箱格式验证正常', async ({ page }) => { + const emailInput = page.locator('input[name="email"], input[placeholder*="邮箱"]'); + + if (await emailInput.count() > 0) { + const invalidEmail = TestDataGenerator.generateInvalidEmail(); + await emailInput.first().fill(invalidEmail); + + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + + const isValid = await emailInput.first().evaluate((el: HTMLInputElement) => { + return el.checkValidity(); + }); + + expect(isValid).toBe(false); + } + }); + + test('电话格式验证正常', async ({ page }) => { + const phoneInput = page.locator('input[name="phone"], input[placeholder*="电话"]'); + + if (await phoneInput.count() > 0) { + const invalidPhone = TestDataGenerator.generateInvalidPhone(); + await phoneInput.first().fill(invalidPhone); + + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + + const value = await phoneInput.first().inputValue(); + expect(value.length).toBeLessThan(15); + } + }); + + test('错误提示显示正确', async ({ page }) => { + const nameInput = page.locator('input[name="name"], input[placeholder*="姓名"]'); + + if (await nameInput.count() > 0) { + await nameInput.first().fill(''); + await nameInput.first().blur(); + + await page.waitForTimeout(300); + + const parent = nameInput.first().locator('xpath=..'); + const hasError = await parent.evaluate((el) => { + return el.classList.contains('error') || + el.classList.contains('invalid') || + el.getAttribute('aria-invalid') === 'true'; + }); + + expect(typeof hasError).toBe('boolean'); + } + }); + + test('错误提示可读性良好', async ({ page }) => { + const errorMessages = page.locator('[class*="error"], [class*="invalid"], [role="alert"]'); + + if (await errorMessages.count() > 0) { + const firstError = errorMessages.first(); + const fontSize = await firstError.evaluate((el) => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + + expect(fontSize).toBeGreaterThanOrEqual(12); + + const color = await firstError.evaluate((el) => { + const style = window.getComputedStyle(el); + return style.color; + }); + + expect(color).toBeTruthy(); + } + }); + }); + + test.describe('提交流程测试', () => { + test('表单提交功能正常', async ({ page }) => { + const nameInput = page.locator('input[name="name"], input[placeholder*="姓名"]'); + const phoneInput = page.locator('input[name="phone"], input[placeholder*="电话"]'); + const emailInput = page.locator('input[name="email"], input[placeholder*="邮箱"]'); + const messageInput = page.locator('textarea[name="message"], textarea[placeholder*="消息"]'); + const submitButton = page.locator('button[type="submit"], button:has-text("提交")'); + + if (await nameInput.count() > 0) { + await nameInput.first().fill(TestDataGenerator.generateName()); + } + + if (await phoneInput.count() > 0) { + await phoneInput.first().fill(TestDataGenerator.generatePhone()); + } + + if (await emailInput.count() > 0) { + await emailInput.first().fill(TestDataGenerator.generateEmail()); + } + + if (await messageInput.count() > 0) { + await messageInput.first().fill(TestDataGenerator.generateMessage()); + } + + if (await submitButton.count() > 0) { + await submitButton.first().click(); + await page.waitForTimeout(2000); + + const successMessage = page.locator('[class*="success"], text=/成功|感谢|已发送/'); + const hasSuccess = await successMessage.count() > 0; + + expect(typeof hasSuccess).toBe('boolean'); + } + }); + + test('成功消息显示正确', async ({ page }) => { + const successMessage = page.locator('[class*="success"], text=/成功|感谢|已发送/'); + + if (await successMessage.count() > 0) { + await expect(successMessage.first()).toBeVisible({ timeout: 5000 }); + + const text = await successMessage.first().textContent(); + expect(text).toBeTruthy(); + expect(text!.length).toBeGreaterThan(0); + } + }); + + test('错误处理正常', async ({ page }) => { + const submitButton = page.locator('button[type="submit"], button:has-text("提交")'); + + if (await submitButton.count() > 0) { + await submitButton.first().click(); + await page.waitForTimeout(1000); + + const errorMessage = page.locator('[class*="error"], [class*="failed"]'); + const hasError = await errorMessage.count() > 0; + + expect(typeof hasError).toBe('boolean'); + } + }); + + test('提交按钮状态变化正常', async ({ page }) => { + const submitButton = page.locator('button[type="submit"], button:has-text("提交")'); + + if (await submitButton.count() > 0) { + const initialState = await submitButton.first().getAttribute('disabled'); + + const nameInput = page.locator('input[name="name"], input[placeholder*="姓名"]'); + if (await nameInput.count() > 0) { + await nameInput.first().fill(TestDataGenerator.generateName()); + } + + const currentState = await submitButton.first().getAttribute('disabled'); + + expect(typeof initialState).toBe('string' || null); + expect(typeof currentState).toBe('string' || null); + } + }); + }); + + test.describe('联系信息测试', () => { + test('地址可点击性正常', async ({ page }) => { + const addressLinks = page.locator('a[href*="map"], a[href*="baidu.com/map"], a[href*="amap.com"]'); + + if (await addressLinks.count() > 0) { + const firstAddress = addressLinks.first(); + const href = await firstAddress.getAttribute('href'); + expect(href).toBeTruthy(); + expect(href).toMatch(/map|baidu|amap/i); + } + }); + + test('电话可点击性正常', async ({ page }) => { + const phoneLinks = page.locator('a[href^="tel:"]'); + const count = await phoneLinks.count(); + + if (count > 0) { + const firstPhone = phoneLinks.first(); + const href = await firstPhone.getAttribute('href'); + expect(href).toMatch(/^tel:\d+/); + + const box = await firstPhone.boundingBox(); + expect(box).toBeTruthy(); + expect(box!.height).toBeGreaterThanOrEqual(44); + } + }); + + test('邮箱可点击性正常', async ({ page }) => { + const emailLinks = page.locator('a[href^="mailto:"]'); + const count = await emailLinks.count(); + + if (count > 0) { + const firstEmail = emailLinks.first(); + const href = await firstEmail.getAttribute('href'); + expect(href).toMatch(/^mailto:.+@.+/); + + const box = await firstEmail.boundingBox(); + expect(box).toBeTruthy(); + expect(box!.height).toBeGreaterThanOrEqual(44); + } + }); + }); + + test.describe('地图集成测试', () => { + test('地图显示正常', async ({ page }) => { + const mapContainer = page.locator('[class*="map"], iframe[src*="map"], #map'); + + if (await mapContainer.count() > 0) { + await expect(mapContainer.first()).toBeVisible({ timeout: 10000 }); + + const box = await mapContainer.first().boundingBox(); + expect(box).toBeTruthy(); + expect(box!.width).toBeGreaterThan(200); + expect(box!.height).toBeGreaterThan(150); + } + }); + + test('地图交互正常', async ({ page }) => { + const mapIframe = page.locator('iframe[src*="map"]'); + + if (await mapIframe.count() > 0) { + const box = await mapIframe.first().boundingBox(); + expect(box).toBeTruthy(); + + await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); + await page.waitForTimeout(500); + } + }); + }); + + test.describe('性能测试', () => { + test('联系页加载性能符合标准', async ({ page }) => { + await performanceMonitor.startMonitoring(); + + const metrics = await performanceMonitor.collectMetrics(); + + expect(metrics.loadTime).toBeLessThan(5000); + expect(metrics.firstContentfulPaint).toBeLessThan(1800); + }); + + test('表单输入响应速度正常', async ({ page }) => { + const nameInput = page.locator('input[name="name"], input[placeholder*="姓名"]'); + + if (await nameInput.count() > 0) { + const startTime = Date.now(); + await nameInput.first().fill(TestDataGenerator.generateName()); + const endTime = Date.now(); + + expect(endTime - startTime).toBeLessThan(1000); + } + }); + }); + + test.describe('可访问性测试', () => { + test('表单标签关联正确', async ({ page }) => { + const labels = page.locator('label'); + const count = await labels.count(); + + if (count > 0) { + const firstLabel = labels.first(); + const forAttr = await firstLabel.getAttribute('for'); + + if (forAttr) { + const input = page.locator(`#${forAttr}`); + await expect(input).toBeVisible(); + } + } + }); + + test('表单字段触摸目标大小符合标准', async ({ page }) => { + const inputs = page.locator('input, textarea, select'); + const count = await inputs.count(); + + if (count > 0) { + const firstInput = inputs.first(); + const box = await firstInput.boundingBox(); + + expect(box).toBeTruthy(); + expect(box!.height).toBeGreaterThanOrEqual(44); + } + }); + + test('提交按钮触摸目标大小符合标准', async ({ page }) => { + const submitButton = page.locator('button[type="submit"], button:has-text("提交")'); + + if (await submitButton.count() > 0) { + const box = await submitButton.first().boundingBox(); + + expect(box).toBeTruthy(); + expect(box!.width).toBeGreaterThanOrEqual(44); + expect(box!.height).toBeGreaterThanOrEqual(44); + } + }); + }); +}); diff --git a/e2e/src/tests/mobile/mobile-functionality.spec.ts b/e2e/src/tests/mobile/mobile-functionality.spec.ts new file mode 100644 index 0000000..0cc1d8e --- /dev/null +++ b/e2e/src/tests/mobile/mobile-functionality.spec.ts @@ -0,0 +1,358 @@ +import { test, expect, Page } from '@playwright/test'; +import { MobilePage } from '../pages/MobilePage'; +import { TestDataGenerator } from '../utils/TestDataGenerator'; +import { PerformanceMonitor } from '../utils/PerformanceMonitor'; +import { MobileHelper } from '../utils/MobileHelper'; +import { getCoreMobileDevices } from '../utils/DeviceMatrix'; + +test.describe('首页移动端功能测试 @mobile', () => { + let mobilePage: MobilePage; + let performanceMonitor: PerformanceMonitor; + let mobileHelper: MobileHelper; + + test.beforeEach(async ({ page }) => { + mobilePage = new MobilePage(page); + performanceMonitor = new PerformanceMonitor(page); + mobileHelper = new MobileHelper(page); + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + }); + + test.describe('Hero区域测试', () => { + test('Hero标题在移动端可见且响应式', async ({ page }) => { + const heroTitle = page.locator('h1').first(); + await expect(heroTitle).toBeVisible({ timeout: 10000 }); + + const titleText = await heroTitle.textContent(); + expect(titleText).toBeTruthy(); + expect(titleText!.length).toBeGreaterThan(0); + + const box = await heroTitle.boundingBox(); + expect(box).toBeTruthy(); + expect(box!.width).toBeLessThanOrEqual(400); + }); + + test('Hero描述文本可读', async ({ page }) => { + const heroDescription = page.locator('h1 + p, [class*="hero"] p').first(); + await expect(heroDescription).toBeVisible({ timeout: 10000 }); + + const fontSize = await heroDescription.evaluate((el) => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(fontSize).toBeGreaterThanOrEqual(14); + }); + + test('CTA按钮触摸目标大小符合标准', async ({ page }) => { + const ctaButtons = page.locator('a[href="#contact"], a[href*="contact"], button:has-text("咨询"), button:has-text("联系")'); + const count = await ctaButtons.count(); + + if (count > 0) { + const firstButton = ctaButtons.first(); + const box = await firstButton.boundingBox(); + + expect(box).toBeTruthy(); + expect(box!.width).toBeGreaterThanOrEqual(44); + expect(box!.height).toBeGreaterThanOrEqual(44); + } + }); + + test('CTA按钮点击响应正常', async ({ page }) => { + const ctaButton = page.locator('a[href="#contact"], a[href*="contact"]').first(); + + if (await ctaButton.isVisible()) { + await ctaButton.click(); + await page.waitForTimeout(500); + + const currentUrl = page.url(); + expect(currentUrl).toContain('contact'); + } + }); + }); + + test.describe('导航测试', () => { + test('移动菜单开关功能正常', async ({ page }) => { + const menuButton = page.locator('button[aria-label="打开菜单"], button[aria-label="关闭菜单"]'); + await expect(menuButton).toBeVisible({ timeout: 10000 }); + + await menuButton.click(); + + const mobileMenu = page.locator('#mobile-menu'); + await expect(mobileMenu).toBeVisible({ timeout: 10000 }); + + const closeButton = page.locator('button[aria-label="关闭菜单"]'); + await closeButton.click(); + + await expect(mobileMenu).not.toBeVisible({ timeout: 10000 }); + }); + + test('导航链接点击正常', async ({ page }) => { + const menuButton = page.locator('button[aria-label="打开菜单"]'); + await menuButton.click(); + + const mobileMenu = page.locator('#mobile-menu'); + await expect(mobileMenu).toBeVisible({ timeout: 10000 }); + + const navLinks = mobileMenu.locator('a'); + const count = await navLinks.count(); + + if (count > 0) { + const firstLink = navLinks.first(); + const linkText = await firstLink.textContent(); + + await firstLink.click(); + await page.waitForTimeout(500); + + const closeButton = page.locator('button[aria-label="关闭菜单"]'); + if (await closeButton.isVisible()) { + await closeButton.click(); + } + } + }); + + test('面包屑导航显示正确', async ({ page }) => { + await page.goto('/about'); + await page.waitForLoadState('domcontentloaded'); + + const breadcrumb = page.locator('nav[aria-label="breadcrumb"]'); + await expect(breadcrumb).toBeVisible({ timeout: 10000 }); + + const breadcrumbLinks = breadcrumb.locator('a'); + const count = await breadcrumbLinks.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + }); + + test.describe('内容区域测试', () => { + test('服务卡片响应式布局正确', async ({ page }) => { + const servicesSection = page.locator('#services, [id*="service"]'); + + if (await servicesSection.count() > 0) { + const serviceCards = servicesSection.first().locator('article, [class*="card"]'); + const count = await serviceCards.count(); + + if (count >= 2) { + const firstCard = serviceCards.first(); + const secondCard = serviceCards.nth(1); + + const firstBox = await firstCard.boundingBox(); + const secondBox = await secondCard.boundingBox(); + + if (firstBox && secondBox) { + expect(secondBox.y).toBeGreaterThan(firstBox.y); + expect(firstBox.width).toBeLessThanOrEqual(400); + } + } + } + }); + + test('产品卡片堆叠布局正确', async ({ page }) => { + const productsSection = page.locator('#products, [id*="product"]'); + + if (await productsSection.count() > 0) { + const productCards = productsSection.first().locator('article, [class*="card"], a'); + const count = await productCards.count(); + + if (count >= 2) { + const firstCard = productCards.first(); + const secondCard = productCards.nth(1); + + const firstBox = await firstCard.boundingBox(); + const secondBox = await secondCard.boundingBox(); + + if (firstBox && secondBox) { + expect(secondBox.y).toBeGreaterThan(firstBox.y); + } + } + } + }); + + test('案例卡片响应式布局正确', async ({ page }) => { + const casesSection = page.locator('#cases, [id*="case"]'); + + if (await casesSection.count() > 0) { + const caseCards = casesSection.first().locator('article, [class*="card"]'); + const count = await caseCards.count(); + expect(count).toBeGreaterThan(0); + + if (count > 0) { + const firstCard = caseCards.first(); + const box = await firstCard.boundingBox(); + + if (box) { + expect(box.width).toBeLessThanOrEqual(400); + } + } + } + }); + + test('新闻卡片响应式布局正确', async ({ page }) => { + const newsSection = page.locator('#news, [id*="news"]'); + + if (await newsSection.count() > 0) { + const newsCards = newsSection.first().locator('article, [class*="card"]'); + const count = await newsCards.count(); + expect(count).toBeGreaterThan(0); + } + }); + }); + + test.describe('页脚测试', () => { + test('联系信息可点击', async ({ page }) => { + const footer = page.locator('footer'); + await expect(footer).toBeVisible({ timeout: 10000 }); + + const phoneLinks = footer.locator('a[href^="tel:"]'); + const phoneCount = await phoneLinks.count(); + + if (phoneCount > 0) { + const firstPhone = phoneLinks.first(); + const href = await firstPhone.getAttribute('href'); + expect(href).toContain('tel:'); + } + + const emailLinks = footer.locator('a[href^="mailto:"]'); + const emailCount = await emailLinks.count(); + + if (emailCount > 0) { + const firstEmail = emailLinks.first(); + const href = await firstEmail.getAttribute('href'); + expect(href).toContain('mailto:'); + } + }); + + test('社交媒体链接正常', async ({ page }) => { + const footer = page.locator('footer'); + const socialLinks = footer.locator('a[target="_blank"], a[href*="weibo"], a[href*="wechat"], a[href*="linkedin"]'); + const count = await socialLinks.count(); + + if (count > 0) { + const firstSocial = socialLinks.first(); + const href = await firstSocial.getAttribute('href'); + expect(href).toBeTruthy(); + } + }); + + test('版权信息显示正确', async ({ page }) => { + const footer = page.locator('footer'); + const copyright = footer.locator('text=/版权|©|Copyright/i'); + + const footerText = await footer.textContent(); + expect(footerText).toMatch(/版权|©|Copyright|公司/i); + }); + }); + + test.describe('表单交互测试', () => { + test('快速联系表单移动端适配', async ({ page }) => { + const contactSection = page.locator('#contact, [id*="contact"]'); + + if (await contactSection.count() > 0) { + const form = contactSection.first().locator('form'); + + if (await form.count() > 0) { + await expect(form.first()).toBeVisible({ timeout: 10000 }); + } + } + }); + + test('表单字段输入正常', async ({ page }) => { + const contactSection = page.locator('#contact, [id*="contact"]'); + + if (await contactSection.count() > 0) { + const nameInput = contactSection.first().locator('input[name="name"], input[placeholder*="姓名"]'); + + if (await nameInput.count() > 0) { + const testData = TestDataGenerator.generateName(); + await nameInput.first().fill(testData); + + const value = await nameInput.first().inputValue(); + expect(value).toBe(testData); + } + } + }); + + test('表单提交功能正常', async ({ page }) => { + const contactSection = page.locator('#contact, [id*="contact"]'); + + if (await contactSection.count() > 0) { + const form = contactSection.first().locator('form'); + + if (await form.count() > 0) { + const nameInput = form.first().locator('input[name="name"], input[placeholder*="姓名"]'); + const phoneInput = form.first().locator('input[name="phone"], input[placeholder*="电话"]'); + const submitButton = form.first().locator('button[type="submit"], button:has-text("提交")'); + + if (await nameInput.count() > 0 && await phoneInput.count() > 0 && await submitButton.count() > 0) { + await nameInput.first().fill(TestDataGenerator.generateName()); + await phoneInput.first().fill(TestDataGenerator.generatePhone()); + + await submitButton.first().click(); + await page.waitForTimeout(1000); + } + } + } + }); + }); + + test.describe('性能测试', () => { + test('页面加载性能符合标准', async ({ page }) => { + await performanceMonitor.startMonitoring(); + + const metrics = await performanceMonitor.collectMetrics(); + + expect(metrics.loadTime).toBeLessThan(5000); + expect(metrics.firstContentfulPaint).toBeLessThan(1800); + }); + + test('LCP符合Core Web Vitals标准', async ({ page }) => { + const lcp = await mobilePage.measureLCP(); + expect(lcp).toBeLessThan(2500); + }); + + test('CLS符合Core Web Vitals标准', async ({ page }) => { + const cls = await mobilePage.measureCLS(); + expect(cls).toBeLessThan(0.1); + }); + }); + + test.describe('可访问性测试', () => { + test('触摸目标大小符合WCAG标准', async ({ page }) => { + const buttons = page.locator('button, a, input, select, textarea'); + const count = await buttons.count(); + + const touchTargets = await buttons.all(); + let validCount = 0; + + for (const target of touchTargets.slice(0, 20)) { + const isValid = await mobilePage.checkTouchTarget(target); + if (isValid) validCount++; + } + + const passRate = validCount / Math.min(touchTargets.length, 20); + expect(passRate).toBeGreaterThan(0.9); + }); + + test('颜色对比度符合WCAG标准', async ({ page }) => { + const textElements = page.locator('p, h1, h2, h3, h4, h5, h6, span, a, label'); + const count = await textElements.count(); + expect(count).toBeGreaterThan(0); + }); + + test('焦点指示器可见', async ({ page }) => { + const focusableElements = page.locator('button, a, input, select, textarea'); + const count = await focusableElements.count(); + + if (count > 0) { + const firstElement = focusableElements.first(); + await firstElement.focus(); + + const outline = await firstElement.evaluate((el) => { + const style = window.getComputedStyle(el); + return style.outline; + }); + + expect(outline).toBeTruthy(); + } + }); + }); +}); diff --git a/e2e/src/utils/DeviceMatrix.ts b/e2e/src/utils/DeviceMatrix.ts new file mode 100644 index 0000000..751a25a --- /dev/null +++ b/e2e/src/utils/DeviceMatrix.ts @@ -0,0 +1,127 @@ +import { devices, Device } from '@playwright/test'; + +export interface MobileDevice { + name: string; + device: Device; + viewport: { width: number; height: number }; + userAgent?: string; +} + +export interface ResponsiveBreakpoint { + name: string; + width: number; + height: number; + description: string; +} + +export const mobileDevices: Record = { + 'iPhone 12': { + name: 'iPhone 12', + device: devices['iPhone 12'], + viewport: { width: 390, height: 844 }, + }, + 'iPhone 14': { + name: 'iPhone 14', + device: devices['iPhone 14'], + viewport: { width: 390, height: 844 }, + }, + 'Galaxy S21': { + name: 'Galaxy S21', + device: devices['Galaxy S21'], + viewport: { width: 360, height: 800 }, + }, + 'iPad Pro': { + name: 'iPad Pro', + device: devices['iPad Pro'], + viewport: { width: 1024, height: 1366 }, + }, + 'iPad Mini': { + name: 'iPad Mini', + device: devices['iPad Mini'], + viewport: { width: 768, height: 1024 }, + }, +}; + +export const responsiveBreakpoints: ResponsiveBreakpoint[] = [ + { + name: 'iPhone SE', + width: 375, + height: 667, + description: 'Small mobile device', + }, + { + name: 'iPhone 11 Pro Max', + width: 414, + height: 896, + description: 'Large mobile device', + }, + { + name: 'iPad Portrait', + width: 768, + height: 1024, + description: 'Tablet portrait', + }, + { + name: 'iPad Landscape', + width: 1024, + height: 768, + description: 'Tablet landscape', + }, + { + name: 'Small Laptop', + width: 1280, + height: 720, + description: 'Small laptop screen', + }, +]; + +export const coreDevices = ['iPhone 12', 'iPhone 14', 'Galaxy S21', 'iPad Pro', 'iPad Mini']; + +export function getMobileDevice(name: string): MobileDevice | undefined { + return mobileDevices[name]; +} + +export function getViewportSize(deviceName: string): { width: number; height: number } | undefined { + const device = mobileDevices[deviceName]; + return device?.viewport; +} + +export function isMobileDevice(userAgent: string): boolean { + const mobilePatterns = [ + /iPhone/i, + /iPad/i, + /iPod/i, + /Android/i, + /BlackBerry/i, + /Windows Phone/i, + /webOS/i, + /Mobile/i, + ]; + + return mobilePatterns.some(pattern => pattern.test(userAgent)); +} + +export function getDeviceByViewport(width: number, height: number): string | undefined { + for (const breakpoint of responsiveBreakpoints) { + if (width <= breakpoint.width && height <= breakpoint.height) { + return breakpoint.name; + } + } + return undefined; +} + +export function getAllMobileDevices(): MobileDevice[] { + return Object.values(mobileDevices); +} + +export function getCoreMobileDevices(): MobileDevice[] { + return coreDevices.map(name => mobileDevices[name]).filter(Boolean) as MobileDevice[]; +} + +export function getAllResponsiveBreakpoints(): ResponsiveBreakpoint[] { + return [...responsiveBreakpoints]; +} + +export function getBreakpointByWidth(width: number): ResponsiveBreakpoint | undefined { + return responsiveBreakpoints.find(bp => width <= bp.width); +} diff --git a/e2e/src/utils/MobileHelper.ts b/e2e/src/utils/MobileHelper.ts new file mode 100644 index 0000000..2c55c64 --- /dev/null +++ b/e2e/src/utils/MobileHelper.ts @@ -0,0 +1,270 @@ +import { Page } from '@playwright/test'; +import { getMobileDevice, isMobileDevice, getDeviceByViewport } from './DeviceMatrix'; + +export interface TouchEvent { + type: 'touchstart' | 'touchmove' | 'touchend'; + touches: Array<{ x: number; y: number }>; + timestamp: number; +} + +export interface NetworkCondition { + offline: boolean; + downloadThroughput: number; + uploadThroughput: number; + latency: number; +} + +export class MobileHelper { + constructor(private page: Page) {} + + async getMobileDevice(name: string) { + return getMobileDevice(name); + } + + async getViewportSize(device: string) { + const device = await this.getMobileDevice(device); + return device?.viewport; + } + + async isMobileDevice(userAgent?: string): Promise { + const ua = userAgent || await this.page.evaluate(() => navigator.userAgent); + return isMobileDevice(ua); + } + + async getCurrentViewport(): Promise<{ width: number; height: number }> { + return await this.page.evaluate(() => { + return { + width: window.innerWidth, + height: window.innerHeight, + }; + }); + } + + async getCurrentDeviceName(): Promise { + const viewport = await this.getCurrentViewport(); + return getDeviceByViewport(viewport.width, viewport.height); + } + + async isPortrait(): Promise { + const viewport = await this.getCurrentViewport(); + return viewport.height > viewport.width; + } + + async isLandscape(): Promise { + const viewport = await this.getCurrentViewport(); + return viewport.width > viewport.height; + } + + async getTouchSupport(): Promise<{ + maxTouchPoints: number; + touchEvent: boolean; + }> { + return await this.page.evaluate(() => { + return { + maxTouchPoints: navigator.maxTouchPoints || 0, + touchEvent: 'ontouchstart' in window, + }; + }); + } + + async generateTouchEvent(type: 'touchstart' | 'touchmove' | 'touchend', touches: Array<{ x: number; y: number }>): Promise { + return { + type, + touches, + timestamp: Date.now(), + }; + } + + async simulateTouch(element: string, x: number, y: number): Promise { + await this.page.locator(element).dispatchEvent('touchstart', { + touches: [{ clientX: x, clientY: y }], + }); + + await this.page.waitForTimeout(50); + + await this.page.locator(element).dispatchEvent('touchend', { + touches: [], + }); + } + + async simulateSwipe(startX: number, startY: number, endX: number, endY: number, duration: number = 500): Promise { + const steps = 10; + const stepDelay = duration / steps; + + for (let i = 0; i <= steps; i++) { + const x = startX + (endX - startX) * (i / steps); + const y = startY + (endY - startY) * (i / steps); + + await this.page.mouse.move(x, y); + await this.page.waitForTimeout(stepDelay); + } + } + + async simulatePinchZoom(centerX: number, centerY: number, scale: number): Promise { + const startDistance = 100; + const endDistance = startDistance / scale; + + const finger1Start = { x: centerX - startDistance / 2, y: centerY }; + const finger2Start = { x: centerX + startDistance / 2, y: centerY }; + + const finger1End = { x: centerX - endDistance / 2, y: centerY }; + const finger2End = { x: centerX + endDistance / 2, y: centerY }; + + await this.page.mouse.move(finger1Start.x, finger1Start.y); + await this.page.mouse.down(); + + await this.page.mouse.move(finger2Start.x, finger2Start.y); + await this.page.mouse.down(); + + await this.page.mouse.move(finger1End.x, finger1End.y); + await this.page.mouse.move(finger2End.x, finger2End.y); + + await this.page.mouse.up(); + await this.page.mouse.up(); + } + + async setNetworkCondition(condition: NetworkCondition): Promise { + await this.page.route('**', (route) => { + if (condition.offline) { + route.abort(); + } else { + route.continue(); + } + }); + + await this.page.evaluate((condition) => { + Object.defineProperty(navigator, 'connection', { + value: { + effectiveType: condition.downloadThroughput < 1.5 ? 'slow-2g' : '4g', + downlink: condition.downloadThroughput, + rtt: condition.latency, + }, + writable: true, + }); + }, condition); + } + + async getNetworkCondition(): Promise { + return await this.page.evaluate(() => { + const connection = (navigator as any).connection; + return { + offline: !navigator.onLine, + downloadThroughput: connection?.downlink || 10, + uploadThroughput: connection?.downlink || 10, + latency: connection?.rtt || 100, + }; + }); + } + + async setViewport(width: number, height: number): Promise { + await this.page.setViewportSize({ width, height }); + } + + async setDevice(deviceName: string): Promise { + const device = await this.getMobileDevice(deviceName); + if (device) { + await this.page.setViewportSize(device.viewport); + + if (device.userAgent) { + await this.page.setExtraHTTPHeaders({ + 'User-Agent': device.userAgent, + }); + } + } + } + + async emulateMobile(deviceName: string): Promise { + await this.setDevice(deviceName); + + await this.page.emulateMedia({ + media: 'screen', + colorScheme: 'light', + }); + } + + async getBatteryInfo(): Promise<{ + level: number; + charging: boolean; + }> { + return await this.page.evaluate(() => { + const battery = (navigator as any).getBattery(); + if (battery) { + return { + level: battery.level, + charging: battery.charging, + }; + } + return { + level: 1, + charging: true, + }; + }); + } + + async getOrientation(): Promise<'portrait' | 'landscape'> { + const viewport = await this.getCurrentViewport(); + return viewport.height > viewport.width ? 'portrait' : 'landscape'; + } + + async rotateDevice(): Promise { + const viewport = await this.getCurrentViewport(); + await this.page.setViewportSize({ + width: viewport.height, + height: viewport.width, + }); + } + + async hideKeyboard(): Promise { + await this.page.keyboard.press('Escape'); + await this.page.waitForTimeout(200); + } + + async scrollToElement(selector: string): Promise { + const element = this.page.locator(selector); + await element.scrollIntoViewIfNeeded(); + await this.page.waitForTimeout(500); + } + + async scrollToTop(): Promise { + await this.page.evaluate(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + await this.page.waitForTimeout(500); + } + + async scrollToBottom(): Promise { + await this.page.evaluate(() => { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }); + await this.page.waitForTimeout(500); + } + + async getScrollPosition(): Promise<{ x: number; y: number }> { + return await this.page.evaluate(() => { + return { + x: window.scrollX, + y: window.scrollY, + }; + }); + } + + async isElementInViewport(selector: string): Promise { + return await this.page.locator(selector).evaluate((el) => { + const rect = el.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); + }); + } + + async waitForElementVisible(selector: string, timeout: number = 5000): Promise { + await this.page.waitForSelector(selector, { state: 'visible', timeout }); + } + + async waitForElementHidden(selector: string, timeout: number = 5000): Promise { + await this.page.waitForSelector(selector, { state: 'hidden', timeout }); + } +} diff --git a/e2e/src/utils/PerformanceMonitor.ts b/e2e/src/utils/PerformanceMonitor.ts index 2cdf0e1..5e0bc9b 100644 --- a/e2e/src/utils/PerformanceMonitor.ts +++ b/e2e/src/utils/PerformanceMonitor.ts @@ -314,4 +314,201 @@ export class PerformanceMonitor { return report; } + + async measureFirstMeaningfulPaint(): Promise { + const fmp = await this.page.evaluate(() => { + return new Promise((resolve) => { + if ('PerformanceObserver' in window) { + const observer = new PerformanceObserver((list) => { + const entries = list.getEntries(); + if (entries.length > 0) { + resolve(entries[0].startTime); + } + }); + observer.observe({ entryTypes: ['first-meaningful-paint'] }); + setTimeout(() => resolve(0), 5000); + } else { + resolve(0); + } + }); + }); + return fmp; + } + + async measureNetworkPerformance(): Promise<{ + dnsLookup: number; + tcpConnection: number; + sslHandshake: number; + requestTime: number; + responseTime: number; + totalTime: number; + }> { + const timing = await this.page.evaluate(() => { + const perf = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + return { + dnsLookup: perf.domainLookupEnd - perf.domainLookupStart, + tcpConnection: perf.connectEnd - perf.connectStart, + sslHandshake: perf.secureConnectionStart > 0 ? perf.connectEnd - perf.secureConnectionStart : 0, + requestTime: perf.responseStart - perf.requestStart, + responseTime: perf.responseEnd - perf.responseStart, + totalTime: perf.loadEventEnd - perf.fetchStart, + }; + }); + return timing; + } + + async measureBatteryImpact(): Promise<{ + estimatedImpact: string; + recommendations: string[]; + }> { + const metrics = await this.collectMetrics(); + const recommendations: string[] = []; + let impact = 'low'; + + if (metrics.loadTime > 3000) { + recommendations.push('页面加载时间过长,建议优化资源加载'); + impact = 'high'; + } + if (metrics.firstInputDelay > 100) { + recommendations.push('首次输入延迟较高,建议优化JavaScript执行'); + impact = impact === 'high' ? 'high' : 'medium'; + } + if (metrics.largestContentfulPaint > 2500) { + recommendations.push('最大内容绘制时间过长,建议优化关键渲染路径'); + impact = impact === 'high' ? 'high' : 'medium'; + } + + return { + estimatedImpact: impact, + recommendations, + }; + } + + async validateLCP(value: number, threshold: number = 2500): boolean { + return value <= threshold; + } + + async validateFID(value: number, threshold: number = 100): boolean { + return value <= threshold; + } + + async validateCLS(value: number, threshold: number = 0.1): boolean { + return value <= threshold; + } + + async validateTTI(value: number, threshold: number = 3500): boolean { + return value <= threshold; + } + + async validateTTFB(value: number, threshold: number = 600): boolean { + return value <= threshold; + } + + async validateFCP(value: number, threshold: number = 1800): boolean { + return value <= threshold; + } + + async getCoreWebVitalsSummary(): Promise<{ + lcp: { value: number; threshold: number; passed: boolean }; + fid: { value: number; threshold: number; passed: boolean }; + cls: { value: number; threshold: number; passed: boolean }; + tti: { value: number; threshold: number; passed: boolean }; + ttfb: { value: number; threshold: number; passed: boolean }; + fcp: { value: number; threshold: number; passed: boolean }; + }> { + const metrics = await this.collectMetrics(); + const ttfb = await this.measureFirstByteTime(); + const fcp = await this.measureFirstContentfulPaint(); + + return { + lcp: { + value: metrics.largestContentfulPaint, + threshold: 2500, + passed: await this.validateLCP(metrics.largestContentfulPaint), + }, + fid: { + value: metrics.firstInputDelay, + threshold: 100, + passed: await this.validateFID(metrics.firstInputDelay), + }, + cls: { + value: metrics.cumulativeLayoutShift, + threshold: 0.1, + passed: await this.validateCLS(metrics.cumulativeLayoutShift), + }, + tti: { + value: metrics.timeToInteractive, + threshold: 3500, + passed: await this.validateTTI(metrics.timeToInteractive), + }, + ttfb: { + value: ttfb, + threshold: 600, + passed: await this.validateTTFB(ttfb), + }, + fcp: { + value: fcp, + threshold: 1800, + passed: await this.validateFCP(fcp), + }, + }; + } + + async measureFirstByteTime(): Promise { + const ttfb = await this.page.evaluate(() => { + const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + return timing.responseStart - timing.fetchStart; + }); + return ttfb; + } + + async measureMobileSpecificMetrics(): Promise<{ + touchResponseTime: number; + scrollPerformance: number; + gestureLatency: number; + }> { + const touchResponseTime = await this.page.evaluate(() => { + return new Promise((resolve) => { + const startTime = performance.now(); + document.addEventListener('touchstart', () => { + resolve(performance.now() - startTime); + }, { once: true }); + setTimeout(() => resolve(0), 1000); + }); + }); + + const scrollPerformance = await this.page.evaluate(() => { + return new Promise((resolve) => { + const startTime = performance.now(); + let frames = 0; + + function countFrames() { + frames++; + if (performance.now() - startTime >= 1000) { + resolve(frames); + } else { + requestAnimationFrame(countFrames); + } + } + + requestAnimationFrame(countFrames); + }); + }); + + const gestureLatency = await this.page.evaluate(() => { + return new Promise((resolve) => { + const startTime = performance.now(); + document.addEventListener('touchmove', () => { + resolve(performance.now() - startTime); + }, { once: true }); + setTimeout(() => resolve(0), 500); + }); + }); + + return { + touchResponseTime, + scrollPerformance, + gestureLatency, + }; + } } diff --git a/e2e/src/utils/TestDataGenerator.ts b/e2e/src/utils/TestDataGenerator.ts index 6a472cf..64ffbe1 100644 --- a/e2e/src/utils/TestDataGenerator.ts +++ b/e2e/src/utils/TestDataGenerator.ts @@ -457,4 +457,347 @@ ${this.generateMessage()}`; const suffix = Math.floor(Math.random() * 90000000 + 10000000); return `${prefix}${suffix}`; } + + static generateMobileDevice(): { + name: string; + userAgent: string; + viewport: { width: number; height: number }; + devicePixelRatio: number; + touchPoints: number; + } { + const devices = [ + { + name: 'iPhone 12', + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1', + viewport: { width: 390, height: 844 }, + devicePixelRatio: 3, + touchPoints: 5, + }, + { + name: 'iPhone 14', + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', + viewport: { width: 390, height: 844 }, + devicePixelRatio: 3, + touchPoints: 5, + }, + { + name: 'Galaxy S21', + userAgent: 'Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36', + viewport: { width: 360, height: 800 }, + devicePixelRatio: 3, + touchPoints: 5, + }, + { + name: 'iPad Pro', + userAgent: 'Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1', + viewport: { width: 1024, height: 1366 }, + devicePixelRatio: 2, + touchPoints: 5, + }, + ]; + return devices[Math.floor(Math.random() * devices.length)]!; + } + + static generateMobileNetworkCondition(): { + offline: boolean; + downloadThroughput: number; + uploadThroughput: number; + latency: number; + name: string; + } { + const conditions = [ + { + offline: false, + downloadThroughput: 10, + uploadThroughput: 5, + latency: 100, + name: '4G', + }, + { + offline: false, + downloadThroughput: 1.5, + uploadThroughput: 0.75, + latency: 300, + name: '3G', + }, + { + offline: false, + downloadThroughput: 0.4, + uploadThroughput: 0.2, + latency: 1000, + name: '2G', + }, + { + offline: true, + downloadThroughput: 0, + uploadThroughput: 0, + latency: 0, + name: 'Offline', + }, + ]; + return conditions[Math.floor(Math.random() * conditions.length)]!; + } + + static generateTouchEvent(): { + type: 'touchstart' | 'touchmove' | 'touchend'; + touches: Array<{ x: number; y: number }>; + timestamp: number; + } { + const types: Array<'touchstart' | 'touchmove' | 'touchend'> = ['touchstart', 'touchmove', 'touchend']; + const type = types[Math.floor(Math.random() * types.length)]!; + const touches = Array.from({ length: Math.floor(Math.random() * 3) + 1 }, () => ({ + x: Math.floor(Math.random() * 400), + y: Math.floor(Math.random() * 800), + })); + + return { + type, + touches, + timestamp: Date.now(), + }; + } + + static generateSwipeGesture(): { + startX: number; + startY: number; + endX: number; + endY: number; + duration: number; + } { + const startX = Math.floor(Math.random() * 300) + 50; + const startY = Math.floor(Math.random() * 700) + 50; + const direction = Math.random() < 0.5 ? 1 : -1; + + return { + startX, + startY, + endX: startX + Math.floor(Math.random() * 200) * direction, + endY: startY + Math.floor(Math.random() * 200) * direction, + duration: Math.floor(Math.random() * 500) + 200, + }; + } + + static generatePinchZoomGesture(): { + centerX: number; + centerY: number; + scale: number; + duration: number; + } { + return { + centerX: Math.floor(Math.random() * 300) + 50, + centerY: Math.floor(Math.random() * 700) + 50, + scale: Math.random() * 2 + 0.5, + duration: Math.floor(Math.random() * 500) + 200, + }; + } + + static generateMobilePerformanceThresholds(): { + lcp: { good: number; needsImprovement: number; poor: number }; + fid: { good: number; needsImprovement: number; poor: number }; + cls: { good: number; needsImprovement: number; poor: number }; + tti: { good: number; needsImprovement: number; poor: number }; + ttfb: { good: number; needsImprovement: number; poor: number }; + fcp: { good: number; needsImprovement: number; poor: number }; + } { + return { + lcp: { good: 2500, needsImprovement: 4000, poor: 4000 }, + fid: { good: 100, needsImprovement: 300, poor: 300 }, + cls: { good: 0.1, needsImprovement: 0.25, poor: 0.25 }, + tti: { good: 3500, needsImprovement: 5000, poor: 5000 }, + ttfb: { good: 600, needsImprovement: 1500, poor: 1500 }, + fcp: { good: 1800, needsImprovement: 3000, poor: 3000 }, + }; + } + + static generateMobileAccessibilityTestData(): { + touchTargetSize: { min: number; recommended: number }; + colorContrast: { normalText: number; largeText: number }; + fontScale: { min: number; max: number }; + spacing: { min: number }; + focusIndicator: { minSize: number }; + } { + return { + touchTargetSize: { min: 44, recommended: 48 }, + colorContrast: { normalText: 4.5, largeText: 3.0 }, + fontScale: { min: 1.0, max: 2.0 }, + spacing: { min: 8 }, + focusIndicator: { minSize: 2 }, + }; + } + + static generateMobileFormTestData(): { + name: string; + email: string; + phone: string; + company: string; + message: string; + subject: string; + touchTargets: Array<{ selector: string; size: { width: number; height: number } }>; + } { + return { + name: this.generateName(), + email: this.generateEmail(), + phone: this.generatePhone(), + company: this.generateCompany(), + message: this.generateMessage(), + subject: this.generateSubject(), + touchTargets: [ + { selector: 'input[name="name"]', size: { width: 350, height: 48 } }, + { selector: 'input[name="email"]', size: { width: 350, height: 48 } }, + { selector: 'input[name="phone"]', size: { width: 350, height: 48 } }, + { selector: 'textarea[name="message"]', size: { width: 350, height: 150 } }, + { selector: 'button[type="submit"]', size: { width: 350, height: 48 } }, + ], + }; + } + + static generateMobileNavigationTestData(): { + menuItems: Array<{ label: string; href: string; touchTarget: boolean }>; + hamburgerMenu: { selector: string; size: { width: number; height: number } }; + breadcrumbs: Array<{ label: string; href: string }>; + } { + return { + menuItems: [ + { label: '首页', href: '#home', touchTarget: true }, + { label: '关于我们', href: '#about', touchTarget: true }, + { label: '服务', href: '#services', touchTarget: true }, + { label: '产品', href: '#products', touchTarget: true }, + { label: '案例', href: '#cases', touchTarget: true }, + { label: '新闻', href: '#news', touchTarget: true }, + { label: '联系我们', href: '#contact', touchTarget: true }, + ], + hamburgerMenu: { + selector: 'button[aria-label="打开菜单"]', + size: { width: 48, height: 48 }, + }, + breadcrumbs: [ + { label: '首页', href: '/' }, + { label: '服务', href: '#services' }, + { label: '详情', href: '#details' }, + ], + }; + } + + static generateMobileScrollTestData(): { + scrollPositions: Array<{ x: number; y: number }>; + scrollSpeeds: Array<{ pixelsPerSecond: number }>; + scrollDirections: Array<'up' | 'down' | 'left' | 'right'>; + } { + return { + scrollPositions: [ + { x: 0, y: 0 }, + { x: 0, y: 500 }, + { x: 0, y: 1000 }, + { x: 0, y: 1500 }, + { x: 0, y: 2000 }, + ], + scrollSpeeds: [ + { pixelsPerSecond: 500 }, + { pixelsPerSecond: 1000 }, + { pixelsPerSecond: 1500 }, + { pixelsPerSecond: 2000 }, + ], + scrollDirections: ['up', 'down', 'left', 'right'], + }; + } + + static generateMobileOrientationTestData(): { + portrait: { width: number; height: number }; + landscape: { width: number; height: number }; + rotation: { from: string; to: string }[]; + } { + return { + portrait: { width: 390, height: 844 }, + landscape: { width: 844, height: 390 }, + rotation: [ + { from: 'portrait', to: 'landscape' }, + { from: 'landscape', to: 'portrait' }, + ], + }; + } + + static generateMobileBatteryTestData(): { + levels: Array<{ level: number; charging: boolean }>; + lowBatteryThreshold: number; + criticalBatteryThreshold: number; + } { + return { + levels: [ + { level: 1.0, charging: true }, + { level: 0.75, charging: false }, + { level: 0.5, charging: false }, + { level: 0.25, charging: false }, + { level: 0.1, charging: false }, + ], + lowBatteryThreshold: 0.2, + criticalBatteryThreshold: 0.1, + }; + } + + static generateMobileGestureTestData(): { + tap: { duration: number; coordinates: Array<{ x: number; y: number }> }; + doubleTap: { interval: number; coordinates: Array<{ x: number; y: number }> }; + longPress: { duration: number; coordinates: Array<{ x: number; y: number }> }; + swipe: { directions: Array<'left' | 'right' | 'up' | 'down'>; distance: number }; + pinch: { scales: Array; duration: number }; + } { + return { + tap: { + duration: 100, + coordinates: [ + { x: 200, y: 400 }, + { x: 300, y: 500 }, + { x: 150, y: 600 }, + ], + }, + doubleTap: { + interval: 300, + coordinates: [ + { x: 200, y: 400 }, + { x: 300, y: 500 }, + ], + }, + longPress: { + duration: 1000, + coordinates: [ + { x: 200, y: 400 }, + { x: 300, y: 500 }, + ], + }, + swipe: { + directions: ['left', 'right', 'up', 'down'], + distance: 200, + }, + pinch: { + scales: [0.5, 1.5, 2.0], + duration: 500, + }, + }; + } + + static generateMobileErrorScenarios(): { + networkError: { message: string; selector: string }; + timeoutError: { message: string; timeout: number }; + touchError: { message: string; selector: string }; + viewportError: { message: string; viewport: { width: number; height: number } }; + } { + return { + networkError: { + message: '网络连接失败,请检查您的网络设置', + selector: '.network-error', + }, + timeoutError: { + message: '请求超时,请稍后重试', + timeout: 30000, + }, + touchError: { + message: '触摸目标不可用', + selector: '.touch-error', + }, + viewportError: { + message: '当前视口大小不支持', + viewport: { width: 320, height: 480 }, + }, + }; + } } diff --git a/public/images/qrcode_for_gh_a297181ff548_258.jpg b/public/images/qrcode_for_gh_a297181ff548_258.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d1f76546d2e8f0da34da2e48af3bae8efc53c7db GIT binary patch literal 28754 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 void; } -export function Breadcrumb({ items }: BreadcrumbProps) { +export function Breadcrumb({ items, showBackButton = false, onBackClick }: BreadcrumbProps) { return ( -