feat: 添加移动端适配和测试功能
refactor(layout): 优化页脚布局和备案信息展示 feat(constants): 添加ICP备案和公安备案信息 feat(header): 实现移动端加载时的骨架屏效果 style(globals): 调整文字颜色和添加移动端响应样式 feat(breadcrumb): 增加返回按钮和响应式优化 feat(e2e): 添加移动端测试工具和测试用例 docs: 添加页脚重设计文档
This commit is contained in:
@@ -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 扩展方式
|
||||
|
||||
需要时取消注释相关代码即可启用。
|
||||
@@ -450,4 +450,142 @@ export class BasePage {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async tapElement(selector: string): Promise<void> {
|
||||
const element = this.page.locator(selector);
|
||||
const box = await element.boundingBox();
|
||||
|
||||
if (box) {
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await this.page.touchscreen.tap(x, y);
|
||||
} else {
|
||||
await element.click();
|
||||
}
|
||||
}
|
||||
|
||||
async swipe(start: { x: number; y: number }, end: { x: number; y: number }): Promise<void> {
|
||||
await this.page.touchscreen.tap(start.x, start.y);
|
||||
await this.page.touchscreen.touchMove(end.x, end.y);
|
||||
await this.page.touchscreen.touchEnd();
|
||||
}
|
||||
|
||||
async longPress(selector: string, duration: number = 1000): Promise<void> {
|
||||
const element = this.page.locator(selector);
|
||||
const box = await element.boundingBox();
|
||||
|
||||
if (box) {
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await this.page.touchscreen.tap(x, y);
|
||||
await this.page.waitForTimeout(duration);
|
||||
} else {
|
||||
await element.click();
|
||||
}
|
||||
}
|
||||
|
||||
async pinchZoom(selector: string, scale: number = 1.5): Promise<void> {
|
||||
const element = this.page.locator(selector);
|
||||
const box = await element.boundingBox();
|
||||
|
||||
if (box) {
|
||||
const centerX = box.x + box.width / 2;
|
||||
const centerY = box.y + box.height / 2;
|
||||
|
||||
const finger1 = { x: centerX - 50, y: centerY };
|
||||
const finger2 = { x: centerX + 50, y: centerY };
|
||||
|
||||
await this.page.touchscreen.tap(finger1.x, finger1.y);
|
||||
await this.page.touchscreen.tap(finger2.x, finger2.y);
|
||||
|
||||
const newFinger1 = { x: centerX - 50 / scale, y: centerY };
|
||||
const newFinger2 = { x: centerX + 50 / scale, y: centerY };
|
||||
|
||||
await this.page.touchscreen.touchMove(newFinger1.x, newFinger1.y);
|
||||
await this.page.touchscreen.touchMove(newFinger2.x, newFinger2.y);
|
||||
|
||||
await this.page.touchscreen.touchEnd();
|
||||
await this.page.touchscreen.touchEnd();
|
||||
}
|
||||
}
|
||||
|
||||
async waitForMobileLoad(): Promise<void> {
|
||||
await this.page.waitForLoadState('domcontentloaded');
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async checkTouchTarget(selector: string): Promise<boolean> {
|
||||
const element = this.page.locator(selector);
|
||||
const box = await element.boundingBox();
|
||||
|
||||
if (box) {
|
||||
return box.width >= 44 && box.height >= 44;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async captureMobileScreenshot(name: string): Promise<void> {
|
||||
const screenshotDir = 'test-results/mobile-screenshots';
|
||||
if (!fs.existsSync(screenshotDir)) {
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${timestamp}-${name}.png`;
|
||||
await this.page.screenshot({
|
||||
path: path.join(screenshotDir, filename),
|
||||
fullPage: true
|
||||
});
|
||||
}
|
||||
|
||||
async measureLCP(): Promise<number> {
|
||||
const vitals = await this.getCoreWebVitals();
|
||||
return vitals.largestContentfulPaint;
|
||||
}
|
||||
|
||||
async measureFID(): Promise<number> {
|
||||
const vitals = await this.getCoreWebVitals();
|
||||
return vitals.firstInputDelay;
|
||||
}
|
||||
|
||||
async measureCLS(): Promise<number> {
|
||||
const vitals = await this.getCoreWebVitals();
|
||||
return vitals.cumulativeLayoutShift;
|
||||
}
|
||||
|
||||
async measureTTI(): Promise<number> {
|
||||
const metrics = await this.measurePerformance();
|
||||
return metrics.domContentLoaded;
|
||||
}
|
||||
|
||||
async measureTTFB(): Promise<number> {
|
||||
const timing = await this.page.evaluate(() => {
|
||||
return performance.timing;
|
||||
});
|
||||
return timing.responseStart - timing.navigationStart;
|
||||
}
|
||||
|
||||
async handleError(error: Error, context: string): Promise<void> {
|
||||
const errorInfo = {
|
||||
timestamp: new Date().toISOString(),
|
||||
context,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
url: this.page.url(),
|
||||
};
|
||||
|
||||
await this.log(`Error in ${context}: ${error.message}`, 'error');
|
||||
await this.captureMobileScreenshot(`error-${context}`);
|
||||
}
|
||||
|
||||
async logAction(action: string, details: any): Promise<void> {
|
||||
const logInfo = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action,
|
||||
details,
|
||||
url: this.page.url(),
|
||||
};
|
||||
|
||||
await this.log(`Action: ${action}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class MobilePage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async handleMobileMenu(): Promise<void> {
|
||||
const menuButton = this.page.locator('button[aria-label="打开菜单"], button[aria-label="关闭菜单"]');
|
||||
|
||||
if (await menuButton.isVisible()) {
|
||||
await this.tapElement('button[aria-label="打开菜单"], button[aria-label="关闭菜单"]');
|
||||
await this.page.waitForSelector('#mobile-menu', { state: 'visible' });
|
||||
}
|
||||
}
|
||||
|
||||
async closeMobileMenu(): Promise<void> {
|
||||
const closeButton = this.page.locator('button[aria-label="关闭菜单"]');
|
||||
|
||||
if (await closeButton.isVisible()) {
|
||||
await this.tapElement('button[aria-label="关闭菜单"]');
|
||||
await this.page.waitForSelector('#mobile-menu', { state: 'hidden' });
|
||||
}
|
||||
}
|
||||
|
||||
async handleMobileNavigation(linkText: string): Promise<void> {
|
||||
await this.handleMobileMenu();
|
||||
|
||||
const link = this.page.locator(`#mobile-menu a:has-text("${linkText}")`);
|
||||
await this.tapElement(`#mobile-menu a:has-text("${linkText}")`);
|
||||
|
||||
await this.closeMobileMenu();
|
||||
}
|
||||
|
||||
async handleMobileForm(formData: Record<string, string>): Promise<void> {
|
||||
for (const [fieldName, value] of Object.entries(formData)) {
|
||||
const field = this.page.locator(`input[name="${fieldName}"], textarea[name="${fieldName}"]`);
|
||||
|
||||
if (await field.isVisible()) {
|
||||
await field.fill(value);
|
||||
await this.page.waitForTimeout(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleMobileScroll(direction: 'up' | 'down' = 'down'): Promise<void> {
|
||||
const viewportSize = this.page.viewportSize();
|
||||
|
||||
if (viewportSize) {
|
||||
const startY = direction === 'down' ? viewportSize.height * 0.8 : viewportSize.height * 0.2;
|
||||
const endY = direction === 'down' ? viewportSize.height * 0.2 : viewportSize.height * 0.8;
|
||||
const centerX = viewportSize.width / 2;
|
||||
|
||||
await this.swipe(
|
||||
{ x: centerX, y: startY },
|
||||
{ x: centerX, y: endY }
|
||||
);
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
async handleMobileGestures(): Promise<void> {
|
||||
await this.logAction('Testing mobile gestures', {});
|
||||
|
||||
const testElement = this.page.locator('body');
|
||||
const box = await testElement.boundingBox();
|
||||
|
||||
if (box) {
|
||||
const centerX = box.x + box.width / 2;
|
||||
const centerY = box.y + box.height / 2;
|
||||
|
||||
await this.swipe(
|
||||
{ x: centerX, y: centerY + 100 },
|
||||
{ x: centerX, y: centerY - 100 }
|
||||
);
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
await this.swipe(
|
||||
{ x: centerX - 100, y: centerY },
|
||||
{ x: centerX + 100, y: centerY }
|
||||
);
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyMobileMenuOpen(): Promise<boolean> {
|
||||
const mobileMenu = this.page.locator('#mobile-menu');
|
||||
return await mobileMenu.isVisible();
|
||||
}
|
||||
|
||||
async verifyMobileMenuClosed(): Promise<boolean> {
|
||||
const mobileMenu = this.page.locator('#mobile-menu');
|
||||
return !(await mobileMenu.isVisible());
|
||||
}
|
||||
|
||||
async getMobileMenuItems(): Promise<string[]> {
|
||||
const items = this.page.locator('#mobile-menu a');
|
||||
const count = await items.count();
|
||||
const itemTexts: string[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await items.nth(i).textContent();
|
||||
if (text) {
|
||||
itemTexts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return itemTexts;
|
||||
}
|
||||
|
||||
async tapMobileMenuItem(itemText: string): Promise<void> {
|
||||
const menuItem = this.page.locator(`#mobile-menu a:has-text("${itemText}")`);
|
||||
await this.tapElement(`#mobile-menu a:has-text("${itemText}")`);
|
||||
}
|
||||
|
||||
async isMobileMenuVisible(): Promise<boolean> {
|
||||
const menuButton = this.page.locator('button[aria-label="打开菜单"]');
|
||||
return await menuButton.isVisible();
|
||||
}
|
||||
|
||||
async waitForMobileMenuAnimation(): Promise<void> {
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, MobileDevice> = {
|
||||
'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);
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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<string | undefined> {
|
||||
const viewport = await this.getCurrentViewport();
|
||||
return getDeviceByViewport(viewport.width, viewport.height);
|
||||
}
|
||||
|
||||
async isPortrait(): Promise<boolean> {
|
||||
const viewport = await this.getCurrentViewport();
|
||||
return viewport.height > viewport.width;
|
||||
}
|
||||
|
||||
async isLandscape(): Promise<boolean> {
|
||||
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<TouchEvent> {
|
||||
return {
|
||||
type,
|
||||
touches,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async simulateTouch(element: string, x: number, y: number): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<NetworkCondition> {
|
||||
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<void> {
|
||||
await this.page.setViewportSize({ width, height });
|
||||
}
|
||||
|
||||
async setDevice(deviceName: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const viewport = await this.getCurrentViewport();
|
||||
await this.page.setViewportSize({
|
||||
width: viewport.height,
|
||||
height: viewport.width,
|
||||
});
|
||||
}
|
||||
|
||||
async hideKeyboard(): Promise<void> {
|
||||
await this.page.keyboard.press('Escape');
|
||||
await this.page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async scrollToElement(selector: string): Promise<void> {
|
||||
const element = this.page.locator(selector);
|
||||
await element.scrollIntoViewIfNeeded();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async scrollToTop(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async scrollToBottom(): Promise<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await this.page.waitForSelector(selector, { state: 'visible', timeout });
|
||||
}
|
||||
|
||||
async waitForElementHidden(selector: string, timeout: number = 5000): Promise<void> {
|
||||
await this.page.waitForSelector(selector, { state: 'hidden', timeout });
|
||||
}
|
||||
}
|
||||
@@ -314,4 +314,201 @@ export class PerformanceMonitor {
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
async measureFirstMeaningfulPaint(): Promise<number> {
|
||||
const fmp = await this.page.evaluate(() => {
|
||||
return new Promise<number>((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<number> {
|
||||
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<number>((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<number>((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<number>((resolve) => {
|
||||
const startTime = performance.now();
|
||||
document.addEventListener('touchmove', () => {
|
||||
resolve(performance.now() - startTime);
|
||||
}, { once: true });
|
||||
setTimeout(() => resolve(0), 500);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
touchResponseTime,
|
||||
scrollPerformance,
|
||||
gestureLatency,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number>; 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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
+29
-3
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<nav aria-label="breadcrumb" className="flex items-center space-x-2 text-sm text-[#5C5C5C] py-4">
|
||||
<Link href="/" className="flex items-center hover:text-[#C41E3A] transition-colors">
|
||||
<nav aria-label="breadcrumb" className="flex items-center space-x-2 text-sm text-[#4A4A4A] py-4 px-4 md:px-0">
|
||||
{showBackButton && (
|
||||
<button
|
||||
onClick={onBackClick}
|
||||
className="flex items-center mr-2 hover:text-[#C41E3A] transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2"
|
||||
aria-label="返回"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<Link href="/" className="flex items-center hover:text-[#C41E3A] transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2 rounded" aria-label="首页">
|
||||
<Home className="w-4 h-4" />
|
||||
</Link>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<ChevronRight className="w-4 h-4 text-[#E5E5E5]" />
|
||||
<ChevronRight className="w-4 h-4 text-[#E5E5E5] flex-shrink-0" aria-hidden="true" />
|
||||
<Link
|
||||
href={item.href}
|
||||
className="ml-2 hover:text-[#C41E3A] transition-colors"
|
||||
className="ml-2 hover:text-[#C41E3A] transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2 rounded truncate max-w-[120px] md:max-w-none"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
|
||||
@@ -22,6 +22,20 @@ export function Footer() {
|
||||
<p className="text-[#5C5C5C] text-sm leading-relaxed mb-6">
|
||||
{COMPANY_INFO.description}
|
||||
</p>
|
||||
<div className="mt-6 pt-6 border-t border-[#E5E5E5]">
|
||||
<p className="text-sm text-[#5C5C5C] mb-3 font-medium">关注公众号</p>
|
||||
<div className="inline-block bg-white p-3 rounded-lg border border-[#E5E5E5] shadow-sm hover:shadow-md transition-shadow duration-200">
|
||||
<Image
|
||||
src="/images/qrcode_for_gh_a297181ff548_258.jpg"
|
||||
alt="微信公众号二维码"
|
||||
width={120}
|
||||
height={120}
|
||||
className="w-[120px] h-[120px]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[#718096] mt-2">扫码关注获取最新资讯</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -99,6 +113,28 @@ export function Footer() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-4 pt-4 border-t border-[#E5E5E5]">
|
||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-2 sm:gap-4 text-xs text-[#718096]">
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-[#C41E3A] transition-colors"
|
||||
>
|
||||
{COMPANY_INFO.icp}
|
||||
</a>
|
||||
<span className="hidden sm:inline">|</span>
|
||||
<a
|
||||
href="http://www.beian.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-[#C41E3A] transition-colors"
|
||||
>
|
||||
{COMPANY_INFO.police}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -290,9 +290,27 @@ function HeaderContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderFallback() {
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-transparent">
|
||||
<div className="container-wide">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="h-8 w-8 bg-gray-200 animate-pulse rounded" />
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-6 w-16 bg-gray-200 animate-pulse rounded mx-1" />
|
||||
))}
|
||||
</nav>
|
||||
<div className="h-9 w-20 bg-gray-200 animate-pulse rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-16" />}>
|
||||
<Suspense fallback={<HeaderFallback />}>
|
||||
<HeaderContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,8 @@ export const COMPANY_INFO = {
|
||||
email: 'contact@novalon.cn',
|
||||
phone: '028-88888888*',
|
||||
address: '中国四川省成都市龙泉驿区幸福路12号',
|
||||
icp: '蜀ICP备XXXXXXXX号-1',
|
||||
police: '川公网安备 XXXXXXXXXXX号',
|
||||
} as const;
|
||||
|
||||
// Navigation Items - 混合导航(首页滚动,详情页跳转)
|
||||
|
||||
Reference in New Issue
Block a user