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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '内测中' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface Product {
|
||||
specs: string[];
|
||||
tags: string[];
|
||||
heroThemeId: string;
|
||||
externalUrl?: string;
|
||||
caseStudies: CaseStudy[];
|
||||
dataProofs: DataProof[];
|
||||
certifications: Certification[];
|
||||
|
||||
Reference in New Issue
Block a user