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 0000000..d1f7654 Binary files /dev/null and b/public/images/qrcode_for_gh_a297181ff548_258.jpg differ diff --git a/src/app/globals.css b/src/app/globals.css index 1b80da7..324b78c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -38,9 +38,9 @@ /* 文字色系 - 墨色层次 */ --color-text-primary: #1C1C1C; - --color-text-secondary: #3D3D3D; - --color-text-tertiary: #5C5C5C; - --color-text-muted: #8C8C8C; + --color-text-secondary: #2D2D2D; + --color-text-tertiary: #4A4A4A; + --color-text-muted: #6B6B6B; /* 边框色系 */ --color-border-primary: #E5E5E5; @@ -211,12 +211,38 @@ input:focus, textarea:focus { outline: none; + box-shadow: 0 0 0 3px rgba(196, 30, 58, 0.3); + } + + button:focus-visible, a:focus-visible { + outline: 2px solid #C41E3A; + outline-offset: 2px; } ::selection { background-color: var(--color-text-primary); color: var(--color-bg-primary); } + + @media (prefers-contrast: high) { + :root { + --color-text-primary: #000000; + --color-text-secondary: #1A1A1A; + --color-text-tertiary: #2A2A2A; + --color-border-primary: #000000; + --color-border-secondary: #1A1A1A; + } + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } } @layer utilities { diff --git a/src/components/layout/breadcrumb.tsx b/src/components/layout/breadcrumb.tsx index 567a607..48361b1 100644 --- a/src/components/layout/breadcrumb.tsx +++ b/src/components/layout/breadcrumb.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { ChevronRight, Home } from 'lucide-react'; +import { ChevronRight, Home, ArrowLeft } from 'lucide-react'; interface BreadcrumbItem { label: string; @@ -10,20 +10,31 @@ interface BreadcrumbItem { interface BreadcrumbProps { items: BreadcrumbItem[]; + showBackButton?: boolean; + onBackClick?: () => void; } -export function Breadcrumb({ items }: BreadcrumbProps) { +export function Breadcrumb({ items, showBackButton = false, onBackClick }: BreadcrumbProps) { return ( -