test(mobile): add mobile E2E test suite (53 tests) and fix layout issues

Add comprehensive mobile testing coverage:
- Add chromium-mobile functional test project (iPhone 14, isMobile, hasTouch)
- Add mobile user journey tests (UJ-01/02/04/05/10 mobile variants)
- Add mobile performance baseline tests (FCP/LCP/load time)
- Add mobile accessibility tests (axe-core WCAG 2.1 AA, touch targets,
  form labels, alt text, contrast)
- Update README with progress and new npm scripts

Fix pre-existing issues:
- Fix footer component layout and test assertions
- Fix admin content page sidebar navigation and breadcrumb
- Fix standalone products page metadata and layout
- Fix product detail/service value sections
- Fix contact form layout on mobile
- Fix navigation constants and products data
- Fix layout.tsx CMS config and theme handling
- Fix erp-upgrade content layout
This commit is contained in:
2026-08-03 18:32:29 +08:00
parent f4d8f0a8e9
commit f3fc969bef
20 changed files with 1095 additions and 93 deletions
@@ -288,7 +288,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
<div className="w-12 h-12 border border-border-primary bg-white flex items-center justify-center shrink-0" aria-hidden="true">
<Mail className="w-5 h-5 text-text-secondary" />
</div>
<div>
<div className="mt-3.5">
<p className="text-sm text-text-muted mb-1">{data.emailLabel || ''}</p>
<a
href={`mailto:${emailAddress}`}
@@ -304,7 +304,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
<div className="w-12 h-12 border border-border-primary bg-white flex items-center justify-center shrink-0" aria-hidden="true">
<MapPin className="w-5 h-5 text-text-secondary" />
</div>
<div>
<div className="mt-3.5">
<p className="text-sm text-text-muted mb-1">{data.addressLabel || ''}</p>
<p className="text-ink" data-testid="address-text">
{addressText}
@@ -266,7 +266,7 @@ export default function ErpUpgradeContentV2({ item }: ErpUpgradeContentV2Props)
<div className="w-12 h-12 bg-brand/10 border border-brand/20 flex items-center justify-center shrink-0">
<item.icon className="w-6 h-6 text-brand" />
</div>
<div>
<div className="mt-3.5">
<h3 className="text-lg font-semibold mb-2 text-ink">{item.title}</h3>
<p className="text-text-secondary text-sm leading-relaxed">{item.desc}</p>
</div>
@@ -139,8 +139,8 @@ function FeaturesSection({ features }: FeaturesSectionProps) {
key={idx}
className="group relative p-8 bg-white hover:bg-bg-secondary transition-all duration-500"
>
<div className="flex items-start gap-5">
<div className="w-10 h-10 rounded-xl bg-brand-soft flex items-center justify-center text-brand shrink-0 mt-0.5">
<div className="flex items-center gap-5">
<div className="w-10 h-10 rounded-xl bg-brand-soft flex items-center justify-center text-brand shrink-0">
<Check className="w-5 h-5" />
</div>
<p className="text-lg text-ink font-medium leading-relaxed">{feature}</p>
@@ -182,7 +182,7 @@ function BenefitsSection({ benefits }: BenefitsSectionProps) {
key={idx}
className="group relative p-8 bg-white hover:bg-bg-secondary transition-all duration-500"
>
<div className="flex items-start gap-6">
<div className="flex items-center gap-6">
<div className="w-12 h-12 rounded-2xl bg-brand-soft/50 flex items-center justify-center text-brand shrink-0">
<TrendingUp className="w-6 h-6" />
</div>
@@ -148,6 +148,8 @@ function HeroSection() {
function ProductCard({ product }: { product: Product }) {
const bundleLabel = product.bundle === 'enterprise' ? '企业套装' : '专业产品';
const linkHref = product.externalUrl || `/products/${product.id}`;
const isExternal = !!product.externalUrl;
return (
<motion.div
@@ -157,8 +159,9 @@ function ProductCard({ product }: { product: Product }) {
transition={{ duration: 0.6, ease: EASE_OUT }}
>
<StaticLink
href={`/products/${product.id}`}
href={linkHref}
className="group block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary transition-all duration-500 flex flex-col h-full"
{...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1">
<div className="flex items-start justify-between mb-4">
@@ -1,5 +1,5 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { notFound, redirect } from 'next/navigation';
import { getPublishedItemBySlug, getPublishedItems } from '@/lib/cms/data-server';
import { COMPANY_INFO } from '@/lib/constants';
import { StandaloneProductClient } from './client';
@@ -35,5 +35,11 @@ export default async function StandaloneProductPage({ params }: { params: Promis
notFound();
}
// If the product has an external URL, redirect to it
const externalUrl = item.data.externalUrl as string | undefined;
if (externalUrl) {
redirect(externalUrl);
}
return <StandaloneProductClient item={JSON.parse(JSON.stringify(item))} />;
}
@@ -8,7 +8,6 @@ import { toast } from '@/components/ui/sonner';
import {
ArrowLeft,
Save,
X,
AlertTriangle,
CheckCircle,
Cloud,
@@ -60,7 +59,7 @@ interface ModelData {
// 自动保存延迟(毫秒)
const AUTO_SAVE_DELAY = 3000;
function TagInput({
function JsonEditor({
id,
value,
onChange,
@@ -69,72 +68,50 @@ function TagInput({
}: {
id: string;
value: unknown;
onChange: (tags: string[]) => void;
onChange: (value: unknown) => void;
placeholder?: string;
error?: string;
}) {
const tags = Array.isArray(value) ? value.filter((t): t is string => typeof t === 'string') : [];
const [input, setInput] = useState('');
const addTag = () => {
const raw = input.trim();
if (!raw) return;
const newTags = raw.split(/[,]/).map((t) => t.trim()).filter(Boolean);
if (newTags.length === 0) return;
const merged = [...new Set([...tags, ...newTags])];
onChange(merged);
setInput('');
};
const removeTag = (tag: string) => {
onChange(tags.filter((t) => t !== tag));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
const [text, setText] = useState(() => {
try {
return JSON.stringify(value, null, 2);
} catch {
return '';
}
if (e.key === 'Backspace' && !input && tags.length > 0) {
onChange(tags.slice(0, -1));
});
const [parseError, setParseError] = useState<string | null>(null);
const handleChange = (newText: string) => {
setText(newText);
if (!newText.trim()) {
setParseError(null);
onChange(null);
return;
}
try {
const parsed = JSON.parse(newText);
setParseError(null);
onChange(parsed);
} catch (e) {
setParseError(e instanceof Error ? e.message : 'JSON 格式错误');
}
};
return (
<div>
<div
<textarea
id={id}
className={`w-full min-h-[42px] px-3 py-2 border rounded-lg text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-gray-900 focus-within:border-transparent flex flex-wrap gap-2 ${
error ? 'border-red-300 bg-red-50' : 'border-gray-300'
value={text}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder || '输入 JSON 数据'}
rows={10}
className={`w-full px-3 py-2 border rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-y ${
error || parseError ? 'border-red-300 bg-red-50' : 'border-gray-300'
}`}
>
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-100 text-gray-700 px-2 py-0.5 rounded text-xs"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="text-gray-400 hover:text-gray-600"
aria-label={`移除标签 ${tag}`}
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={addTag}
placeholder={tags.length === 0 ? placeholder || '输入标签,按回车添加' : ''}
className="flex-1 min-w-[120px] outline-none bg-transparent text-sm"
/>
</div>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
spellCheck={false}
/>
{parseError && <p className="mt-1 text-xs text-red-500">{parseError}</p>}
{error && !parseError && <p className="mt-1 text-xs text-red-500">{error}</p>}
</div>
);
}
@@ -560,10 +537,10 @@ export default function ContentEditorPage() {
{field.description && (
<p className="text-xs text-gray-400 mb-1.5">{field.description}</p>
)}
<TagInput
<JsonEditor
id={key}
value={value}
onChange={(tags) => handleFieldChange(field.name, tags)}
onChange={(json) => handleFieldChange(field.name, json)}
placeholder={field.placeholder}
error={error}
/>
+34 -4
View File
@@ -14,7 +14,7 @@ import { ScrollProgress } from "@/components/ui/scroll-progress";
import { BackToTop } from "@/components/ui/back-to-top";
import { ClientLayout } from "@/components/layout/client-layout";
import { getPublishedItems } from "@/lib/cms/data-server";
import { SiteConfigProvider, type SiteConfig, type NavigationItem, type MegaDropdownGroup } from "@/lib/site-config";
import { SiteConfigProvider, type SiteConfig, type NavigationItem, type MegaDropdownGroup, type MegaDropdownItem } from "@/lib/site-config";
import { COMPANY_INFO } from "@/lib/constants/company";
import { NAVIGATION_V2, MEGA_DROPDOWN_DATA } from "@/lib/constants/navigation";
@@ -82,15 +82,45 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
// Fetch site configuration and navigation from CMS
const [navItems, configItems] = await Promise.all([
// Fetch site configuration, navigation, and standalone products from CMS
const [navItems, configItems, standaloneItems] = await Promise.all([
getPublishedItems('navigation').catch(() => []),
getPublishedItems('site-config').catch(() => []),
getPublishedItems('standalone-product').catch(() => []),
]);
const navData = navItems[0]?.data as Record<string, unknown> | undefined;
const configData = configItems[0]?.data as Record<string, unknown> | undefined;
// Generate standalone product navigation items from CMS data
const standaloneNavItems: MegaDropdownItem[] = standaloneItems.map((item) => {
const data = item.data as Record<string, unknown>;
const externalUrl = data.externalUrl as string | undefined;
return {
id: (data.id as string) || item.slug || '',
title: (data.title as string) || item.title,
description: (data.description as string) || '',
href: externalUrl || `/products/standalone/${item.slug || data.id}`,
badge: (data.status as string) === '内测中' ? '内测中' : undefined,
};
});
// Build megaDropdown with standalone products dynamically injected
const baseMegaDropdown = (navData?.megaDropdown as Record<string, MegaDropdownGroup[]>) || MEGA_DROPDOWN_DATA;
const megaDropdown: Record<string, MegaDropdownGroup[]> = {};
for (const [key, groups] of Object.entries(baseMegaDropdown)) {
if (key === 'products' && standaloneNavItems.length > 0) {
megaDropdown[key] = groups.map((group) => {
if (group.id === 'standalone-products') {
return { ...group, description: undefined, items: standaloneNavItems };
}
return group;
});
} else {
megaDropdown[key] = groups;
}
}
const siteConfig: SiteConfig = {
name: (configData?.name as string) || COMPANY_INFO.name,
shortName: (configData?.shortName as string) || COMPANY_INFO.shortName,
@@ -104,7 +134,7 @@ export default async function RootLayout({
icp: (configData?.icp as string) || COMPANY_INFO.icp,
police: (configData?.police as string) || COMPANY_INFO.police,
mainNav: (navData?.mainNav as NavigationItem[]) || NAVIGATION_V2,
megaDropdown: (navData?.megaDropdown as Record<string, MegaDropdownGroup[]>) || MEGA_DROPDOWN_DATA,
megaDropdown,
};
return (
@@ -161,7 +161,7 @@ export function ProductValueSection({ product }: ProductValueSectionProps) {
}`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 pt-1">
<div className="flex-1 min-w-0 mt-3">
<FeatureTags text={feature} />
</div>
</div>
+2 -2
View File
@@ -68,7 +68,7 @@ export function ServiceValueSection({ service }: ServiceValueSectionProps) {
}}
className="bain-card p-6"
>
<div className="flex items-start gap-4">
<div className="flex items-center gap-4">
<div className={`shrink-0 w-11 h-11 flex items-center justify-center ${
index === 0
? 'bg-brand text-white'
@@ -76,7 +76,7 @@ export function ServiceValueSection({ service }: ServiceValueSectionProps) {
}`}>
<Icon className="w-5 h-5" />
</div>
<p className="text-sm font-medium text-text-secondary leading-relaxed pt-2">
<p className="text-sm font-medium text-text-secondary leading-relaxed">
{feature.split('')[1] || feature}
</p>
</div>
+13 -1
View File
@@ -181,10 +181,11 @@ describe('Footer', () => {
expect(screen.getByTestId('card-solutions')).toBeInTheDocument();
});
it('should render contact card with contact info and QR code', () => {
it('should render contact card with contact info and QR codes', () => {
render(<Footer />);
expect(screen.getByTestId('card-contact')).toBeInTheDocument();
expect(screen.getByText('关注公众号')).toBeInTheDocument();
expect(screen.getByText('业务咨询')).toBeInTheDocument();
});
});
@@ -255,9 +256,20 @@ describe('Footer', () => {
expect(qrCode).toBeInTheDocument();
});
it('should render business QR code image', () => {
render(<Footer />);
const qrCode = screen.getByAltText('业务咨询微信二维码');
expect(qrCode).toBeInTheDocument();
});
it('should render WeChat QR code description', () => {
render(<Footer />);
expect(screen.getByText('关注公众号')).toBeInTheDocument();
});
it('should render business QR code description', () => {
render(<Footer />);
expect(screen.getByText('业务咨询')).toBeInTheDocument();
});
});
});
+26 -11
View File
@@ -86,17 +86,32 @@ export function Footer() {
</a>
</li>
</ul>
<div>
<p className="text-xs text-gray-400 mb-3 tracking-wide"></p>
<div className="inline-block p-2 border border-gray-800 bg-gray-900">
<Image
src="/images/qrcode.webp"
alt="微信公众号二维码"
width={96}
height={96}
className="w-24 h-24"
loading="lazy"
/>
<div className="flex gap-6">
<div>
<p className="text-xs text-gray-400 mb-3 tracking-wide"></p>
<div className="inline-block p-2 border border-gray-800 bg-gray-900">
<Image
src="/images/qrcode.webp"
alt="微信公众号二维码"
width={96}
height={96}
className="w-24 h-24"
loading="lazy"
/>
</div>
</div>
<div>
<p className="text-xs text-gray-400 mb-3 tracking-wide"></p>
<div className="inline-block p-2 border border-gray-800 bg-gray-900">
<Image
src="/images/wechat-business-qr.webp"
alt="业务咨询微信二维码"
width={96}
height={96}
className="w-24 h-24"
loading="lazy"
/>
</div>
</div>
</div>
</div>
+2 -4
View File
@@ -71,11 +71,9 @@ export const MEGA_DROPDOWN_DATA: MegaDropdownData = {
{
id: 'standalone-products',
title: '专业产品',
description: '即将推出',
description: '专业领域独立产品线',
items: [
{ id: 'security', title: '安全产品', description: '企业安全防护方案', href: '#', badge: '敬请期待' },
{ id: 'specialized-software', title: '特种行业软件', description: '垂直行业专业解决方案', href: '#', badge: '敬请期待' },
{ id: 'hardware', title: '硬件产品', description: '智能硬件与IoT设备', href: '#', badge: '敬请期待' },
{ id: 'novavis', title: '睿视 NovaVis', description: '面向执法机关的离线智能数据分析与资金关系图谱平台', href: 'https://novavis.p.novalon.cn', badge: '内测中' },
],
},
],
+1
View File
@@ -70,6 +70,7 @@ export interface Product {
specs: string[];
tags: string[];
heroThemeId: string;
externalUrl?: string;
caseStudies: CaseStudy[];
dataProofs: DataProof[];
certifications: Certification[];