refactor/refactor-static #7

Merged
zhangxiang merged 12 commits from refactor/refactor-static into dev 2026-04-22 16:02:57 +08:00
85 changed files with 2109 additions and 3016 deletions
-1
View File
@@ -1,2 +1 @@
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
CDN_DOMAIN=https://cdn.novalon.cn
+4 -1
View File
@@ -291,4 +291,7 @@ findings.md
# Git will track them because they are not in test-results/ or allure-results/
# AGENTS
AGENTS.md
AGENTS.md
# dogfood
dogfood-output/
+8
View File
@@ -0,0 +1,8 @@
FROM nginx:alpine
COPY html /var/www/novalon
COPY nginx-internal.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]
+47 -57
View File
@@ -1,57 +1,47 @@
ci:
collect:
numberOfRuns: 3
startServerCommand: npm run start
startServerReadyPattern: 'Local:'
url:
- http://localhost:3000/
- http://localhost:3000/about
- http://localhost:3000/services
- http://localhost:3000/products
- http://localhost:3000/cases
- http://localhost:3000/news
- http://localhost:3000/contact
settings:
preset: desktop
onlyCategories:
- performance
- accessibility
- best-practices
- seo
assert:
assertions:
categories:performance:
- error
- minScore: 0.9
categories:accessibility:
- error
- minScore: 0.9
categories:best-practices:
- error
- minScore: 0.9
categories:seo:
- error
- minScore: 0.9
first-contentful-paint:
- error
- maxNumericValue: 2000
largest-contentful-paint:
- error
- maxNumericValue: 3000
cumulative-layout-shift:
- error
- maxNumericValue: 0.1
total-blocking-time:
- error
- maxNumericValue: 300
speed-index:
- error
- maxNumericValue: 3000
upload:
target: temporary-public-storage
settings:
output: html
outputPath: lighthouse-reports
{
"ci": {
"collect": {
"numberOfRuns": 3,
"startServerCommand": "npm run start",
"startServerReadyPattern": "Local:",
"url": [
"http://localhost:3000/",
"http://localhost:3000/about",
"http://localhost:3000/services",
"http://localhost:3000/products",
"http://localhost:3000/cases",
"http://localhost:3000/news",
"http://localhost:3000/contact"
],
"settings": {
"preset": "desktop",
"onlyCategories": [
"performance",
"accessibility",
"best-practices",
"seo"
]
}
},
"assert": {
"assertions": {
"categories:performance": ["error", {"minScore": 0.9}],
"categories:accessibility": ["error", {"minScore": 0.9}],
"categories:best-practices": ["error", {"minScore": 0.9}],
"categories:seo": ["error", {"minScore": 0.9}],
"first-contentful-paint": ["error", {"maxNumericValue": 2000}],
"largest-contentful-paint": ["error", {"maxNumericValue": 3000}],
"cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}],
"total-blocking-time": ["error", {"maxNumericValue": 300}],
"speed-index": ["error", {"maxNumericValue": 3000}]
}
},
"upload": {
"target": "temporary-public-storage"
},
"settings": {
"output": "html",
"outputPath": "lighthouse-reports"
}
}
}
+5 -6
View File
@@ -54,7 +54,7 @@ echo ""
echo "📋 步骤0: 部署前检查..."
for file in docker-compose.yml Dockerfile nginx.conf .env.example setup-ssl.sh; do
for file in docker-compose.yml Dockerfile nginx-static.conf .env.example setup-ssl.sh; do
if [ ! -f "$file" ]; then
echo "❌ 缺少必要文件: $file"
exit 1
@@ -85,7 +85,7 @@ echo "✅ SSH连接验证成功"
echo ""
echo "📋 步骤2: 上传部署文件..."
ssh "$SERVER_USER@$SERVER_IP" "mkdir -p '$PROJECT_DIR'"
scp -r docker-compose.yml Dockerfile nginx.conf .env.example setup-ssl.sh "$SERVER_USER@$SERVER_IP:$PROJECT_DIR/"
scp -r docker-compose.yml Dockerfile nginx-static.conf .env.example setup-ssl.sh "$SERVER_USER@$SERVER_IP:$PROJECT_DIR/"
echo "✅ 部署文件已上传"
echo ""
@@ -102,13 +102,12 @@ if [ ! -f .env ]; then
echo "📝 创建.env文件..."
cp .env.example .env
echo "⚠️ 请编辑.env文件,填入正确的环境变量"
echo "⚠️ 必须配置: DATABASE_URL, NEXTAUTH_SECRET, NEXTAUTH_URL, RESEND_API_KEY, OPS_ALERT_EMAIL"
exit 1
echo "⚠️ 可选配置: NEXT_PUBLIC_GA_ID"
fi
echo "🐳 启动Docker容器..."
docker-compose down
docker-compose pull
docker-compose build --no-cache
docker-compose up -d
echo "📋 等待服务启动..."
@@ -118,7 +117,7 @@ check_interval=3
while [ $elapsed -lt $timeout ]; do
if docker inspect --format='{{.State.Status}}' "$CONTAINER_NAME" 2>/dev/null | grep -q "running"; then
if curl -f -s -o /dev/null "http://localhost:3000" --max-time 5 2>/dev/null; then
if curl -f -s -o /dev/null "http://localhost:80" --max-time 5 2>/dev/null; then
echo "✅ 服务已启动并响应正常"
break
else
+23
View File
@@ -0,0 +1,23 @@
version: "3.8"
services:
nginx:
image: nginx:alpine
container_name: novalon-nginx-secure
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx-static-production.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
- ./logs:/var/log/nginx
- ../certbot:/var/www/certbot
- ../novalon-static:/var/www/novalon:ro
networks:
- novalon-network
networks:
novalon-network:
driver: bridge
external: true
+18
View File
@@ -0,0 +1,18 @@
version: "3.8"
services:
novalon-website:
build:
context: .
dockerfile: Dockerfile.static
image: novalon-website:latest
container_name: novalon-website
restart: unless-stopped
ports:
- "3000:3000"
networks:
- novalon-network
networks:
novalon-network:
external: true
+3
View File
@@ -2,6 +2,9 @@ version: "3.8"
services:
novalon-website:
build:
context: .
dockerfile: Dockerfile
image: novalon-website:1.0.0
container_name: novalon-website
restart: unless-stopped
+57
View File
@@ -0,0 +1,57 @@
server {
listen 3000;
server_name localhost;
root /var/www/novalon;
index index.html;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml
application/rss+xml
image/svg+xml;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
location /_next/static/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location /fonts/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
try_files $uri =404;
}
location ~* \.(svg|jpg|jpeg|png|gif|webp|avif|ico)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri.html $uri/ /404.html;
}
error_page 404 /404.html;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
}
+237
View File
@@ -0,0 +1,237 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
resolver 127.0.0.11 valid=30s ipv6=off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
server_tokens off;
client_max_body_size 100M;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
limit_req_zone $binary_remote_addr zone=general:10m rate=100r/s;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+text text/javascript;
upstream gitea_app {
server gitea:3000;
}
upstream jenkins_app {
server jenkins:8080;
}
# Novalon 主站 - 静态文件服务
server {
listen 80;
server_name novalon.cn www.novalon.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://www.novalon.cn$request_uri;
}
}
server {
listen 443 ssl http2;
server_name novalon.cn www.novalon.cn;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 静态文件根目录
root /var/www/novalon;
index index.html;
# 静态资源长期缓存
location /_next/static/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
# 字体文件缓存
location ~* \.(ttf|woff|woff2|eot)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
try_files $uri =404;
}
# 图片文件缓存
location ~* \.(svg|jpg|jpeg|png|gif|webp|avif|ico)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
try_files $uri =404;
}
# Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Next.js 静态导出的页面路由
location / {
limit_req zone=general burst=20 nodelay;
try_files $uri $uri.html $uri/ /404.html;
}
# 自定义 404 页面
error_page 404 /404.html;
# 优化文件传输
sendfile on;
tcp_nopush on;
tcp_nodelay on;
}
# Git 服务
server {
listen 80;
server_name git.f.novalon.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name git.f.novalon.cn;
ssl_certificate /etc/nginx/ssl/git.f.novalon.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/git.f.novalon.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://gitea_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Powered-By;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
}
# CI 服务
server {
listen 80;
server_name ci.f.novalon.cn;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name ci.f.novalon.cn;
ssl_certificate /etc/nginx/ssl/ci.f.novalon.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/ci.f.novalon.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://jenkins_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_hide_header X-Powered-By;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
client_max_body_size 100m;
}
access_log /var/log/nginx/jenkins-access.log;
error_log /var/log/nginx/jenkins-error.log;
}
}
+2 -2
View File
@@ -20,7 +20,7 @@ server {
ssl_session_timeout 10m;
# 静态文件根目录
root /var/www/novalon;
root /usr/share/nginx/html;
index index.html;
# Gzip 压缩
@@ -79,7 +79,7 @@ server {
# Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
root /usr/share/nginx/html;
try_files $uri =404;
}
-2
View File
@@ -32,8 +32,6 @@
"lighthouse:upload": "lhci upload",
"lighthouse:desktop": "lhci autorun --settings.preset=desktop",
"lighthouse:mobile": "lhci autorun --settings.preset=mobile",
"deploy:cdn": "bash scripts/deploy-cdn.sh",
"deploy:cdn:refresh": "bash scripts/refresh-cdn.sh",
"clean:tests": "bash scripts/maintenance/clean-test-files.sh",
"prepare": "husky"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
<defs>
<style>
.calligraphy-font {
font-family: 'Aoyagi Reisho', 'Long Cang', 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif;
font-family: 'Aoyagi Reisho', 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif;
}
.arial-font {
font-family: Arial, sans-serif;

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

-89
View File
@@ -1,89 +0,0 @@
#!/bin/bash
set -e
CDN_DOMAIN=${CDN_DOMAIN:-"https://cdn.novalon.cn"}
COS_BUCKET=${COS_BUCKET:-"novalon-cdn-1250000000"}
COS_REGION=${COS_REGION:-"ap-chengdu"}
DIST_DIR=${DIST_DIR:-"dist/static"}
STANDALONE_DIR=${STANDALONE_DIR:-"dist/standalone"}
echo "========================================="
echo "CDN静态资源部署脚本"
echo "========================================="
echo "CDN域名: $CDN_DOMAIN"
echo "COS存储桶: $COS_BUCKET"
echo "COS区域: $COS_REGION"
echo "静态资源目录: $DIST_DIR"
echo "========================================="
if [ ! -d "$DIST_DIR" ]; then
echo "错误: 静态资源目录不存在: $DIST_DIR"
echo "请先运行 npm run build 构建项目"
exit 1
fi
echo ""
echo "步骤1: 检查coscmd工具..."
if ! command -v coscmd &> /dev/null; then
echo "安装coscmd工具..."
pip install coscmd
fi
echo ""
echo "步骤2: 配置coscmd..."
if [ -z "$COS_SECRET_ID" ] || [ -z "$COS_SECRET_KEY" ]; then
echo "错误: 请设置环境变量 COS_SECRET_ID 和 COS_SECRET_KEY"
echo "可以在腾讯云控制台 > 访问管理 > API密钥管理中获取"
exit 1
fi
coscmd config -a "$COS_SECRET_ID" -s "$COS_SECRET_KEY" -b "$COS_BUCKET" -r "$COS_REGION"
echo ""
echo "步骤3: 上传静态资源到COS..."
echo "上传 _next/static/ 目录..."
coscmd upload -r "$DIST_DIR" /_next/static/ --sync --delete
echo ""
echo "步骤4: 上传public目录中的静态资源..."
if [ -d "public" ]; then
echo "上传 public/ 目录..."
coscmd upload -r public/ / --sync
fi
echo ""
echo "步骤5: 设置COS对象缓存策略..."
echo "为静态资源设置长期缓存 (1年)..."
coscmd set-meta "_next/static/*" "Cache-Control: public, max-age=31536000, immutable" -r
echo ""
echo "步骤6: 刷新CDN缓存..."
if [ -n "$CDN_DOMAIN" ]; then
CDN_DOMAIN_CLEAN=$(echo "$CDN_DOMAIN" | sed 's|https://||' | sed 's|http://||')
echo "刷新CDN域名: $CDN_DOMAIN_CLEAN"
if command -v tccli &> /dev/null; then
tccli cdn PurgePathsCache --Paths '["https://'"$CDN_DOMAIN_CLEAN"'/_next/static/"]' --FlushType flush
echo "CDN缓存刷新请求已提交"
else
echo "提示: 未安装tccli工具,请手动在腾讯云控制台刷新CDN缓存"
echo "刷新路径: https://$CDN_DOMAIN_CLEAN/_next/static/"
fi
fi
echo ""
echo "========================================="
echo "部署完成!"
echo "========================================="
echo "静态资源已上传到: https://$COS_BUCKET.cos.$COS_REGION.myqcloud.com"
echo "CDN加速域名: $CDN_DOMAIN"
echo ""
echo "后续步骤:"
echo "1. 在腾讯云CDN控制台配置加速域名: cdn.novalon.cn"
echo "2. 设置源站为COS存储桶: $COS_BUCKET.cos.$COS_REGION.myqcloud.com"
echo "3. 配置HTTPS证书"
echo "4. 测试CDN加速效果"
echo "========================================="
-43
View File
@@ -1,43 +0,0 @@
#!/bin/bash
set -e
CDN_DOMAIN=${CDN_DOMAIN:-"https://cdn.novalon.cn"}
COS_BUCKET=${COS_BUCKET:-"novalon-cdn-1250000000"}
COS_REGION=${COS_REGION:-"ap-chengdu"}
echo "========================================="
echo "CDN缓存刷新脚本"
echo "========================================="
echo "CDN域名: $CDN_DOMAIN"
echo "========================================="
CDN_DOMAIN_CLEAN=$(echo "$CDN_DOMAIN" | sed 's|https://||' | sed 's|http://||')
echo ""
echo "刷新CDN缓存..."
if command -v tccli &> /dev/null; then
echo "使用tccli刷新CDN缓存..."
tccli cdn PurgePathsCache \
--Paths "[\"https://$CDN_DOMAIN_CLEAN/_next/static/\"]" \
--FlushType flush
echo "CDN缓存刷新请求已提交"
echo "刷新ID可通过腾讯云控制台查看进度"
else
echo "错误: 未安装tccli工具"
echo ""
echo "请手动在腾讯云控制台刷新CDN缓存:"
echo "1. 登录腾讯云控制台: https://console.cloud.tencent.com/cdn"
echo "2. 进入缓存刷新页面"
echo "3. 选择'目录刷新'"
echo "4. 输入刷新URL: https://$CDN_DOMAIN_CLEAN/_next/static/"
echo "5. 点击提交"
fi
echo ""
echo "========================================="
echo "完成!"
echo "========================================="
Executable
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
set -e
echo "========================================="
echo " SSL 证书配置检查"
echo "========================================="
SSL_DIR="./ssl"
if [ ! -d "$SSL_DIR" ]; then
echo "⚠️ SSL 目录不存在,正在创建..."
mkdir -p "$SSL_DIR"
fi
if [ ! -f "$SSL_DIR/fullchain.pem" ] || [ ! -f "$SSL_DIR/privkey.pem" ]; then
echo "⚠️ SSL 证书文件不存在"
echo ""
echo "请将 SSL 证书文件放置到 $SSL_DIR 目录:"
echo " - fullchain.pem (证书链)"
echo " - privkey.pem (私钥)"
echo ""
echo "获取证书的方式:"
echo " 1. 使用 Let's Encrypt 免费证书:"
echo " certbot certonly --webroot -w /var/www/certbot -d novalon.cn -d www.novalon.cn"
echo " 2. 使用商业证书:"
echo " 从证书提供商下载并重命名文件"
echo ""
echo "证书文件权限:"
echo " chmod 644 $SSL_DIR/fullchain.pem"
echo " chmod 600 $SSL_DIR/privkey.pem"
exit 1
fi
echo "✅ SSL 证书文件检查通过"
echo " - 证书链: $SSL_DIR/fullchain.pem"
echo " - 私钥: $SSL_DIR/privkey.pem"
echo ""
echo "📋 证书有效期检查..."
openssl x509 -in "$SSL_DIR/fullchain.pem" -noout -dates 2>/dev/null || echo "⚠️ 无法读取证书信息"
echo ""
echo "✅ SSL 配置完成"
+104 -36
View File
@@ -2,6 +2,23 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { CaseDetailClient } from './client';
interface TestCaseItem {
id: string;
title: string;
excerpt: string;
content: string;
category: string;
slug: string;
date: string;
image?: string;
challenge: string;
solution: string;
keyMoments: { title: string; description: string }[];
results: { label: string; value: string }[];
testimonial: { quote: string; author: string; role: string };
duration: string;
}
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
@@ -11,12 +28,14 @@ jest.mock('next/navigation', () => ({
}));
jest.mock('next/link', () => {
return ({ children, href }: { children: React.ReactNode; href: string }) => {
const MockLink = ({ children, href }: { children: React.ReactNode; href: string }) => {
return <a href={href}>{children}</a>;
};
MockLink.displayName = 'MockLink';
return MockLink;
});
const mockCaseItem = {
const mockCaseItem: TestCaseItem = {
id: 'test-case',
title: '测试案例标题',
excerpt: '这是一个测试案例的描述',
@@ -24,6 +43,23 @@ const mockCaseItem = {
category: '制造业',
slug: 'test-case',
date: '2026-03-27',
challenge: '这是客户面临的挑战描述',
solution: '这是我们的解决方案描述',
keyMoments: [
{ title: '关键时刻一', description: '关键时刻一的详细描述' },
{ title: '关键时刻二', description: '关键时刻二的详细描述' },
],
results: [
{ label: '运营成本', value: '降低25%' },
{ label: '设备故障响应', value: '缩短85%' },
{ label: '排产周期', value: '从1周缩至半天' },
],
testimonial: {
quote: '这是客户证言内容',
author: '测试客户',
role: 'CTO',
},
duration: '2年',
};
describe('CaseDetailClient', () => {
@@ -33,107 +69,139 @@ describe('CaseDetailClient', () => {
describe('Rendering', () => {
it('should render case detail page', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
const main = screen.getByRole('main');
expect(main).toBeInTheDocument();
const { container } = render(<CaseDetailClient caseItem={mockCaseItem} />);
expect(container.firstChild).toBeInTheDocument();
});
it('should render case title', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
render(<CaseDetailClient caseItem={mockCaseItem} />);
const title = screen.getByRole('heading', { level: 1 });
expect(title).toBeInTheDocument();
expect(title).toHaveTextContent('测试案例标题');
});
it('should render case client name', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
it('should render case excerpt', () => {
render(<CaseDetailClient caseItem={mockCaseItem} />);
const excerpts = screen.getAllByText('这是一个测试案例的描述');
expect(excerpts.length).toBeGreaterThan(0);
});
it('should render case industry badge', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
render(<CaseDetailClient caseItem={mockCaseItem} />);
const categories = screen.getAllByText('制造业');
expect(categories.length).toBeGreaterThan(0);
});
it('should render case description', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
const excerpts = screen.getAllByText('这是一个测试案例的描述');
expect(excerpts.length).toBeGreaterThan(0);
it('should render challenge content', () => {
render(<CaseDetailClient caseItem={mockCaseItem} />);
expect(screen.getByText('这是客户面临的挑战描述')).toBeInTheDocument();
});
it('should render case results', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
const excerpts = screen.getAllByText('这是一个测试案例的描述');
expect(excerpts.length).toBeGreaterThan(0);
it('should render solution content', () => {
render(<CaseDetailClient caseItem={mockCaseItem} />);
expect(screen.getByText('这是我们的解决方案描述')).toBeInTheDocument();
});
it('should render case tags', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
const categories = screen.getAllByText('制造业');
expect(categories.length).toBeGreaterThan(0);
it('should render results data', () => {
render(<CaseDetailClient caseItem={mockCaseItem} />);
expect(screen.getByText('降低25%')).toBeInTheDocument();
expect(screen.getByText('缩短85%')).toBeInTheDocument();
});
it('should render testimonial', () => {
render(<CaseDetailClient caseItem={mockCaseItem} />);
expect(screen.getByText('这是客户证言内容')).toBeInTheDocument();
const authors = screen.getAllByText('测试客户');
expect(authors.length).toBeGreaterThan(0);
const roles = screen.getAllByText('CTO');
expect(roles.length).toBeGreaterThan(0);
});
it('should render contact button', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
render(<CaseDetailClient caseItem={mockCaseItem} />);
const contactButton = screen.getByRole('link', { name: /联系我们/i });
expect(contactButton).toBeInTheDocument();
});
it('should render duration in sidebar', () => {
render(<CaseDetailClient caseItem={mockCaseItem} />);
expect(screen.getByText('2年')).toBeInTheDocument();
});
});
describe('Sections', () => {
it('should render customer challenges section', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
render(<CaseDetailClient caseItem={mockCaseItem} />);
const section = screen.getByText('客户遇到的成长瓶颈');
expect(section).toBeInTheDocument();
});
it('should render solution section', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
render(<CaseDetailClient caseItem={mockCaseItem} />);
const section = screen.getByText('我们如何智连未来');
expect(section).toBeInTheDocument();
});
it('should render growth story section', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
it('should render growth story section with key moments', () => {
render(<CaseDetailClient caseItem={mockCaseItem} />);
const section = screen.getByText('共同成长的故事');
expect(section).toBeInTheDocument();
expect(screen.getByText('关键时刻一')).toBeInTheDocument();
expect(screen.getByText('关键时刻二')).toBeInTheDocument();
});
it('should render achievements section', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
it('should render achievements section with results', () => {
render(<CaseDetailClient caseItem={mockCaseItem} />);
const section = screen.getByText('今天,他们走到了哪里');
expect(section).toBeInTheDocument();
});
it('should render testimonial section', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
render(<CaseDetailClient caseItem={mockCaseItem} />);
const section = screen.getByText('客户证言精选');
expect(section).toBeInTheDocument();
});
});
describe('Conditional Rendering', () => {
it('should not render key moments section when empty', () => {
const caseWithoutMoments = { ...mockCaseItem, keyMoments: [] };
render(<CaseDetailClient caseItem={caseWithoutMoments} />);
expect(screen.queryByText('共同成长的故事')).not.toBeInTheDocument();
});
it('should not render results section when empty', () => {
const caseWithoutResults = { ...mockCaseItem, results: [] };
render(<CaseDetailClient caseItem={caseWithoutResults} />);
expect(screen.queryByText('今天,他们走到了哪里')).not.toBeInTheDocument();
});
it('should not render testimonial section when absent', () => {
const caseWithoutTestimonial = { ...mockCaseItem, testimonial: undefined };
render(<CaseDetailClient caseItem={caseWithoutTestimonial} />);
expect(screen.queryByText('客户证言精选')).not.toBeInTheDocument();
});
});
describe('Navigation', () => {
it('should have back button', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
render(<CaseDetailClient caseItem={mockCaseItem} />);
const backButton = screen.getByRole('button', { name: /返回/i });
expect(backButton).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have main landmark', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
const main = screen.getByRole('main');
expect(main).toBeInTheDocument();
it('should have container element', () => {
const { container } = render(<CaseDetailClient caseItem={mockCaseItem} />);
expect(container.firstChild).toBeInTheDocument();
});
it('should have proper heading hierarchy', () => {
render(<CaseDetailClient caseItem={mockCaseItem as any} />);
render(<CaseDetailClient caseItem={mockCaseItem} />);
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1).toBeInTheDocument();
const h2s = screen.getAllByRole('heading', { level: 2 });
expect(h2s.length).toBeGreaterThan(0);
});
+166 -130
View File
@@ -5,7 +5,30 @@ import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BackButton } from '@/components/ui/back-button';
import { Users, Target, Quote, Clock, MessageCircle, Award, TrendingUp } from 'lucide-react';
import {
Target,
Quote,
Clock,
MessageCircle,
Award,
TrendingUp,
} from 'lucide-react';
interface CaseKeyMoment {
title: string;
description: string;
}
interface CaseResult {
label: string;
value: string;
}
interface CaseTestimonial {
quote: string;
author: string;
role: string;
}
interface CaseItem {
id: string;
@@ -16,6 +39,18 @@ interface CaseItem {
slug: string;
date: string;
image?: string;
/** 客户面临的挑战 */
challenge: string;
/** 我们的解决方案 */
solution: string;
/** 关键时刻 */
keyMoments: CaseKeyMoment[];
/** 成果数据 */
results: CaseResult[];
/** 客户证言 */
testimonial?: CaseTestimonial;
/** 合作时长 */
duration: string;
}
interface CaseDetailClientProps {
@@ -44,7 +79,7 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
}, []);
return (
<main className="min-h-screen bg-white">
<div className="min-h-screen bg-white">
<div className="relative overflow-hidden bg-gradient-to-b from-[#FAFAFA] to-white">
<div className="container-wide relative z-10 pt-32 pb-20">
<BackButton />
@@ -55,55 +90,54 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-semibold text-[#1C1C1C] mb-2">
{caseItem.title}
</h1>
<p className="text-lg text-[#5C5C5C]">
{caseItem.excerpt}
</p>
<p className="text-lg text-[#5C5C5C]">{caseItem.excerpt}</p>
</div>
</div>
</div>
<div ref={contentRef} className="container-wide py-12 md:py-16">
<div
className={`
grid lg:grid-cols-3 gap-8 lg:gap-12
opacity-0 translate-y-4
${isVisible ? 'animate-fade-in-up' : ''}
`}
>
<div className="lg:col-span-2 space-y-12">
<section className="bg-gradient-to-br from-[#FFFBF5] to-white rounded-2xl p-8 border border-[#C41E3A]/20">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center">
<MessageCircle className="w-6 h-6 text-white" />
</div>
<h2 className="text-2xl font-semibold text-[#1C1C1C]">
</h2>
<div ref={contentRef} className="container-wide py-12 md:py-16">
<div
className={`
grid lg:grid-cols-3 gap-8 lg:gap-12
opacity-0 translate-y-4
${isVisible ? 'animate-fade-in-up' : ''}
`}
>
<div className="lg:col-span-2 space-y-12">
{/* 客户遇到的成长瓶颈 */}
<section className="bg-gradient-to-br from-[#FFFBF5] to-white rounded-2xl p-8 border border-[#C41E3A]/20">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center">
<MessageCircle className="w-6 h-6 text-white" />
</div>
<h2 className="text-2xl font-semibold text-[#1C1C1C]">
</h2>
</div>
<p className="text-[#5C5C5C] leading-relaxed text-lg">
{caseItem.challenge}
</p>
</section>
{/* 我们如何智连未来 */}
<section className="bg-gradient-to-br from-[#FFFBF5] to-white rounded-2xl p-8 border border-[#C41E3A]/20">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center">
<Target className="w-6 h-6 text-white" />
</div>
<h2 className="text-2xl font-semibold text-[#1C1C1C]">
</h2>
</div>
<div className="prose prose-base max-w-none [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-[#1C1C1C] [&_h3]:mt-8 [&_h3]:mb-4 [&_p]:text-[#5C5C5C] [&_p]:leading-[1.8] [&_p]:mb-4 [&_p]:text-base">
<p className="text-[#5C5C5C] leading-relaxed text-lg">
{caseItem.excerpt}
{caseItem.solution}
</p>
<div className="mt-6 p-4 bg-white rounded-lg border border-[#E5E5E5]">
<p className="text-sm text-[#737373] italic">
&ldquo;...&rdquo;
</p>
</div>
</section>
<section className="bg-gradient-to-br from-[#FFFBF5] to-white rounded-2xl p-8 border border-[#C41E3A]/20">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center">
<Target className="w-6 h-6 text-white" />
</div>
<h2 className="text-2xl font-semibold text-[#1C1C1C]">
</h2>
</div>
<div className="prose prose-base max-w-none [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-[#1C1C1C] [&_h3]:mt-8 [&_h3]:mb-4 [&_p]:text-[#5C5C5C] [&_p]:leading-[1.8] [&_p]:mb-4 [&_p]:text-base">
<div dangerouslySetInnerHTML={{ __html: caseItem.content }} />
</div>
</section>
</div>
</section>
{/* 共同成长的故事 */}
{caseItem.keyMoments && caseItem.keyMoments.length > 0 && (
<section className="bg-gradient-to-br from-[#FFFBF5] to-white rounded-2xl p-8 border border-[#C41E3A]/20">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center">
@@ -114,31 +148,30 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
</h2>
</div>
<div className="space-y-4">
<div className="p-4 bg-white rounded-lg border border-[#E5E5E5]">
<div className="flex items-start gap-3">
<Quote className="w-5 h-5 text-[#C41E3A] flex-shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-[#1C1C1C] mb-2"></h4>
<p className="text-sm text-[#737373]">
线3线
</p>
{caseItem.keyMoments.map((moment, index) => (
<div
key={index}
className="p-4 bg-white rounded-lg border border-[#E5E5E5]"
>
<div className="flex items-start gap-3">
<Quote className="w-5 h-5 text-[#C41E3A] flex-shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-[#1C1C1C] mb-2">
{moment.title}
</h4>
<p className="text-sm text-[#737373]">
{moment.description}
</p>
</div>
</div>
</div>
</div>
<div className="p-4 bg-white rounded-lg border border-[#E5E5E5]">
<div className="flex items-start gap-3">
<Quote className="w-5 h-5 text-[#C41E3A] flex-shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-[#1C1C1C] mb-2"></h4>
<p className="text-sm text-[#737373]">
</p>
</div>
</div>
</div>
))}
</div>
</section>
)}
{/* 今天,他们走到了哪里 */}
{caseItem.results && caseItem.results.length > 0 && (
<section className="bg-gradient-to-br from-[#FFFBF5] to-white rounded-2xl p-8 border border-[#C41E3A]/20">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center">
@@ -148,36 +181,27 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
</h2>
</div>
<div className="grid sm:grid-cols-3 gap-4 mb-6">
<div className="p-6 bg-white rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
<TrendingUp className="w-8 h-8 text-[#C41E3A] mb-3" />
<div className="text-2xl font-semibold text-[#C41E3A] mb-1">
300%
<div className="grid sm:grid-cols-3 gap-4">
{caseItem.results.map((result, index) => (
<div
key={index}
className="p-6 bg-white rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors"
>
<TrendingUp className="w-8 h-8 text-[#C41E3A] mb-3" />
<div className="text-2xl font-semibold text-[#C41E3A] mb-1">
{result.value}
</div>
<div className="text-sm text-[#737373]">
{result.label}
</div>
</div>
<div className="text-sm text-[#737373]"></div>
</div>
<div className="p-6 bg-white rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
<Users className="w-8 h-8 text-[#C41E3A] mb-3" />
<div className="text-2xl font-semibold text-[#C41E3A] mb-1">
95%
</div>
<div className="text-sm text-[#737373]"></div>
</div>
<div className="p-6 bg-white rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
<Target className="w-8 h-8 text-[#C41E3A] mb-3" />
<div className="text-2xl font-semibold text-[#C41E3A] mb-1">
-40%
</div>
<div className="text-sm text-[#737373]"></div>
</div>
</div>
<div className="p-4 bg-white rounded-lg border border-[#E5E5E5]">
<p className="text-sm text-[#737373] italic">
&ldquo;&rdquo;
</p>
))}
</div>
</section>
)}
{/* 客户证言精选 */}
{caseItem.testimonial && (
<section className="bg-gradient-to-br from-[#FFFBF5] to-white rounded-2xl p-8 border border-[#C41E3A]/20">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center">
@@ -190,61 +214,73 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
<div className="p-6 bg-white rounded-lg border border-[#E5E5E5]">
<Quote className="w-8 h-8 text-[#C41E3A] mb-4" />
<p className="text-lg text-[#1C1C1C] leading-relaxed mb-4">
{caseItem.testimonial.quote}
</p>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-[#C41E3A] rounded-full flex items-center justify-center">
<span className="text-white font-semibold"></span>
</div>
<div>
<p className="font-semibold text-[#1C1C1C]"></p>
<p className="text-sm text-[#737373]">CEO</p>
<p className="font-semibold text-[#1C1C1C]">
{caseItem.testimonial.author}
</p>
<p className="text-sm text-[#737373]">
{caseItem.testimonial.role}
</p>
</div>
</div>
</div>
</section>
)}
</div>
{/* 侧边栏 */}
<div className="space-y-6">
<div className="p-6 bg-[#FAFAFA] rounded-lg border border-[#E5E5E5]">
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-4">
</h3>
<dl className="space-y-3">
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium">
{caseItem.testimonial?.author || '客户企业'}
</dd>
</div>
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium">
{caseItem.category}
</dd>
</div>
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium">
{caseItem.duration || '3年'}
</dd>
</div>
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium">{caseItem.date}</dd>
</div>
</dl>
</div>
<div className="space-y-6">
<div className="p-6 bg-[#FAFAFA] rounded-lg border border-[#E5E5E5]">
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-4"></h3>
<dl className="space-y-3">
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium"></dd>
</div>
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium">{caseItem.category}</dd>
</div>
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium">3</dd>
</div>
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium">{caseItem.date}</dd>
</div>
</dl>
</div>
<div className="p-6 bg-gradient-to-br from-[#C41E3A] to-[#8B1429] rounded-lg text-white">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm text-white/80 mb-4">
</p>
<Button
className="w-full bg-white text-[#C41E3A] hover:bg-white/90"
asChild
>
<StaticLink href="/contact">
</StaticLink>
</Button>
</div>
<div className="p-6 bg-gradient-to-br from-[#C41E3A] to-[#8B1429] rounded-lg text-white">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm text-white/80 mb-4">
</p>
<Button
className="w-full bg-white text-[#C41E3A] hover:bg-white/90"
asChild
>
<StaticLink href="/contact"></StaticLink>
</Button>
</div>
</div>
</div>
</main>
</div>
</div>
);
}
+8 -2
View File
@@ -37,10 +37,16 @@ export default async function CaseDetailPage({ params }: { params: Promise<{ id:
id: caseItem.id,
title: caseItem.title,
excerpt: caseItem.description,
content: caseItem.content || '',
content: caseItem.solution || '',
category: caseItem.industry,
slug: caseItem.id,
date: '2026-01-15',
date: caseItem.date,
image: caseItem.image,
challenge: caseItem.challenge,
solution: caseItem.solution,
keyMoments: caseItem.keyMoments,
results: caseItem.results,
testimonial: caseItem.testimonial,
duration: caseItem.duration,
}} />;
}
+8 -5
View File
@@ -99,6 +99,7 @@ export default function CasesPage() {
value={searchQuery}
onChange={handleSearchChange}
className="pl-10"
aria-label="搜索案例"
/>
</div>
</motion.div>
@@ -145,12 +146,14 @@ export default function CasesPage() {
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="secondary" className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
3
</Badge>
<Badge variant="secondary" className="flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
{caseItem.duration}
</Badge>
{caseItem.tags.slice(0, 1).map((tag) => (
<Badge key={tag} variant="secondary" className="flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
{tag}
</Badge>
))}
</div>
<p className="text-[#5C5C5C] text-sm line-clamp-2 mb-4">
-256
View File
@@ -1,256 +0,0 @@
import { describe, it, expect, jest, beforeAll } from '@jest/globals';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import '@testing-library/jest-dom';
global.fetch = jest.fn();
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
},
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('lucide-react', () => ({
Mail: () => <span data-testid="mail-icon" />,
Phone: () => <span data-testid="phone-icon" />,
MapPin: () => <span data-testid="map-pin-icon" />,
Send: () => <span data-testid="send-icon" />,
Loader2: () => <span data-testid="loader-icon" />,
Clock: () => <span data-testid="clock-icon" />,
HeadphonesIcon: () => <span data-testid="headphones-icon" />,
CheckCircle2: () => <span data-testid="check-circle-icon" />,
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, className, disabled, ...props }: any) => (
<button className={className} disabled={disabled} {...props}>
{children}
</button>
),
}));
jest.mock('@/components/ui/input', () => ({
Input: ({ label, error, 'data-testid': testId, id, onChange, onBlur, ...props }: any) => (
<div>
{label && <label htmlFor={id}>{label}</label>}
<input
id={id}
data-testid={testId}
onChange={onChange}
onBlur={onBlur}
{...props}
/>
{error && <span data-testid={`${id}-error`} role="alert">{error}</span>}
</div>
),
}));
jest.mock('@/components/ui/textarea', () => ({
Textarea: ({ label, error, 'data-testid': testId, id, onChange, onBlur, ...props }: any) => (
<div>
{label && <label htmlFor={id}>{label}</label>}
<textarea
id={id}
data-testid={testId}
onChange={onChange}
onBlur={onBlur}
{...props}
/>
{error && <span data-testid={`${id}-error`} role="alert">{error}</span>}
</div>
),
}));
jest.mock('@/components/ui/toast', () => ({
Toast: ({ message, type, onClose }: any) => (
<div data-testid="toast" data-type={type}>
{message}
<button onClick={onClose}></button>
</div>
),
}));
jest.mock('@/lib/sanitize', () => ({
sanitizeInput: (input: string) => input,
}));
jest.mock('@/lib/csrf', () => ({
generateCSRFToken: () => 'test-csrf-token',
setCSRFTokenToStorage: jest.fn(),
}));
jest.mock('@/lib/constants', () => ({
COMPANY_INFO: {
name: '四川睿新致远科技有限公司',
email: 'contact@ruixin.com',
phone: '028-12345678',
address: '四川省成都市龙泉驿区',
},
}));
jest.mock('resend', () => ({
Resend: jest.fn().mockImplementation(() => ({
emails: {
send: jest.fn().mockResolvedValue({
data: { id: 'test-email-id' },
error: null,
}),
},
})),
}));
jest.mock('./actions', () => ({
submitContactForm: jest.fn(),
}));
import ContactPage from './page';
import { submitContactForm } from './actions';
describe('ContactPage', () => {
beforeEach(() => {
jest.clearAllMocks();
(global.fetch as jest.Mock).mockReset();
});
describe('Rendering', () => {
it('should render contact page', () => {
const { container } = render(<ContactPage />);
const main = screen.getByRole('main');
expect(main).toBeInTheDocument();
});
it('should render page title', () => {
render(<ContactPage />);
const title = screen.getByText(/开启/i);
expect(title).toBeInTheDocument();
});
it('should render name input', () => {
render(<ContactPage />);
const nameInput = screen.getByPlaceholderText(/请输入您的姓名/i);
expect(nameInput).toBeInTheDocument();
});
it('should render email input', () => {
render(<ContactPage />);
const emailInput = screen.getByPlaceholderText(/请输入您的邮箱/i);
expect(emailInput).toBeInTheDocument();
});
it('should render phone input', () => {
render(<ContactPage />);
const phoneInput = screen.getByPlaceholderText(/请输入您的电话/i);
expect(phoneInput).toBeInTheDocument();
});
it('should render message textarea', () => {
render(<ContactPage />);
const messageTextarea = screen.getByPlaceholderText(/请输入您想咨询的内容/i);
expect(messageTextarea).toBeInTheDocument();
});
it('should render submit button', () => {
render(<ContactPage />);
const submitButton = screen.getByTestId('submit-button');
expect(submitButton).toBeInTheDocument();
});
it('should render contact information', () => {
render(<ContactPage />);
const contactInfo = screen.getByTestId('contact-info');
expect(contactInfo).toBeInTheDocument();
});
});
describe('Form Validation', () => {
it('should show error for short name on blur', async () => {
render(<ContactPage />);
const nameInput = screen.getByPlaceholderText(/请输入您的姓名/i);
await act(async () => {
fireEvent.change(nameInput, { target: { value: 'A' } });
fireEvent.blur(nameInput);
});
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
it('should show error for invalid phone on blur', async () => {
render(<ContactPage />);
const phoneInput = screen.getByPlaceholderText(/请输入您的电话/i);
await act(async () => {
fireEvent.change(phoneInput, { target: { value: '12345' } });
fireEvent.blur(phoneInput);
});
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
it('should show error for invalid email on blur', async () => {
render(<ContactPage />);
const emailInput = screen.getByPlaceholderText(/请输入您的邮箱/i);
await act(async () => {
fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
fireEvent.blur(emailInput);
});
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
});
describe('Form Submission', () => {
it('should submit form successfully', async () => {
const mockSubmitContactForm = submitContactForm as jest.Mock;
mockSubmitContactForm.mockResolvedValueOnce({
success: true,
message: '消息已发送',
});
render(<ContactPage />);
const nameInput = screen.getByPlaceholderText(/请输入您的姓名/i);
const phoneInput = screen.getByPlaceholderText(/请输入您的电话/i);
const emailInput = screen.getByPlaceholderText(/请输入您的邮箱/i);
const subjectInput = screen.getByPlaceholderText(/请输入消息主题/i);
const messageTextarea = screen.getByPlaceholderText(/请输入您想咨询的内容/i);
await act(async () => {
fireEvent.change(nameInput, { target: { value: '张三' } });
fireEvent.change(phoneInput, { target: { value: '13800138000' } });
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.change(subjectInput, { target: { value: '测试主题' } });
fireEvent.change(messageTextarea, { target: { value: '这是一条测试留言内容' } });
});
const form = document.querySelector('form');
expect(form).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have main landmark', () => {
render(<ContactPage />);
const main = screen.getByRole('main');
expect(main).toBeInTheDocument();
});
it('should have proper heading hierarchy', () => {
render(<ContactPage />);
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1).toBeInTheDocument();
});
});
});
+33 -22
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
@@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Toast } from '@/components/ui/toast';
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2 } from 'lucide-react';
import { COMPANY_INFO } from '@/lib/constants';
import { trackContactForm, trackConversion } from '@/lib/analytics';
const contactFormSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符'),
@@ -28,7 +29,7 @@ interface FormErrors {
message?: string;
}
export default function ContactPage() {
function ContactFormContent() {
const searchParams = useSearchParams();
const isSuccessFromRedirect = searchParams.get('success') === 'true';
const [isVisible, setIsVisible] = useState(false);
@@ -50,7 +51,6 @@ export default function ContactPage() {
});
const [errors, setErrors] = useState<FormErrors>({});
const sectionRef = useRef<HTMLElement>(null);
const hasProcessedSuccess = useRef(isSuccessFromRedirect);
useEffect(() => {
requestAnimationFrame(() => {
@@ -58,13 +58,6 @@ export default function ContactPage() {
});
}, []);
useEffect(() => {
if (isSuccessFromRedirect && !hasProcessedSuccess.current) {
hasProcessedSuccess.current = true;
window.history.replaceState({}, '', '/contact');
}
}, [isSuccessFromRedirect]);
const validateField = (field: keyof ContactFormData, value: string) => {
try {
contactFormSchema.shape[field].parse(value);
@@ -106,8 +99,9 @@ export default function ContactPage() {
}
setIsSubmitting(true);
try {
const formEndpoint = `https://formsubmit.co/${COMPANY_INFO.email}`;
const formEndpoint = `https://formsubmit.co/ajax/${COMPANY_INFO.email}`;
const formBody = new URLSearchParams();
formBody.append('name', formData.name);
formBody.append('phone', formData.phone);
@@ -117,31 +111,36 @@ export default function ContactPage() {
formBody.append('_subject', `网站留言: ${formData.subject}`);
formBody.append('_captcha', 'false');
formBody.append('_template', 'table');
formBody.append('_next', `${window.location.origin}/contact?success=true`);
const response = await fetch(formEndpoint, {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: formBody,
});
const data = await response.json();
if (response.ok && data.success === 'true') {
setIsSubmitted(true);
if (response.ok && (data.success === 'true' || data.success === true)) {
trackContactForm({
name: formData.name,
email: formData.email,
company: formData.subject,
});
trackConversion('contact_form_submission');
setToastMessage('表单提交成功!我们会尽快与您联系。');
setToastType('success');
setShowToast(true);
setIsSubmitted(true);
setFormData({ name: '', phone: '', email: '', subject: '', message: '' });
setErrors({});
} else {
const errorMsg = data.message || '提交失败,请稍后重试或直接发送邮件联系我们。';
if (errorMsg.includes('HTML files') || errorMsg.includes('web server')) {
setToastMessage('表单服务需要在生产环境激活。部署后首次提交会发送确认邮件到 ' + COMPANY_INFO.email);
setToastType('error');
} else {
setToastMessage(errorMsg);
setToastType('error');
}
setToastType('error');
setShowToast(true);
}
} catch {
@@ -154,7 +153,7 @@ export default function ContactPage() {
}
return (
<main className="min-h-screen bg-white">
<div className="min-h-screen bg-white">
{showToast && (
<Toast
message={toastMessage}
@@ -180,7 +179,7 @@ export default function ContactPage() {
<span className="text-sm text-[#5C5C5C] tracking-wide" data-testid="page-badge"></span>
</div>
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A]"></span>
<span className="text-[#C41E3A] font-calligraphy"></span>
</h1>
<p className="mt-4 text-[#5C5C5C] max-w-2xl" data-testid="page-description">
@@ -369,6 +368,18 @@ export default function ContactPage() {
</div>
</div>
</section>
</main>
</div>
);
}
export default function ContactPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="animate-pulse text-[#5C5C5C]">...</div>
</div>
}>
<ContactFormContent />
</Suspense>
);
}
+67 -24
View File
@@ -5,72 +5,115 @@ import { useSearchParams } from 'next/navigation';
import dynamic from 'next/dynamic';
import { HeroSection } from "@/components/sections/hero-section";
import { SectionSkeleton } from "@/components/ui/loading-skeleton";
import type { ReactNode } from 'react';
declare global {
interface Window {
__isProgrammaticScroll?: boolean;
}
}
const ServicesSection = dynamic(
() => import('@/components/sections/services-section').then(mod => ({ default: mod.ServicesSection })),
{
{
loading: () => <SectionSkeleton />,
ssr: false
ssr: false
}
);
const HomeSolutionsSection = dynamic(
() => import('@/components/sections/home-solutions-section').then(mod => ({ default: mod.HomeSolutionsSection })),
{
loading: () => <SectionSkeleton />,
ssr: false
}
);
const ProductsSection = dynamic(
() => import('@/components/sections/products-section').then(mod => ({ default: mod.ProductsSection })),
{
{
loading: () => <SectionSkeleton />,
ssr: false
ssr: false
}
);
const CasesSection = dynamic(
() => import('@/components/sections/cases-section').then(mod => ({ default: mod.CasesSection })),
{
{
loading: () => <SectionSkeleton />,
ssr: false
ssr: false
}
);
const AboutSection = dynamic(
() => import('@/components/sections/about-section').then(mod => ({ default: mod.AboutSection })),
{
{
loading: () => <SectionSkeleton />,
ssr: false
ssr: false
}
);
const TeamSection = dynamic(
() => import('@/components/sections/team-section').then(mod => ({ default: mod.TeamSection })),
{
loading: () => <SectionSkeleton />,
ssr: false
}
);
const NewsSection = dynamic(
() => import('@/components/sections/news-section').then(mod => ({ default: mod.NewsSection })),
{
{
loading: () => <SectionSkeleton />,
ssr: false
ssr: false
}
);
function HomeContent() {
function HomeContent({ heroStats }: { heroStats: ReactNode }) {
const searchParams = useSearchParams();
useEffect(() => {
const section = searchParams.get('section');
if (section) {
const timer = setTimeout(() => {
const targetElement = document.getElementById(section);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 100);
return () => clearTimeout(timer);
}
return undefined;
if (!section) {return;}
const maxAttempts = 50;
const interval = 100;
let attempts = 0;
const scrollToSection = () => {
const targetElement = document.getElementById(section);
if (targetElement) {
window.__isProgrammaticScroll = true;
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
setTimeout(() => {
window.__isProgrammaticScroll = false;
}, 2000);
return true;
}
return false;
};
if (scrollToSection()) {return;}
const timer = setInterval(() => {
attempts++;
if (scrollToSection() || attempts >= maxAttempts) {
clearInterval(timer);
}
}, interval);
return () => clearInterval(timer);
}, [searchParams]);
return (
<main className="min-h-screen bg-white dark:bg-(--color-bg-primary)">
<HeroSection />
<main id="main-content" className="min-h-screen bg-white dark:bg-(--color-bg-primary)">
<HeroSection heroStats={heroStats} />
<ServicesSection />
<HomeSolutionsSection />
<ProductsSection />
<CasesSection />
<AboutSection />
<TeamSection />
<NewsSection />
</main>
);
+1 -1
View File
@@ -28,7 +28,7 @@ export default function MarketingLayout({
<div className="min-h-screen flex flex-col">
<Header />
<ErrorBoundary>
<main className="flex-1 pt-16">
<main id="main-content" className="flex-1 pt-16">
{breadcrumbItem && (
<div className="container-wide">
<Breadcrumb items={[breadcrumbItem]} />
@@ -52,9 +52,19 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
className="max-w-4xl"
>
<article className="prose prose-lg max-w-none">
<div className="aspect-video bg-linear-to-br from-[#C41E3A]/10 to-[#1C1C1C]/10 rounded-lg mb-8 flex items-center justify-center">
<span className="text-6xl">📰</span>
</div>
{news.image ? (
<div className="aspect-video rounded-lg overflow-hidden mb-8">
<img
src={news.image}
alt={news.title}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="aspect-video bg-linear-to-br from-[#C41E3A]/10 to-[#1C1C1C]/10 rounded-lg mb-8 flex items-center justify-center">
<span className="text-6xl">📰</span>
</div>
)}
<p className="text-xl text-[#5C5C5C] leading-relaxed mb-8 border-l-4 border-[#C41E3A] pl-6">
{news.excerpt}
@@ -74,8 +84,18 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
{relatedNews.map((related) => (
<StaticLink key={related.id} href={`/news/${related.id}`}>
<div className="group cursor-pointer">
<div className="aspect-video bg-linear-to-br from-[#C41E3A]/10 to-[#1C1C1C]/10 rounded-lg mb-4 flex items-center justify-center group-hover:shadow-lg transition-shadow">
<span className="text-4xl">📰</span>
<div className="aspect-video rounded-lg mb-4 overflow-hidden group-hover:shadow-lg transition-shadow">
{related.image ? (
<img
src={related.image}
alt={related.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-linear-to-br from-[#C41E3A]/10 to-[#1C1C1C]/10 flex items-center justify-center">
<span className="text-4xl">📰</span>
</div>
)}
</div>
<Badge variant="secondary" className="mb-2">
{related.category}
-219
View File
@@ -1,219 +0,0 @@
import { describe, it, expect, jest, beforeAll, beforeEach } from '@jest/globals';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
section: ({ children, className, ...props }: any) => (
<section className={className} {...props}>
{children}
</section>
),
},
AnimatePresence: ({ children }: any) => <>{children}</>,
useInView: () => [null, true],
}));
jest.mock('next/link', () => {
return ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
);
});
jest.mock('lucide-react', () => ({
Search: () => <span data-testid="search-icon" />,
Calendar: () => <span data-testid="calendar-icon" />,
ArrowRight: () => <span data-testid="arrow-right-icon" />,
ArrowLeft: () => <span data-testid="arrow-left-icon" />,
Filter: () => <span data-testid="filter-icon" />,
ChevronLeft: () => <span data-testid="chevron-left-icon" />,
ChevronRight: () => <span data-testid="chevron-right-icon" />,
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, className, onClick, ...props }: any) => (
<button className={className} onClick={onClick} {...props}>
{children}
</button>
),
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, className, ...props }: any) => (
<span className={className} {...props}>
{children}
</span>
),
}));
jest.mock('@/components/ui/card', () => ({
Card: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
CardContent: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
}));
jest.mock('@/components/ui/input', () => ({
Input: ({ className, ...props }: any) => (
<input className={className} {...props} />
),
}));
jest.mock('@/components/ui/page-header', () => ({
PageHeader: ({ title, description }: any) => (
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
),
}));
const mockNews = [
{
id: 'news-1',
title: '公司成立新闻',
category: '公司新闻',
date: '2026-01-15',
excerpt: '公司正式成立,开启数字化转型之旅',
content: '详细内容',
slug: 'company-founded',
},
{
id: 'news-2',
title: '产品发布新闻',
category: '产品发布',
date: '2026-02-01',
excerpt: '新产品正式发布',
content: '详细内容',
slug: 'product-released',
},
];
jest.mock('@/hooks/use-news', () => ({
useNews: () => ({
news: mockNews,
loading: false,
error: null,
}),
}));
import NewsListPage from './page';
describe('NewsListPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render news page', () => {
const { container } = render(<NewsListPage />);
const pageContainer = container.querySelector('.min-h-screen');
expect(pageContainer).toBeInTheDocument();
});
it('should render page header', async () => {
render(<NewsListPage />);
await waitFor(() => {
const title = screen.getByText(/新闻动态/i);
expect(title).toBeInTheDocument();
});
});
it('should render back to home link', async () => {
render(<NewsListPage />);
await waitFor(() => {
const backLink = screen.getByText(/返回首页/i);
expect(backLink).toBeInTheDocument();
});
});
it('should render news cards', async () => {
render(<NewsListPage />);
await waitFor(() => {
const headings = screen.getAllByRole('heading');
const newsCards = headings.filter(h => h.tagName === 'H3');
expect(newsCards.length).toBeGreaterThan(0);
});
});
it('should render category filter', async () => {
render(<NewsListPage />);
await waitFor(() => {
const allButton = screen.getByRole('button', { name: '全部' });
expect(allButton).toBeInTheDocument();
});
});
it('should render search input', async () => {
render(<NewsListPage />);
await waitFor(() => {
const searchInput = screen.getByPlaceholderText(/搜索新闻/i);
expect(searchInput).toBeInTheDocument();
});
});
});
describe('Filtering', () => {
it('should filter news by category', async () => {
render(<NewsListPage />);
await waitFor(() => {
const companyNewsButton = screen.getByRole('button', { name: '公司新闻' });
fireEvent.click(companyNewsButton);
});
await waitFor(() => {
const headings = screen.getAllByRole('heading');
const newsCards = headings.filter(h => h.tagName === 'H3');
expect(newsCards.length).toBe(1);
});
});
it('should filter news by search query', async () => {
render(<NewsListPage />);
await waitFor(() => {
const searchInput = screen.getByPlaceholderText(/搜索新闻/i);
fireEvent.change(searchInput, { target: { value: '成立' } });
});
await waitFor(() => {
const headings = screen.getAllByRole('heading');
const newsCards = headings.filter(h => h.tagName === 'H3');
expect(newsCards.length).toBe(1);
});
});
});
describe('Navigation', () => {
it('should have news detail links', async () => {
render(<NewsListPage />);
await waitFor(() => {
const links = screen.getAllByRole('link');
const newsLinks = links.filter(link => link.getAttribute('href')?.startsWith('/news/'));
expect(newsLinks.length).toBeGreaterThan(0);
});
});
});
describe('Accessibility', () => {
it('should have proper heading hierarchy', async () => {
render(<NewsListPage />);
await waitFor(() => {
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1).toBeInTheDocument();
});
});
});
});
+2 -1
View File
@@ -12,7 +12,7 @@ import { Search, Calendar, Filter, ChevronLeft, ChevronRight, ArrowRight } from
import { StaticLink } from '@/components/ui/static-link';
import { motion } from 'framer-motion';
const categories = ['全部', '公司新闻', '产品发布', '合作动态', '行业资讯'];
const categories = ['全部', '公司新闻', '产品发布'];
const ITEMS_PER_PAGE = 9;
export default function NewsListPage() {
@@ -98,6 +98,7 @@ export default function NewsListPage() {
value={searchQuery}
onChange={handleSearchChange}
className="pl-10"
aria-label="搜索新闻"
/>
</div>
</motion.div>
-212
View File
@@ -1,212 +0,0 @@
import { describe, it, expect, jest, beforeAll } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
jest.mock('next/navigation', () => ({
useSearchParams: () => ({
get: jest.fn(),
}),
}));
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
section: ({ children, className, ...props }: any) => (
<section className={className} {...props}>
{children}
</section>
),
span: ({ children, className, ...props }: any) => (
<span className={className} {...props}>
{children}
</span>
),
h1: ({ children, className, ...props }: any) => (
<h1 className={className} {...props}>
{children}
</h1>
),
},
AnimatePresence: ({ children }: any) => <>{children}</>,
useInView: () => [null, true],
}));
jest.mock('next/link', () => {
return ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
);
});
jest.mock('@/db', () => ({
db: {
select: jest.fn().mockReturnValue({
from: jest.fn().mockResolvedValue([]),
}),
},
}));
jest.mock('@/components/sections/hero-section', () => ({
HeroSection: () => (
<section id="home" aria-labelledby="hero-heading">
<h1 id="hero-heading"></h1>
</section>
),
}));
jest.mock('@/components/sections/services-section', () => ({
ServicesSection: () => (
<section id="services" aria-labelledby="services-heading">
<h2 id="services-heading"></h2>
</section>
),
}));
jest.mock('@/components/sections/products-section', () => ({
ProductsSection: () => (
<section id="products" aria-labelledby="products-heading">
<h2 id="products-heading"></h2>
</section>
),
}));
jest.mock('@/components/sections/cases-section', () => ({
CasesSection: () => (
<section id="cases" aria-labelledby="cases-heading">
<h2 id="cases-heading"></h2>
</section>
),
}));
jest.mock('@/components/sections/about-section', () => ({
AboutSection: () => (
<section id="about" aria-labelledby="about-heading">
<h2 id="about-heading"></h2>
</section>
),
}));
jest.mock('@/components/sections/news-section', () => ({
NewsSection: () => (
<section id="news" aria-labelledby="news-heading">
<h2 id="news-heading"></h2>
</section>
),
}));
jest.mock('@/components/ui/loading-skeleton', () => ({
SectionSkeleton: () => <div data-testid="section-skeleton">Loading...</div>,
}));
jest.mock('next/dynamic', () => ({
__esModule: true,
default: (importFn: any) => {
const mockComponents: Record<string, any> = {
'@/components/sections/services-section': () => (
<section id="services" aria-labelledby="services-heading">
<h2 id="services-heading"></h2>
</section>
),
'@/components/sections/products-section': () => (
<section id="products" aria-labelledby="products-heading">
<h2 id="products-heading"></h2>
</section>
),
'@/components/sections/cases-section': () => (
<section id="cases" aria-labelledby="cases-heading">
<h2 id="cases-heading"></h2>
</section>
),
'@/components/sections/about-section': () => (
<section id="about" aria-labelledby="about-heading">
<h2 id="about-heading"></h2>
</section>
),
'@/components/sections/news-section': () => (
<section id="news" aria-labelledby="news-heading">
<h2 id="news-heading"></h2>
</section>
),
};
const importString = importFn.toString();
for (const [key, component] of Object.entries(mockComponents)) {
if (importString.includes(key.replace('@/components/sections/', ''))) {
return component;
}
}
return () => <div>Mocked Dynamic Component</div>;
},
}));
import { HomeContent } from './home-content';
describe('HomePage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render home page', () => {
render(<HomeContent config={{}} />);
const main = screen.getByRole('main');
expect(main).toBeInTheDocument();
});
it('should render hero section', () => {
render(<HomeContent config={{}} />);
const heroSection = document.querySelector('#home');
expect(heroSection).toBeInTheDocument();
});
it('should render services section', () => {
render(<HomeContent config={{}} />);
const servicesSection = document.querySelector('#services');
expect(servicesSection).toBeInTheDocument();
});
it('should render products section', () => {
render(<HomeContent config={{}} />);
const productsSection = document.querySelector('#products');
expect(productsSection).toBeInTheDocument();
});
it('should render cases section', () => {
render(<HomeContent config={{}} />);
const casesSection = document.querySelector('#cases');
expect(casesSection).toBeInTheDocument();
});
it('should render about section', () => {
render(<HomeContent config={{}} />);
const aboutSection = document.querySelector('#about');
expect(aboutSection).toBeInTheDocument();
});
it('should render news section', () => {
render(<HomeContent config={{}} />);
const newsSection = document.querySelector('#news');
expect(newsSection).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have main landmark', () => {
render(<HomeContent config={{}} />);
const main = screen.getByRole('main');
expect(main).toBeInTheDocument();
});
it('should have proper heading hierarchy', () => {
render(<HomeContent config={{}} />);
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1).toBeInTheDocument();
});
});
});
+2 -1
View File
@@ -1,11 +1,12 @@
import { Suspense } from 'react';
import { HomeContent } from './home-content';
import { SectionSkeleton } from '@/components/ui/loading-skeleton';
import { HeroStatsSSR } from '@/components/sections/hero-stats-ssr';
export default function HomePage() {
return (
<Suspense fallback={<SectionSkeleton />}>
<HomeContent />
<HomeContent heroStats={<HeroStatsSSR />} />
</Suspense>
);
}
-212
View File
@@ -1,212 +0,0 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
section: ({ children, className, ...props }: any) => (
<section className={className} {...props}>
{children}
</section>
),
},
AnimatePresence: ({ children }: any) => <>{children}</>,
useInView: () => [null, true],
}));
jest.mock('next/link', () => {
return ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
);
});
jest.mock('lucide-react', () => ({
ArrowRight: () => <span data-testid="arrow-right-icon" />,
ArrowLeft: () => <span data-testid="arrow-left-icon" />,
Check: () => <span data-testid="check-icon" />,
TrendingUp: () => <span data-testid="trending-up-icon" />,
Search: () => <span data-testid="search-icon" />,
ChevronLeft: () => <span data-testid="chevron-left-icon" />,
ChevronRight: () => <span data-testid="chevron-right-icon" />,
Filter: () => <span data-testid="filter-icon" />,
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, className, ...props }: any) => (
<button className={className} {...props}>
{children}
</button>
),
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, className, ...props }: any) => (
<span className={className} {...props}>
{children}
</span>
),
}));
jest.mock('@/components/ui/input', () => ({
Input: ({ className, ...props }: any) => (
<input className={className} {...props} />
),
}));
jest.mock('@/components/ui/card', () => ({
Card: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
CardContent: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
CardHeader: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
CardTitle: ({ children, className, ...props }: any) => (
<h3 className={className} {...props}>
{children}
</h3>
),
CardDescription: ({ children, className, ...props }: any) => (
<p className={className} {...props}>
{children}
</p>
),
}));
jest.mock('@/components/ui/page-header', () => ({
PageHeader: ({ title, description }: any) => (
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
),
}));
const mockProducts = [
{
id: 'erp',
title: 'ERP企业资源计划',
category: '软件产品',
description: '一站式企业资源管理解决方案',
features: ['财务管理', '供应链管理', '生产管理', '人力资源'],
benefits: ['提高运营效率', '降低管理成本'],
},
{
id: 'crm',
title: 'CRM客户关系管理',
category: '软件产品',
description: '智能化客户关系管理平台',
features: ['客户管理', '销售管理', '营销自动化', '数据分析'],
benefits: ['提升客户满意度', '增加销售收入'],
},
];
jest.mock('@/hooks/use-products', () => ({
useProducts: () => ({
products: mockProducts,
loading: false,
error: null,
}),
}));
import ProductsPage from './page';
describe('ProductsPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render products page', async () => {
const { container } = render(<ProductsPage />);
await waitFor(() => {
const pageContainer = container.querySelector('.min-h-screen');
expect(pageContainer).toBeInTheDocument();
});
});
it('should render page header', async () => {
render(<ProductsPage />);
await waitFor(() => {
const title = screen.getByText(/产品服务/i);
expect(title).toBeInTheDocument();
});
});
it('should render back to home link', async () => {
render(<ProductsPage />);
await waitFor(() => {
const backLink = screen.getByText(/返回首页/i);
expect(backLink).toBeInTheDocument();
});
});
it('should render product cards', async () => {
render(<ProductsPage />);
await waitFor(() => {
const productTitles = screen.getAllByRole('heading', { level: 3 });
expect(productTitles.length).toBeGreaterThan(0);
});
});
it('should render product categories', async () => {
render(<ProductsPage />);
await waitFor(() => {
const categories = screen.getAllByText(/软件产品/i);
expect(categories.length).toBeGreaterThan(0);
});
});
it('should render CTA section', async () => {
render(<ProductsPage />);
await waitFor(() => {
const cta = screen.getByText(/需要定制化解决方案/i);
expect(cta).toBeInTheDocument();
});
});
});
describe('Navigation', () => {
it('should have product detail links', async () => {
render(<ProductsPage />);
await waitFor(() => {
const links = screen.getAllByRole('link');
const productLinks = links.filter(link => link.getAttribute('href')?.startsWith('/products/'));
expect(productLinks.length).toBeGreaterThan(0);
});
});
it('should have contact link', async () => {
render(<ProductsPage />);
await waitFor(() => {
const contactLink = screen.getByRole('link', { name: /联系我们/i });
expect(contactLink).toHaveAttribute('href', '/contact');
});
});
});
describe('Accessibility', () => {
it('should have proper heading hierarchy', async () => {
render(<ProductsPage />);
await waitFor(() => {
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1).toBeInTheDocument();
});
});
});
});
+1
View File
@@ -99,6 +99,7 @@ export default function ProductsPage() {
value={searchQuery}
onChange={handleSearchChange}
className="pl-10"
aria-label="搜索产品"
/>
</div>
</motion.div>
+2 -2
View File
@@ -103,7 +103,7 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
const Icon = iconMap[service.icon];
return (
<main className="min-h-screen bg-white">
<div className="min-h-screen bg-white">
<div className="relative overflow-hidden bg-gradient-to-b from-[#FAFAFA] to-white">
<div className="container-wide relative z-10 pt-32 pb-20">
<BackButton />
@@ -272,6 +272,6 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
</div>
</div>
</div>
</main>
</div>
);
}
-171
View File
@@ -1,171 +0,0 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
section: ({ children, className, ...props }: any) => (
<section className={className} {...props}>
{children}
</section>
),
},
AnimatePresence: ({ children }: any) => <>{children}</>,
useInView: () => [null, true],
}));
jest.mock('next/link', () => {
return ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
);
});
jest.mock('lucide-react', () => ({
ArrowRight: () => <span data-testid="arrow-right-icon" />,
ArrowLeft: () => <span data-testid="arrow-left-icon" />,
Code: () => <span data-testid="code-icon" />,
Cloud: () => <span data-testid="cloud-icon" />,
BarChart3: () => <span data-testid="bar-chart-icon" />,
Shield: () => <span data-testid="shield-icon" />,
Search: () => <span data-testid="search-icon" />,
ChevronLeft: () => <span data-testid="chevron-left-icon" />,
ChevronRight: () => <span data-testid="chevron-right-icon" />,
Filter: () => <span data-testid="filter-icon" />,
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, className, ...props }: any) => (
<button className={className} {...props}>
{children}
</button>
),
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, className, ...props }: any) => (
<span className={className} {...props}>
{children}
</span>
),
}));
jest.mock('@/components/ui/input', () => ({
Input: ({ className, ...props }: any) => (
<input className={className} {...props} />
),
}));
jest.mock('@/components/ui/loading-skeleton', () => ({
ServiceCardSkeleton: () => <div data-testid="service-card-skeleton">Loading...</div>,
}));
jest.mock('@/components/ui/page-header', () => ({
PageHeader: ({ title, description }: any) => (
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
),
}));
const mockServices = [
{
id: 'software-dev',
title: '软件开发',
icon: 'Code',
description: '定制化软件开发服务',
features: ['需求分析', '架构设计', '开发测试', '运维支持'],
},
{
id: 'cloud-service',
title: '云服务',
icon: 'Cloud',
description: '企业云服务解决方案',
features: ['云迁移', '云原生', '云安全', '云运维'],
},
];
jest.mock('@/hooks/use-services', () => ({
useServices: () => ({
services: mockServices,
loading: false,
error: null,
}),
}));
import ServicesPage from './page';
describe('ServicesPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render services page', async () => {
const { container } = render(<ServicesPage />);
await waitFor(() => {
const pageContainer = container.querySelector('.min-h-screen');
expect(pageContainer).toBeInTheDocument();
});
});
it('should render page header', async () => {
render(<ServicesPage />);
await waitFor(() => {
const title = screen.getByText(/核心业务/i);
expect(title).toBeInTheDocument();
});
});
it('should render back to home link', async () => {
render(<ServicesPage />);
await waitFor(() => {
const backLink = screen.getByText(/返回首页/i);
expect(backLink).toBeInTheDocument();
});
});
it('should render loading skeletons initially', async () => {
render(<ServicesPage />);
await waitFor(() => {
const pageContainer = screen.queryByText('加载中...');
expect(pageContainer).not.toBeInTheDocument();
});
});
it('should render CTA section', async () => {
render(<ServicesPage />);
await waitFor(() => {
const cta = screen.getByText(/准备开始您的数字化转型之旅/i);
expect(cta).toBeInTheDocument();
});
});
});
describe('Navigation', () => {
it('should have contact link', async () => {
render(<ServicesPage />);
await waitFor(() => {
const contactLink = screen.getByRole('link', { name: /立即咨询/i });
expect(contactLink).toHaveAttribute('href', '/contact');
});
});
});
describe('Accessibility', () => {
it('should have proper heading hierarchy', async () => {
render(<ServicesPage />);
await waitFor(() => {
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1).toBeInTheDocument();
});
});
});
});
+1
View File
@@ -105,6 +105,7 @@ export default function ServicesPage() {
value={searchQuery}
onChange={handleSearchChange}
className="pl-10"
aria-label="搜索服务"
/>
</div>
</motion.div>
+3
View File
@@ -7,6 +7,7 @@ import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/ui/page-header';
import { ArrowRight, Lightbulb, Cpu, Users, CheckCircle2 } from 'lucide-react';
import { MethodologySection } from '@/components/sections/methodology-section';
export default function SolutionsPage() {
const contentRef = useRef(null);
@@ -236,6 +237,8 @@ export default function SolutionsPage() {
</div>
</div>
<MethodologySection />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
+125
View File
@@ -0,0 +1,125 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/ui/page-header';
import { Shield, Building2, Users, Code, Target, ArrowRight } from 'lucide-react';
const TEAM_PILLARS = [
{
icon: Shield,
title: '12年+ 行业深耕',
description: '核心团队长期从事技术咨询、企业数字化等领域,服务覆盖金融、制造、零售、政务、农业等多个行业,积累了丰富的跨行业经验和最佳实践。',
},
{
icon: Building2,
title: '大型 IT 企业背景',
description: '开发团队成员来自多个大型传统 IT 企业,具备扎实的工程能力、规范化的交付流程和严格的质量意识,确保每一个项目都经得起考验。',
},
{
icon: Users,
title: '复合型技术团队',
description: '我们的团队成员既懂技术又懂业务,能够深入理解客户的真实场景和痛点,提供真正可落地的解决方案,而非纸上谈兵。',
},
{
icon: Code,
title: '全栈技术能力',
description: '团队掌握从前端到后端、从云原生到数据智能、从移动端到物联网的全栈技术能力,能够应对各种复杂的技术挑战。',
},
{
icon: Target,
title: '结果导向交付',
description: '我们不以"项目上线"为终点,而是以"客户业务是否真正改善"为衡量标准。每一个交付成果,都追求可量化的业务价值。',
},
];
export function TeamClient() {
const contentRef = useRef(null);
const isContentInView = useInView(contentRef, { once: true, margin: '-100px' });
return (
<div className="min-h-screen bg-white">
<PageHeader
title="核心团队"
description="核心团队从事技术咨询、企业数字化等行业 12 年+,开发团队成员来自于多个大型传统 IT 企业"
/>
<div ref={contentRef} className="container-wide py-12 md:py-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
className="max-w-5xl mx-auto"
>
{/* 团队概述 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.1 }}
className="bg-[#FFFBF5] rounded-2xl p-8 md:p-12 border border-[#E5E5E5] mb-16"
>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 text-center"></h2>
<div className="space-y-4 max-w-3xl mx-auto text-center">
<p className="text-[#5C5C5C] leading-relaxed">
<span className="text-[#C41E3A] font-medium"></span><span className="text-[#C41E3A] font-medium"></span> 12
</p>
<p className="text-[#5C5C5C] leading-relaxed">
<span className="text-[#C41E3A] font-medium"> IT </span>
</p>
<p className="text-[#5C5C5C] leading-relaxed">
</p>
</div>
</motion.div>
{/* 团队优势 */}
<div className="mb-16">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-8 text-center"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{TEAM_PILLARS.map((item, idx) => {
const Icon = item.icon;
return (
<motion.div
key={item.title}
initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.2 + idx * 0.1 }}
className={idx >= 3 ? 'md:col-span-1 lg:col-start-1' : ''}
>
<div className="bg-white rounded-xl p-6 border border-[#E5E5E5] hover:border-[#C41E3A]/30 hover:shadow-md transition-all duration-300 h-full">
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center mb-4">
<Icon className="w-6 h-6 text-white" />
</div>
<h3 className="text-lg font-bold text-[#1C1C1C] mb-2">{item.title}</h3>
<p className="text-sm text-[#5C5C5C] leading-relaxed">{item.description}</p>
</div>
</motion.div>
);
})}
</div>
</div>
{/* CTA */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.5 }}
className="text-center"
>
<p className="text-lg text-[#5C5C5C] mb-6"></p>
<Button size="lg" asChild>
<StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" />
</StaticLink>
</Button>
</motion.div>
</motion.div>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { COMPANY_INFO } from '@/lib/constants';
import { TeamClient } from './client';
export const metadata = {
title: `核心团队 - ${COMPANY_INFO.name}`,
description: `了解${COMPANY_INFO.name}的核心团队。我们的团队成员拥有丰富的行业经验和技术专长,致力于为客户提供专业的数字化转型服务。`,
};
export default function TeamPage() {
return <TeamClient />;
}
Binary file not shown.
Binary file not shown.
+12 -22
View File
@@ -1,29 +1,10 @@
@import "tailwindcss";
@font-face {
font-family: 'Aoyagi Reisho';
src: url('/fonts/AoyagiReisho.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: block;
font-stretch: normal;
unicode-range: U+4E00-9FFF, U+3400-4DBF, U+20000-2A6DF, U+2A700-2B73F, U+2B740-2B81F, U+2B820-2CEAF, U+F900-FAFF, U+2F800-2FA1F;
}
/* 字体加载优化 - 防止 FOUT */
.font-loading {
font-family: 'STKaiti', 'KaiTi', serif;
}
.font-loaded {
font-family: 'Aoyagi Reisho', 'STKaiti', 'KaiTi', serif;
}
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-chinese: var(--font-noto-sans-sc);
--font-calligraphy: 'Aoyagi Reisho', var(--font-long-cang), 'Long Cang', var(--font-ma-shan-zheng), 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif;
--font-calligraphy: 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif;
}
:root {
@@ -339,9 +320,9 @@
background-clip: text;
}
/* 青柳隶书体 - 与 Logo 保持一致 */
/* 马善政行书体 - 用于红色关键词高亮 */
.font-calligraphy {
font-family: 'Aoyagi Reisho', var(--font-long-cang), 'Long Cang', var(--font-ma-shan-zheng), 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif !important;
font-family: var(--font-ma-shan-zheng), 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif !important;
font-weight: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -388,6 +369,15 @@
}
}
/* 青柳隷書 - 仅用于品牌标题"睿新致遠" */
@utility font-brand {
font-family: var(--font-aoyagi-reisho), 'Aoyagi Reisho', 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif !important;
font-weight: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
@keyframes slideIn {
from {
opacity: 0;
+26 -17
View File
@@ -1,8 +1,13 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono, Noto_Sans_SC, Ma_Shan_Zheng, Long_Cang } from "next/font/google";
import { Geist, Geist_Mono, Noto_Sans_SC, Ma_Shan_Zheng } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";
import { Suspense } from "react";
import { ThemeProvider } from "@/contexts/theme-context";
import { GoogleAnalytics } from "@/components/analytics/GoogleAnalytics";
import { CookieConsent } from "@/components/analytics/CookieConsent";
import { PerformanceTracker } from "@/components/analytics/PerformanceTracker";
import { OutboundLinkTracker } from "@/components/analytics/OutboundLinkTracker";
import { OrganizationSchema, WebsiteSchema } from "@/components/seo/structured-data";
import { MobileTabBar } from "@/components/layout/mobile-tab-bar";
import { ErrorBoundary } from "@/components/ui/error-boundary";
@@ -36,18 +41,19 @@ const maShanZheng = Ma_Shan_Zheng({
variable: "--font-ma-shan-zheng",
subsets: ["latin"],
display: "swap",
preload: false,
preload: true,
});
const longCang = Long_Cang({
weight: "400",
variable: "--font-long-cang",
subsets: ["latin"],
// 青柳隷書 - 仅用于品牌标题"睿新致遠"(子集版本,仅包含4个字符)
const aoyagiReisho = localFont({
src: "./fonts/AoyagiReisho-subset.ttf",
variable: "--font-aoyagi-reisho",
display: "swap",
preload: false,
preload: true,
});
export const metadata: Metadata = {
metadataBase: new URL("https://www.novalon.cn"),
title: {
default: "四川睿新致远科技有限公司 - 企业数字化转型服务商",
template: "%s | 四川睿新致远科技有限公司",
@@ -118,29 +124,32 @@ export default function RootLayout({
<head>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
{/* 字体预加载优化 */}
<link
rel="preload"
href="/fonts/AoyagiReisho.ttf"
as="font"
type="font/ttf"
crossOrigin="anonymous"
/>
<OrganizationSchema />
<WebsiteSchema />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} ${notoSansSC.variable} ${maShanZheng.variable} ${longCang.variable} font-sans antialiased`}
className={`${geistSans.variable} ${geistMono.variable} ${notoSansSC.variable} ${maShanZheng.variable} ${aoyagiReisho.variable} font-sans antialiased`}
style={{ fontFamily: "'Noto Sans SC', 'Geist', -apple-system, BlinkMacSystemFont, sans-serif" }}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[9999] focus:px-4 focus:py-2 focus:bg-[#C41E3A] focus:text-white focus:rounded-md focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[#C41E3A]"
>
</a>
<ScrollProgress />
<GoogleAnalytics />
<PerformanceTracker />
<OutboundLinkTracker />
<ThemeProvider>
<ErrorBoundary>
{children}
</ErrorBoundary>
</ThemeProvider>
<MobileTabBar />
<Suspense fallback={null}>
<MobileTabBar />
</Suspense>
<CookieConsent />
<BackToTop />
</body>
</html>
@@ -0,0 +1,95 @@
'use client';
import { useState, useEffect } from 'react';
import { updateConsent, trackButtonClick } from '@/lib/analytics';
import { motion, AnimatePresence } from 'framer-motion';
const CONSENT_KEY = 'ga_consent';
export function CookieConsent() {
const [showConsent, setShowConsent] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
const consent = localStorage.getItem(CONSENT_KEY);
if (!consent) {
const timer = setTimeout(() => {
setShowConsent(true);
}, 2000);
return () => clearTimeout(timer);
} else if (consent === 'granted') {
updateConsent(true);
}
return undefined;
}, []);
const handleAccept = () => {
setIsAnimating(true);
localStorage.setItem(CONSENT_KEY, 'granted');
updateConsent(true);
trackButtonClick('accept_cookies', 'consent_banner');
setTimeout(() => {
setShowConsent(false);
setIsAnimating(false);
}, 300);
};
const handleDecline = () => {
setIsAnimating(true);
localStorage.setItem(CONSENT_KEY, 'denied');
updateConsent(false);
trackButtonClick('decline_cookies', 'consent_banner');
setTimeout(() => {
setShowConsent(false);
setIsAnimating(false);
}, 300);
};
return (
<AnimatePresence>
{showConsent && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed bottom-16 md:bottom-0 left-0 right-0 z-[9998] bg-white border-t border-gray-200 shadow-lg"
>
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex-1">
<p className="text-sm text-gray-700">
使 Cookie
使{' '}
<a
href="/privacy"
className="text-[#C41E3A] hover:text-[#A01830] underline font-medium"
>
</a>
</p>
</div>
<div className="flex gap-3 shrink-0">
<button
onClick={handleDecline}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleAccept}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-white bg-[#C41E3A] rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
>
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
+44 -5
View File
@@ -1,13 +1,30 @@
'use client';
import Script from 'next/script';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, Suspense } from 'react';
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
export function GoogleAnalytics() {
if (!GA_MEASUREMENT_ID) {
return null;
}
function GoogleAnalyticsContent() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!GA_MEASUREMENT_ID || typeof window === 'undefined') {return;}
const url = pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
if (window.gtag) {
window.gtag('config', GA_MEASUREMENT_ID, {
page_path: url,
page_title: document.title,
page_location: window.location.origin + url,
});
}
}, [pathname, searchParams]);
if (!GA_MEASUREMENT_ID) {return null;}
return (
<>
@@ -20,11 +37,33 @@ export function GoogleAnalytics() {
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
// 默认禁用存储,等待用户同意
gtag('consent', 'default', {
'analytics_storage': 'denied',
'ad_storage': 'denied',
'wait_for_update': 500
});
gtag('config', '${GA_MEASUREMENT_ID}', {
send_page_view: false
send_page_view: false,
anonymize_ip: true,
allow_google_signals: true,
allow_ad_personalization_signals: false,
cookie_flags: 'SameSite=None;Secure'
});
`}
</Script>
</>
);
}
export function GoogleAnalytics() {
if (!GA_MEASUREMENT_ID) {return null;}
return (
<Suspense fallback={null}>
<GoogleAnalyticsContent />
</Suspense>
);
}
@@ -0,0 +1,31 @@
'use client';
import { useEffect } from 'react';
import { trackOutboundLink } from '@/lib/analytics';
export function OutboundLinkTracker() {
useEffect(() => {
if (typeof window === 'undefined') {return;}
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const link = target.closest('a');
if (link && link.href) {
try {
const url = new URL(link.href);
if (url.hostname !== window.location.hostname && url.protocol.startsWith('http')) {
trackOutboundLink(link.href);
}
} catch {
// Invalid URL
}
}
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
return null;
}
@@ -0,0 +1,73 @@
'use client';
import { useEffect } from 'react';
import { trackPerformance } from '@/lib/analytics';
export function PerformanceTracker() {
useEffect(() => {
if (typeof window === 'undefined') {return;}
const reportWebVitals = (): (() => void) | undefined => {
if ('PerformanceObserver' in window) {
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
if (lastEntry) {
trackPerformance('LCP', lastEntry.startTime);
}
});
const fidObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const firstEntry = entries[0];
if (firstEntry && 'processingStart' in firstEntry) {
const fidEntry = firstEntry as PerformanceEventTiming;
trackPerformance('FID', fidEntry.processingStart - fidEntry.startTime);
}
});
const clsObserver = new PerformanceObserver((list) => {
let clsValue = 0;
for (const entry of list.getEntries()) {
if ('value' in entry && !(entry as LayoutShift).hadRecentInput) {
clsValue += (entry as LayoutShift).value;
}
}
if (clsValue > 0) {
trackPerformance('CLS', clsValue * 1000);
}
});
try {
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
fidObserver.observe({ type: 'first-input', buffered: true });
clsObserver.observe({ type: 'layout-shift', buffered: true });
} catch {
// Observer not supported
}
return () => {
lcpObserver.disconnect();
fidObserver.disconnect();
clsObserver.disconnect();
};
}
return undefined;
};
const cleanup = reportWebVitals();
return cleanup;
}, []);
return null;
}
interface PerformanceEventTiming extends PerformanceEntry {
processingStart: number;
startTime: number;
}
interface LayoutShift extends PerformanceEntry {
value: number;
hadRecentInput: boolean;
}
+1 -1
View File
@@ -15,7 +15,7 @@ interface BreadcrumbProps {
export function Breadcrumb({ items }: BreadcrumbProps) {
return (
<nav aria-label="breadcrumb" className="flex items-center space-x-2 text-sm text-[#5C5C5C] py-4">
<StaticLink href="/" className="flex items-center hover:text-[#C41E3A] transition-colors">
<StaticLink href="/" className="flex items-center hover:text-[#C41E3A] transition-colors" aria-label="返回首页">
<Home className="w-4 h-4" />
</StaticLink>
{items.map((item, index) => (
+14 -4
View File
@@ -3,17 +3,27 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
jest.mock('next/link', () => {
return ({ children, href, ...props }: any) => (
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
<a href={href} {...props}>
{children}
</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
jest.mock('next/image', () => {
return ({ src, alt, width, height, className, ...props }: any) => (
const MockImage = ({ src, alt, width, height, className, ...props }: {
src: string;
alt: string;
width: number;
height: number;
className?: string;
}) => (
<img src={src} alt={alt} width={width} height={height} className={className} {...props} />
);
MockImage.displayName = 'MockImage';
return MockImage;
});
jest.mock('lucide-react', () => ({
@@ -94,9 +104,9 @@ describe('Footer', () => {
it('should render service links', () => {
render(<Footer />);
expect(screen.getByText('软件开发')).toBeInTheDocument();
expect(screen.getByText('云服务')).toBeInTheDocument();
expect(screen.getByText('数据分析')).toBeInTheDocument();
expect(screen.getByText('信息安全')).toBeInTheDocument();
expect(screen.getByText('技术咨询')).toBeInTheDocument();
expect(screen.getByText('解决方案')).toBeInTheDocument();
});
it('should render contact details', () => {
+4 -3
View File
@@ -13,10 +13,11 @@ export function Footer() {
<Image
src="/logo.svg"
alt={COMPANY_INFO.name}
width={48}
width={192}
height={48}
className="h-12 w-auto transition-transform duration-200 hover:scale-105"
loading="lazy"
className="transition-transform duration-200 hover:scale-105"
loading="eager"
priority
/>
</div>
<p className="text-[#5C5C5C] text-sm leading-relaxed mb-6">
+12 -4
View File
@@ -10,6 +10,12 @@ import { Button } from '@/components/ui/button';
import { COMPANY_INFO, NAVIGATION, type NavigationItem } from '@/lib/constants';
import { useFocusTrap } from '@/hooks/use-focus-trap';
declare global {
interface Window {
__isProgrammaticScroll?: boolean;
}
}
function HeaderContent() {
const [isOpen, setIsOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
@@ -34,9 +40,9 @@ function HeaderContent() {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
if (pathname === '/' && !isScrollingRef.current) {
if (pathname === '/' && !isScrollingRef.current && !window.__isProgrammaticScroll) {
const scrollPosition = window.scrollY + 100;
const sections = ['home', 'services', 'products', 'cases', 'about', 'news'];
const sections = ['home', 'services', 'solutions', 'products', 'cases', 'about', 'team', 'news'];
for (const sectionId of sections) {
const element = document.getElementById(sectionId);
@@ -158,13 +164,15 @@ function HeaderContent() {
<StaticLink
href="/"
className="flex items-center group"
aria-label="返回首页"
>
<Image
src="/logo.svg"
alt={COMPANY_INFO.name}
width={32}
width={128}
height={32}
className="h-8 w-auto transition-transform duration-200 group-hover:scale-105"
className="transition-transform duration-200 group-hover:scale-105"
loading="eager"
priority
/>
</StaticLink>
+36 -8
View File
@@ -1,7 +1,8 @@
'use client';
import { useState, useLayoutEffect, useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { usePathname } from 'next/navigation';
import { usePathname, useSearchParams } from 'next/navigation';
import { Home, Briefcase, Package, FileText, User } from 'lucide-react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
@@ -11,18 +12,45 @@ const tabs = [
{ id: 'services', label: '服务', href: '/#services', icon: Briefcase },
{ id: 'products', label: '产品', href: '/#products', icon: Package },
{ id: 'news', label: '新闻', href: '/#news', icon: FileText },
{ id: 'contact', label: '联系', href: '/#contact', icon: User },
{ id: 'contact', label: '联系', href: '/contact', icon: User },
];
export function MobileTabBar() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [hash, setHash] = useState('');
const isInitializedRef = useRef(false);
const isActive = (href: string) => {
if (href === '/') {
return pathname === '/';
useLayoutEffect(() => {
if (!isInitializedRef.current) {
isInitializedRef.current = true;
setHash(window.location.hash.slice(1));
}
const basePath = href.split('#')[0] || href;
return pathname.startsWith(basePath);
const handleHashChange = () => {
setHash(window.location.hash.slice(1));
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
const isActive = (_href: string, id: string) => {
if (id === 'contact') {
return pathname === '/contact';
}
if (pathname === '/') {
const section = searchParams.get('section');
const currentSection = section || hash;
if (id === 'home') {
return !currentSection || currentSection === 'home';
}
return currentSection === id;
}
return false;
};
return (
@@ -30,7 +58,7 @@ export function MobileTabBar() {
<div className="flex items-center justify-around h-16">
{tabs.map((tab) => {
const Icon = tab.icon;
const active = isActive(tab.href);
const active = isActive(tab.href, tab.id);
return (
<StaticLink
+1 -1
View File
@@ -27,7 +27,7 @@ export function AboutSection() {
>
<div className="text-center mb-12">
<h2 id="about-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6">
<span className="tracking-tight font-calligraphy text-[#C41E3A]" style={{ fontWeight: 'normal', WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale', textRendering: 'optimizeLegibility' }}>{COMPANY_INFO.shortName}</span>
<span className="tracking-tight font-brand text-[#C41E3A]" style={{ fontWeight: 'normal', WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale', textRendering: 'optimizeLegibility' }}>{COMPANY_INFO.shortName}</span>
</h2>
<p className="text-lg text-[#5C5C5C] mb-8">
{COMPANY_INFO.slogan}
+2 -2
View File
@@ -28,7 +28,7 @@ export function CasesSection() {
className="text-center max-w-3xl mx-auto mb-16"
>
<h2 id="cases-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A]"></span>
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-lg text-[#5C5C5C] max-w-2xl mx-auto">
@@ -63,7 +63,7 @@ export function CasesSection() {
<CardContent className="p-6">
<div className="flex items-center gap-2 mb-3">
<Building2 className="w-4 h-4 text-[#C41E3A]" />
<span className="text-sm text-[#5C5C5C]"></span>
<span className="text-sm text-[#5C5C5C]">{caseItem.client}</span>
</div>
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-3 group-hover:text-[#C41E3A] transition-colors">
{caseItem.title}
@@ -1,333 +0,0 @@
import { describe, it, expect, jest, beforeAll, afterEach } from '@jest/globals';
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
interface MotionComponentProps {
children?: React.ReactNode;
className?: string;
disabled?: boolean;
[key: string]: unknown;
}
interface InputComponentProps {
label?: string;
id?: string;
placeholder?: string;
required?: boolean;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur?: () => void;
error?: string;
rows?: number;
[key: string]: unknown;
}
interface ToastComponentProps {
message?: string;
type?: string;
onClose?: () => void;
[key: string]: unknown;
}
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true }),
} as Response)
);
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: MotionComponentProps) => (
<div className={className} {...props}>
{children}
</div>
),
section: ({ children, className, ...props }: MotionComponentProps) => (
<section className={className} {...props}>
{children}
</section>
),
},
AnimatePresence: ({ children }: MotionComponentProps) => <>{children}</>,
}));
jest.mock('lucide-react', () => ({
Mail: () => <span data-testid="mail-icon" />,
Phone: () => <span data-testid="phone-icon" />,
MapPin: () => <span data-testid="map-pin-icon" />,
Send: () => <span data-testid="send-icon" />,
Loader2: () => <span data-testid="loader-icon" />,
Clock: () => <span data-testid="clock-icon" />,
HeadphonesIcon: () => <span data-testid="headphones-icon" />,
CheckCircle2: () => <span data-testid="check-circle-icon" />,
RefreshCw: () => <span data-testid="refresh-cw-icon" />,
}));
jest.mock('@/lib/sanitize', () => ({
sanitizeInput: (value: string) => value,
}));
jest.mock('@/lib/csrf', () => ({
generateCSRFToken: jest.fn(() => 'test-csrf-token'),
setCSRFTokenToStorage: jest.fn(),
getCSRFTokenFromStorage: jest.fn(() => 'test-csrf-token'),
}));
const { generateCSRFToken, setCSRFTokenToStorage } = jest.requireMock('@/lib/csrf') as {
generateCSRFToken: jest.Mock;
setCSRFTokenToStorage: jest.Mock;
};
jest.mock('@/lib/security/captcha', () => ({
generateCaptcha: jest.fn(() => ({
question: '1 + 1 = ?',
answer: 2,
hash: 'test-hash',
timestamp: Date.now(),
})),
}));
const { generateCaptcha } = jest.requireMock('@/lib/security/captcha') as {
generateCaptcha: jest.Mock;
};
jest.mock('@/lib/constants', () => ({
COMPANY_INFO: {
name: '四川睿新致远科技有限公司',
email: 'contact@novalon.cn',
phone: '028-88888888',
address: '中国四川省成都市龙泉驿区幸福路12号',
},
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, className, disabled, ...props }: MotionComponentProps) => (
<button className={className} disabled={disabled} {...props}>
{children}
</button>
),
}));
jest.mock('@/components/ui/input', () => ({
Input: ({ label, id, placeholder, required, value, onChange, onBlur, error, ...props }: InputComponentProps) => (
<div>
<label htmlFor={id}>{label}{required && '*'}</label>
<input
id={id}
placeholder={placeholder}
value={value}
onChange={onChange}
onBlur={onBlur}
data-testid={`${id}-input`}
{...props}
/>
{error && <span data-testid={`${id}-error`}>{error}</span>}
</div>
),
}));
jest.mock('@/components/ui/textarea', () => ({
Textarea: ({ label, id, placeholder, rows, required, value, onChange, onBlur, error, ...props }: InputComponentProps) => (
<div>
<label htmlFor={id}>{label}{required && '*'}</label>
<textarea
id={id}
placeholder={placeholder}
rows={rows}
value={value}
onChange={onChange}
onBlur={onBlur}
data-testid={`${id}-input`}
{...props}
/>
{error && <span data-testid={`${id}-error`}>{error}</span>}
</div>
),
}));
jest.mock('@/components/ui/toast', () => ({
Toast: ({ message, type, onClose, ...props }: ToastComponentProps) => (
<div data-testid="toast-notification" data-type={type} {...props}>
{message}
<button onClick={onClose}></button>
</div>
),
}));
import { ContactSection } from './contact-section';
describe('ContactSection', () => {
beforeAll(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render contact section', () => {
render(<ContactSection />);
const section = document.querySelector('section#contact');
expect(section).toBeInTheDocument();
});
it('should render contact form', () => {
render(<ContactSection />);
expect(screen.getByTestId('name-input')).toBeInTheDocument();
expect(screen.getByTestId('phone-input')).toBeInTheDocument();
expect(screen.getByTestId('email-input')).toBeInTheDocument();
expect(screen.getByTestId('message-input')).toBeInTheDocument();
expect(screen.getByTestId('captcha-question')).toBeInTheDocument();
expect(screen.getByTestId('captcha-input')).toBeInTheDocument();
});
it('should render submit button', () => {
render(<ContactSection />);
expect(screen.getByRole('button', { name: /发送消息/ })).toBeInTheDocument();
});
it('should render company contact information', () => {
render(<ContactSection />);
expect(screen.getByText('contact@novalon.cn')).toBeInTheDocument();
expect(screen.getByText('中国四川省成都市龙泉驿区幸福路12号')).toBeInTheDocument();
});
it('should render work hours card', () => {
render(<ContactSection />);
expect(screen.getByTestId('work-hours-card')).toBeInTheDocument();
});
});
describe('Form Validation', () => {
it('should show error for invalid name', async () => {
render(<ContactSection />);
const nameInput = screen.getByTestId('name-input');
await userEvent.type(nameInput, '张');
fireEvent.blur(nameInput);
await waitFor(() => {
expect(screen.getByTestId('name-error')).toBeInTheDocument();
});
});
it('should show error for invalid phone', async () => {
render(<ContactSection />);
const phoneInput = screen.getByTestId('phone-input');
await userEvent.type(phoneInput, '1234567890');
fireEvent.blur(phoneInput);
await waitFor(() => {
expect(screen.getByTestId('phone-error')).toBeInTheDocument();
});
});
it('should show error for invalid email', async () => {
render(<ContactSection />);
const emailInput = screen.getByTestId('email-input');
await userEvent.type(emailInput, 'invalid-email');
fireEvent.blur(emailInput);
await waitFor(() => {
expect(screen.getByTestId('email-error')).toBeInTheDocument();
});
});
it('should show error for short message', async () => {
render(<ContactSection />);
const messageInput = screen.getByTestId('message-input');
await userEvent.type(messageInput, '短留言');
fireEvent.blur(messageInput);
await waitFor(() => {
expect(screen.getByTestId('message-error')).toBeInTheDocument();
});
});
});
describe('Accessibility', () => {
it('should have proper form labels', () => {
render(<ContactSection />);
expect(screen.getByLabelText(/姓名/)).toBeInTheDocument();
expect(screen.getByLabelText(/电话/)).toBeInTheDocument();
expect(screen.getByLabelText(/邮箱/)).toBeInTheDocument();
expect(screen.getByLabelText(/留言/)).toBeInTheDocument();
expect(screen.getByLabelText(/验证码/)).toBeInTheDocument();
});
it('should have proper ARIA attributes', () => {
render(<ContactSection />);
const section = document.querySelector('section#contact');
expect(section).toHaveAttribute('role', 'region');
expect(section).toHaveAttribute('aria-labelledby', 'contact-heading');
});
});
describe('CSRF Protection', () => {
it('should generate CSRF token on mount', () => {
render(<ContactSection />);
expect(generateCSRFToken).toHaveBeenCalled();
expect(setCSRFTokenToStorage).toHaveBeenCalledWith('test-csrf-token');
});
});
describe('Captcha Functionality', () => {
it('should render captcha question', () => {
render(<ContactSection />);
expect(screen.getByTestId('captcha-question')).toBeInTheDocument();
expect(screen.getByText('1 + 1 = ?')).toBeInTheDocument();
});
it('should render captcha input', () => {
render(<ContactSection />);
expect(screen.getByTestId('captcha-input')).toBeInTheDocument();
});
it('should render refresh captcha button', () => {
render(<ContactSection />);
expect(screen.getByTestId('refresh-captcha')).toBeInTheDocument();
});
it('should refresh captcha when refresh button is clicked', async () => {
render(<ContactSection />);
const refreshButton = screen.getByTestId('refresh-captcha');
await userEvent.click(refreshButton);
expect(generateCaptcha).toHaveBeenCalled();
});
it.skip('should show error for invalid captcha', async () => {
render(<ContactSection />);
const nameInput = screen.getByTestId('name-input');
const phoneInput = screen.getByTestId('phone-input');
const emailInput = screen.getByTestId('email-input');
const messageInput = screen.getByTestId('message-input');
const captchaInput = screen.getByTestId('captcha-input');
const submitButton = screen.getByRole('button', { name: /发送消息/ });
await userEvent.type(nameInput, '张三');
await userEvent.type(phoneInput, '13800138000');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(messageInput, '这是一条测试留言内容');
captchaInput.focus();
fireEvent.change(captchaInput, { target: { value: '3' } });
await userEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByTestId('captcha-error')).toBeInTheDocument();
}, { timeout: 3000 });
});
});
});
+15 -4
View File
@@ -8,6 +8,7 @@ import { MagneticButton, BlurReveal, CounterWithEffect } from '@/lib/animations'
import { COMPANY_INFO, STATS } from '@/lib/constants';
import { ArrowRight, Shield, Zap, Award } from 'lucide-react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { trackButtonClick, trackServiceInterest } from '@/lib/analytics';
interface HeroContentProps {
isVisible: boolean;
@@ -59,7 +60,7 @@ export function HeroTitle({ isVisible }: HeroContentProps) {
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.6, delay: 0.1 }}
className="text-5xl sm:text-6xl lg:text-7xl tracking-tight mb-6 font-calligraphy"
className="text-5xl sm:text-6xl lg:text-7xl tracking-tight mb-6 font-brand"
style={{
fontWeight: 'normal',
WebkitFontSmoothing: 'antialiased',
@@ -94,6 +95,16 @@ export function HeroDescription(_props: HeroContentProps) {
export function HeroButtons({ isVisible }: HeroContentProps) {
const shouldReduceMotion = useReducedMotion();
const handleConsultClick = () => {
trackButtonClick('consult_now', 'hero_section');
trackServiceInterest('consultation');
};
const handleLearnMoreClick = () => {
trackButtonClick('learn_more', 'hero_section');
scrollTo('about');
};
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
@@ -102,7 +113,7 @@ export function HeroButtons({ isVisible }: HeroContentProps) {
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8"
>
<MagneticButton strength={0.4}>
<StaticLink href="/contact">
<StaticLink href="/contact" onClick={handleConsultClick}>
<SealButton size="lg" className="min-w-45">
<ArrowRight className="w-4 h-4 ml-2" />
@@ -113,7 +124,7 @@ export function HeroButtons({ isVisible }: HeroContentProps) {
<RippleButton
size="lg"
variant="outline"
onClick={() => scrollTo('about')}
onClick={handleLearnMoreClick}
onKeyDown={(e) => handleKeyDown(e, 'about')}
className="min-w-45"
>
@@ -152,7 +163,7 @@ export function HeroFeatures({ isVisible }: HeroContentProps) {
}
export function HeroStats() {
const [statsVisible, setStatsVisible] = useState(false);
const [statsVisible, setStatsVisible] = useState(true);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
+47 -31
View File
@@ -4,36 +4,38 @@ import '@testing-library/jest-dom';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: any) => (
div: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<div className={className} {...props}>
{children}
</div>
),
section: ({ children, className, ...props }: any) => (
section: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<section className={className} {...props}>
{children}
</section>
),
span: ({ children, className, ...props }: any) => (
span: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<span className={className} {...props}>
{children}
</span>
),
h1: ({ children, className, ...props }: any) => (
h1: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<h1 className={className} {...props}>
{children}
</h1>
),
},
AnimatePresence: ({ children }: any) => <>{children}</>,
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
jest.mock('next/link', () => {
return ({ children, href, ...props }: any) => (
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
<a href={href} {...props}>
{children}
</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
jest.mock('lucide-react', () => ({
@@ -43,25 +45,22 @@ jest.mock('lucide-react', () => ({
Award: () => <span data-testid="award-icon" />,
}));
jest.mock('next/dynamic', () => {
const React = require('react');
return {
__esModule: true,
default: (importFn: any, options: any) => {
return React.forwardRef((props: any, ref: any) => {
return null;
});
},
};
});
jest.mock('next/dynamic', () => ({
__esModule: true,
default: () => {
const MockDynamic = () => null;
MockDynamic.displayName = 'MockDynamic';
return MockDynamic;
},
}));
jest.mock('@/components/ui/ripple-button', () => ({
RippleButton: ({ children, className, ...props }: any) => (
RippleButton: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<button className={className} {...props}>
{children}
</button>
),
SealButton: ({ children, className, ...props }: any) => (
SealButton: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<button className={className} {...props}>
{children}
</button>
@@ -69,16 +68,16 @@ jest.mock('@/components/ui/ripple-button', () => ({
}));
jest.mock('@/lib/animations', () => ({
GradientText: ({ children, className }: any) => (
GradientText: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<span className={className}>{children}</span>
),
MagneticButton: ({ children, className }: any) => (
MagneticButton: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<button className={className}>{children}</button>
),
BlurReveal: ({ children, className }: any) => (
BlurReveal: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
CounterWithEffect: ({ end, suffix, className }: any) => (
CounterWithEffect: ({ end, suffix, className }: { end: number; suffix?: string; className?: string }) => (
<span className={className}>{end}{suffix || ''}</span>
),
}));
@@ -93,11 +92,28 @@ jest.mock('@/lib/constants', () => ({
{ value: '10+', label: '企业客户' },
{ value: '20+', label: '成功案例' },
{ value: '30+', label: '项目交付' },
{ value: '12+', label: '年行业经验' },
{ value: '12+', label: '年团队经验' },
],
}));
jest.mock('./hero-section-atoms', () => ({
HeroContent: () => <div></div>,
HeroTitle: () => <h1></h1>,
HeroDescription: () => <p></p>,
HeroButtons: () => <div><button></button><button></button></div>,
HeroFeatures: () => <div><span></span><span>便</span><span></span></div>,
HeroStats: () => (
<div data-testid="hero-stats">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
),
}));
import { HeroSection } from './hero-section';
import { HeroStats } from './hero-section-atoms';
describe('HeroSection', () => {
beforeAll(() => {
@@ -106,18 +122,18 @@ describe('HeroSection', () => {
describe('Rendering', () => {
it('should render hero section', () => {
render(<HeroSection />);
render(<HeroSection heroStats={<HeroStats />} />);
const section = document.querySelector('section#home');
expect(section).toBeInTheDocument();
});
it('should render company name', () => {
render(<HeroSection />);
render(<HeroSection heroStats={<HeroStats />} />);
expect(screen.getByText('睿新致遠')).toBeInTheDocument();
});
it('should render features', () => {
render(<HeroSection />);
render(<HeroSection heroStats={<HeroStats />} />);
expect(screen.getByText('安全可靠')).toBeInTheDocument();
expect(screen.getByText('高效便捷')).toBeInTheDocument();
expect(screen.getByText('专业服务')).toBeInTheDocument();
@@ -126,23 +142,23 @@ describe('HeroSection', () => {
describe('Statistics', () => {
it('should render statistics section', () => {
render(<HeroSection />);
render(<HeroSection heroStats={<HeroStats />} />);
expect(screen.getByText('企业客户')).toBeInTheDocument();
expect(screen.getByText('成功案例')).toBeInTheDocument();
expect(screen.getByText('项目交付')).toBeInTheDocument();
expect(screen.getByText('年行业经验')).toBeInTheDocument();
expect(screen.getByText('年团队经验')).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have proper ARIA labels', () => {
render(<HeroSection />);
render(<HeroSection heroStats={<HeroStats />} />);
const section = document.querySelector('section#home');
expect(section).toHaveAttribute('aria-labelledby', 'hero-heading');
});
it('should have accessible buttons', () => {
render(<HeroSection />);
render(<HeroSection heroStats={<HeroStats />} />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
+4 -3
View File
@@ -2,7 +2,8 @@
import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { HeroContent, HeroTitle, HeroDescription, HeroButtons, HeroFeatures, HeroStats } from './hero-section-atoms';
import { HeroContent, HeroTitle, HeroDescription, HeroButtons, HeroFeatures } from './hero-section-atoms';
import type { ReactNode } from 'react';
const InkBackground = dynamic(
() => import('@/components/ui/ink-decoration').then(mod => ({ default: mod.InkBackground })),
@@ -19,7 +20,7 @@ const SubtleDots = dynamic(
{ ssr: false }
);
export function HeroSection() {
export function HeroSection({ heroStats }: { heroStats: ReactNode }) {
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
@@ -64,7 +65,7 @@ export function HeroSection() {
<HeroDescription isVisible={isVisible} />
<HeroButtons isVisible={isVisible} />
<HeroFeatures isVisible={isVisible} />
<HeroStats />
{heroStats}
</div>
</div>
</section>
@@ -0,0 +1,23 @@
import { STATS } from '@/lib/constants';
export function HeroStatsSSR() {
return (
<div className="pt-16 border-t border-[#E2E8F0]">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12">
{STATS.map((stat) => (
<div
key={stat.label}
className="group cursor-default text-center"
>
<div className="text-4xl sm:text-5xl font-bold text-[#C41E3A] mb-3">
{stat.value}
</div>
<div className="text-sm text-[#718096] group-hover:text-[#4A5568] transition-colors">
{stat.label}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,106 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { ArrowRight, Lightbulb, Cpu, Users } from 'lucide-react';
const SOLUTIONS_OVERVIEW = [
{
icon: Lightbulb,
title: '参谋伙伴',
subtitle: '数字化转型咨询',
description: '帮您看清前路,迈对第一步。用行业智慧洞察趋势,用理性分析避开陷阱。',
points: ['行业趋势洞察报告', '成熟度评估', '实施路径规划'],
},
{
icon: Cpu,
title: '技术伙伴',
subtitle: '信息技术解决方案',
description: '让技术真正为业务服务。不追逐"最火"的技术,只选择"最对"的技术。',
points: ['业务场景调研', '技术方案定制', '敏捷交付迭代'],
},
{
icon: Users,
title: '同行伙伴',
subtitle: '长期陪跑服务',
description: '交付只是开始,陪伴才是常态。项目上线那天,是我们真正成为伙伴的开始。',
points: ['专属客户成功经理', '季度业务复盘', '7×24小时响应'],
},
];
export function HomeSolutionsSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<section id="solutions" role="region" aria-labelledby="solutions-heading" className="py-24 bg-[#F5F7FA] relative overflow-hidden" ref={ref}>
<div className="absolute top-1/2 right-0 w-[400px] h-[400px] bg-[rgba(196,30,58,0.02)] rounded-full blur-3xl" />
<div className="container-wide relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
className="text-center max-w-3xl mx-auto mb-16"
>
<h2 id="solutions-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-lg text-[#5C5C5C]">
</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{SOLUTIONS_OVERVIEW.map((item, idx) => {
const Icon = item.icon;
return (
<motion.div
key={item.title}
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.1 + idx * 0.15 }}
>
<div className="bg-white rounded-2xl p-8 border border-[#E5E5E5] hover:border-[#C41E3A]/30 hover:shadow-lg transition-all duration-300 h-full flex flex-col">
<div className="w-14 h-14 bg-[#C41E3A] rounded-2xl flex items-center justify-center mb-6">
<Icon className="w-7 h-7 text-white" />
</div>
<h3 className="text-xl font-bold text-[#1C1C1C] mb-1">{item.title}</h3>
<p className="text-sm text-[#C41E3A] font-medium mb-3">{item.subtitle}</p>
<p className="text-sm text-[#5C5C5C] leading-relaxed mb-6 flex-1">{item.description}</p>
<ul className="space-y-2">
{item.points.map((point, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-[#1C1C1C]">
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-1.5 shrink-0" />
{point}
</li>
))}
</ul>
</div>
</motion.div>
);
})}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.5 }}
className="mt-12 text-center"
>
<Button
size="lg"
asChild
>
<StaticLink href="/solutions">
<ArrowRight className="ml-2 w-4 h-4" />
</StaticLink>
</Button>
</motion.div>
</div>
</section>
);
}
@@ -1,86 +0,0 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { InsightsSection } from './insights-section';
jest.mock('@/components/ui/insight-card', () => ({
InsightCard: ({ title, category }: any) => (
<div data-testid="insight-card">
<div>{title}</div>
<div>{category}</div>
</div>
),
}));
describe('InsightsSection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render insights section', () => {
render(<InsightsSection />);
const section = document.querySelector('section#insights');
expect(section).toBeInTheDocument();
});
it('should render section heading', () => {
render(<InsightsSection />);
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
});
it('should render section description', () => {
render(<InsightsSection />);
expect(screen.getByText(/分享前沿技术趋势/)).toBeInTheDocument();
});
it('should render insight cards', () => {
render(<InsightsSection />);
const cards = screen.getAllByTestId('insight-card');
expect(cards.length).toBeGreaterThan(0);
});
it('should render insight titles', () => {
render(<InsightsSection />);
expect(screen.getByText('2025年技术趋势:AI驱动的数字化转型')).toBeInTheDocument();
});
it('should render insight categories', () => {
render(<InsightsSection />);
expect(screen.getByText('技术趋势')).toBeInTheDocument();
});
it('should render view all button', () => {
render(<InsightsSection />);
expect(screen.getByText('查看全部洞察')).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have section id', () => {
render(<InsightsSection />);
const section = document.querySelector('section#insights');
expect(section).toBeInTheDocument();
});
});
describe('Styling', () => {
it('should have correct background', () => {
render(<InsightsSection />);
const section = document.querySelector('section.bg-\\[\\#FAFAFA\\]');
expect(section).toBeInTheDocument();
});
it('should have container', () => {
render(<InsightsSection />);
const container = document.querySelector('.container-wide');
expect(container).toBeInTheDocument();
});
it('should have grid layout', () => {
render(<InsightsSection />);
const grid = document.querySelector('.grid');
expect(grid).toBeInTheDocument();
});
});
});
@@ -1,129 +0,0 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { InsightCard } from '@/components/ui/insight-card';
import { Button } from '@/components/ui/button';
import { ArrowRight } from 'lucide-react';
interface Insight {
id: string;
title: string;
excerpt: string;
category: string;
readTime: string;
publishedAt: string;
imageUrl?: string;
href: string;
featured?: boolean;
}
const MOCK_INSIGHTS: Insight[] = [
{
id: '1',
title: '2025年技术趋势:AI驱动的数字化转型',
excerpt: '深入探讨人工智能如何重塑企业技术架构,以及如何把握AI时代的机遇',
category: '技术趋势',
readTime: '8 分钟',
publishedAt: '2026-02-10',
imageUrl: '/insights/ai-trend.jpg',
href: '/insights/ai-trend-2025',
featured: true,
},
{
id: '2',
title: '微服务架构最佳实践指南',
excerpt: '从单体应用到微服务的演进之路,包含实战案例和避坑指南',
category: '架构设计',
readTime: '12 分钟',
publishedAt: '2026-02-08',
imageUrl: '/insights/microservices.jpg',
href: '/insights/microservices-best-practices',
},
{
id: '3',
title: '云原生技术栈选型指南',
excerpt: 'Kubernetes、Docker、Service Mesh等技术栈的深度对比与选型建议',
category: '云原生',
readTime: '10 分钟',
publishedAt: '2026-02-05',
imageUrl: '/insights/cloud-native.jpg',
href: '/insights/cloud-native-stack-guide',
},
];
export function InsightsSection() {
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.1 }
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => observer.disconnect();
}, []);
return (
<section
id="insights"
ref={sectionRef}
className="py-24 bg-[#FAFAFA]"
>
<div className="container-wide">
<div
className={`
text-center mb-16
opacity-0 translate-y-4
${isVisible ? 'animate-fade-in-up' : ''}
`}
>
<h2 className="text-3xl sm:text-4xl font-semibold text-[#171717] mb-4">
</h2>
<p className="text-lg text-[#737373] max-w-2xl mx-auto">
沿
</p>
</div>
<div
className={`
grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12
opacity-0 translate-y-4
${isVisible ? 'animate-fade-in-up stagger-1' : ''}
`}
>
{MOCK_INSIGHTS.map((insight) => (
<InsightCard key={insight.id} {...insight} />
))}
</div>
<div
className={`
text-center
opacity-0 translate-y-4
${isVisible ? 'animate-fade-in-up stagger-2' : ''}
`}
>
<Button
variant="secondary"
size="lg"
className="group"
onClick={() => window.location.href = '/insights'}
>
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</div>
</div>
</section>
);
}
@@ -0,0 +1,83 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { METHODOLOGY } from '@/lib/constants/methodology';
import { CheckCircle2 } from 'lucide-react';
export function MethodologySection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<section id="methodology" role="region" aria-labelledby="methodology-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
<div className="container-wide relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
className="text-center max-w-3xl mx-auto mb-16"
>
<h2 id="methodology-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-lg text-[#5C5C5C]">
</p>
</motion.div>
<div className="relative">
{/* 连接线 */}
<div className="hidden lg:block absolute top-24 left-[12.5%] right-[12.5%] h-0.5 bg-gradient-to-r from-[#C41E3A]/20 via-[#C41E3A]/40 to-[#C41E3A]/20" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{METHODOLOGY.map((phase, idx) => (
<motion.div
key={phase.id}
initial={{ opacity: 0, y: 30 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.2 + idx * 0.15 }}
>
<div className="relative bg-[#FFFBF5] rounded-2xl p-8 border border-[#E5E5E5] hover:border-[#C41E3A]/30 hover:shadow-lg transition-all duration-300 h-full">
{/* 阶段编号 */}
<div className="w-12 h-12 bg-[#C41E3A] rounded-full flex items-center justify-center mb-6 text-white font-bold text-xl">
{phase.number}
</div>
<h3 className="text-xl font-bold text-[#1C1C1C] mb-1">{phase.title}</h3>
<p className="text-sm text-[#C41E3A] font-medium mb-3">{phase.subtitle}</p>
<p className="text-sm text-[#5C5C5C] leading-relaxed mb-6">{phase.description}</p>
<div className="mb-4">
<p className="text-xs font-semibold text-[#1C1C1C] mb-2 uppercase tracking-wide"></p>
<ul className="space-y-1.5">
{phase.activities.map((activity, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-[#3D3D3D]">
<CheckCircle2 className="w-3.5 h-3.5 text-[#C41E3A] mt-0.5 shrink-0" />
{activity}
</li>
))}
</ul>
</div>
<div>
<p className="text-xs font-semibold text-[#1C1C1C] mb-2 uppercase tracking-wide"></p>
<ul className="space-y-1.5">
{phase.deliverables.map((deliverable, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-[#5C5C5C]">
<span className="w-1.5 h-1.5 bg-[#C41E3A]/60 rounded-full mt-1.5 shrink-0" />
{deliverable}
</li>
))}
</ul>
</div>
</div>
</motion.div>
))}
</div>
</div>
</div>
</section>
);
}
@@ -1,149 +0,0 @@
import { describe, it, expect, beforeEach } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { NewsSection } from './news-section';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
useInView: () => true,
}));
jest.mock('next/link', () => {
return ({ children, href }: any) => <a href={href}>{children}</a>;
});
jest.mock('@/hooks/use-news', () => ({
useNews: () => ({
news: [
{
id: '1',
title: '测试新闻1',
excerpt: '这是测试新闻1的摘要',
date: '2024-01-01',
category: '公司新闻',
slug: 'test-news-1',
},
{
id: '2',
title: '测试新闻2',
excerpt: '这是测试新闻2的摘要',
date: '2024-01-02',
category: '行业资讯',
slug: 'test-news-2',
},
],
loading: false,
error: null,
}),
}));
describe('NewsSection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render news section', () => {
render(<NewsSection />);
const section = document.querySelector('section#news');
expect(section).toBeInTheDocument();
});
it('should render section heading', () => {
render(<NewsSection />);
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
});
it('should render section description', () => {
render(<NewsSection />);
expect(screen.getByText(/了解公司最新动态/)).toBeInTheDocument();
});
});
describe('News Cards', () => {
it('should render news cards', () => {
render(<NewsSection />);
const cards = document.querySelectorAll('[class*="flex-col"]');
expect(cards.length).toBeGreaterThan(0);
});
it('should display news in grid layout', () => {
const { container } = render(<NewsSection />);
const grid = container.querySelector('.grid-cols-1');
expect(grid).toBeInTheDocument();
});
it('should render news categories', () => {
render(<NewsSection />);
const categories = document.querySelectorAll('[class*="rounded-full"]');
expect(categories.length).toBeGreaterThan(0);
});
it('should render news dates', () => {
render(<NewsSection />);
const dates = document.querySelectorAll('[class*="text-sm"]');
expect(dates.length).toBeGreaterThan(0);
});
});
describe('Call to Action', () => {
it('should render view all news link', () => {
render(<NewsSection />);
expect(screen.getByRole('link', { name: /查看全部新闻/ })).toBeInTheDocument();
});
it('should link to news page', () => {
render(<NewsSection />);
const link = screen.getByRole('link', { name: /查看全部新闻/ });
expect(link).toHaveAttribute('href', '/news');
});
it('should render read more links', () => {
render(<NewsSection />);
const readMoreLinks = screen.getAllByText(/阅读更多/);
expect(readMoreLinks.length).toBeGreaterThan(0);
});
});
describe('Accessibility', () => {
it('should have region role', () => {
render(<NewsSection />);
const section = screen.getByRole('region');
expect(section).toBeInTheDocument();
});
it('should have aria-labelledby attribute', () => {
render(<NewsSection />);
const section = document.querySelector('section#news');
expect(section).toHaveAttribute('aria-labelledby', 'news-heading');
});
it('should have accessible heading', () => {
render(<NewsSection />);
const heading = screen.getByRole('heading', { level: 2 });
expect(heading).toHaveAttribute('id', 'news-heading');
});
});
describe('Styling', () => {
it('should have background color', () => {
render(<NewsSection />);
const section = document.querySelector('section#news');
expect(section).toHaveClass('bg-[#F5F5F5]');
});
it('should have proper padding', () => {
render(<NewsSection />);
const section = document.querySelector('section#news');
expect(section).toHaveClass('py-24');
});
it('should have container styling', () => {
const { container } = render(<NewsSection />);
const containerDiv = container.querySelector('.container-custom');
expect(containerDiv).toBeInTheDocument();
});
});
});
+1 -1
View File
@@ -26,7 +26,7 @@ export function NewsSection() {
className="text-center max-w-3xl mx-auto mb-16"
>
<h2 id="news-heading" className="text-3xl sm:text-4xl lg:text-5xl font-bold text-[#1C1C1C] mb-6">
<span className="text-[#C41E3A]"></span>
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-lg text-[#5C5C5C]">
@@ -1,155 +0,0 @@
import { describe, it, expect, beforeEach } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ProductsSection } from './products-section';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
useInView: () => true,
}));
jest.mock('next/link', () => {
return ({ children, href }: any) => <a href={href}>{children}</a>;
});
jest.mock('@/hooks/use-products', () => ({
useProducts: () => ({
products: [
{
id: '1',
title: '测试产品1',
description: '这是测试产品1的描述',
image: '/test-image-1.jpg',
category: '企业服务',
features: ['特性1', '特性2'],
benefits: ['价值1', '价值2'],
},
{
id: '2',
title: '测试产品2',
description: '这是测试产品2的描述',
image: '/test-image-2.jpg',
category: '解决方案',
features: ['特性3', '特性4'],
benefits: ['价值3', '价值4'],
},
],
loading: false,
error: null,
}),
}));
describe('ProductsSection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render products section', () => {
render(<ProductsSection />);
const section = document.querySelector('section#products');
expect(section).toBeInTheDocument();
});
it('should render section heading', () => {
render(<ProductsSection />);
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
});
it('should render section description', () => {
render(<ProductsSection />);
expect(screen.getByText(/自主研发的企业级产品/)).toBeInTheDocument();
});
});
describe('Product Cards', () => {
it('should render product cards', () => {
render(<ProductsSection />);
const cards = document.querySelectorAll('[class*="flex-col"]');
expect(cards.length).toBeGreaterThan(0);
});
it('should display products in grid layout', () => {
const { container } = render(<ProductsSection />);
const grid = container.querySelector('.grid-cols-1');
expect(grid).toBeInTheDocument();
});
it('should render product categories', () => {
render(<ProductsSection />);
const badges = document.querySelectorAll('[class*="rounded-full"]');
expect(badges.length).toBeGreaterThan(0);
});
it('should render product features', () => {
render(<ProductsSection />);
const features = document.querySelectorAll('[class*="inline-flex"]');
expect(features.length).toBeGreaterThan(0);
});
});
describe('Custom Solution Section', () => {
it('should render custom solution section', () => {
render(<ProductsSection />);
expect(screen.getByText(/需要定制化解决方案/)).toBeInTheDocument();
});
it('should render custom solution description', () => {
render(<ProductsSection />);
expect(screen.getByText(/我们的专业团队/)).toBeInTheDocument();
});
it('should render contact button', () => {
render(<ProductsSection />);
expect(screen.getByRole('link', { name: /联系我们/ })).toBeInTheDocument();
});
it('should link to contact page', () => {
render(<ProductsSection />);
const link = screen.getByRole('link', { name: /联系我们/ });
expect(link).toHaveAttribute('href', '/contact');
});
});
describe('Accessibility', () => {
it('should have region role', () => {
render(<ProductsSection />);
const section = screen.getByRole('region');
expect(section).toBeInTheDocument();
});
it('should have aria-labelledby attribute', () => {
render(<ProductsSection />);
const section = document.querySelector('section#products');
expect(section).toHaveAttribute('aria-labelledby', 'products-heading');
});
it('should have accessible heading', () => {
render(<ProductsSection />);
const heading = screen.getByRole('heading', { level: 2 });
expect(heading).toHaveAttribute('id', 'products-heading');
});
});
describe('Styling', () => {
it('should have background color', () => {
render(<ProductsSection />);
const section = document.querySelector('section#products');
expect(section).toHaveClass('bg-[#F5F7FA]');
});
it('should have proper padding', () => {
render(<ProductsSection />);
const section = document.querySelector('section#products');
expect(section).toHaveClass('py-24');
});
it('should have decorative background elements', () => {
const { container } = render(<ProductsSection />);
const decorativeElements = container.querySelectorAll('.blur-3xl');
expect(decorativeElements.length).toBeGreaterThan(0);
});
});
});
+1 -14
View File
@@ -26,7 +26,7 @@ export function ProductsSection() {
className="text-center max-w-3xl mx-auto mb-16"
>
<h2 id="products-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6">
<span className="text-[#C41E3A]"></span>
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-lg text-[#5C5C5C]">
@@ -85,19 +85,6 @@ export function ProductsSection() {
</ul>
</div>
{product.pricing && (
<div className="mb-4 p-3 bg-[#F5F7FA] rounded-lg">
<p className="text-sm font-medium text-[#1C1C1C] mb-2"></p>
<div className="space-y-1">
{Object.entries(product.pricing).map(([key, value]) => (
<p key={key} className="text-xs text-[#5C5C5C]">
{value}
</p>
))}
</div>
</div>
)}
<Button variant="outline" className="w-full mt-auto group-hover:bg-[#A01830] group-hover:text-white group-hover:border-[#A01830] transition-colors">
<ArrowRight className="ml-2 w-4 h-4" />
@@ -1,135 +0,0 @@
import { describe, it, expect, beforeEach } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ServicesSection } from './services-section';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
useInView: () => true,
}));
jest.mock('next/link', () => {
return ({ children, href }: any) => <a href={href}>{children}</a>;
});
jest.mock('@/hooks/use-services', () => ({
useServices: () => ({
services: [
{
id: '1',
title: '测试服务1',
description: '这是测试服务1的描述',
icon: 'Code',
features: ['特性1', '特性2'],
},
{
id: '2',
title: '测试服务2',
description: '这是测试服务2的描述',
icon: 'Database',
features: ['特性3', '特性4'],
},
],
loading: false,
error: null,
}),
}));
describe('ServicesSection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render services section', () => {
render(<ServicesSection />);
const section = document.querySelector('section#services');
expect(section).toBeInTheDocument();
});
it('should render section heading', () => {
render(<ServicesSection />);
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
});
it('should render section description', () => {
render(<ServicesSection />);
expect(screen.getByText(/专业技术团队/)).toBeInTheDocument();
});
});
describe('Service Cards', () => {
it('should render service cards', () => {
render(<ServicesSection />);
const cards = document.querySelectorAll('.p-6');
expect(cards.length).toBeGreaterThan(0);
});
it('should display services in grid layout', () => {
const { container } = render(<ServicesSection />);
const grid = container.querySelector('.grid-cols-1');
expect(grid).toBeInTheDocument();
});
it('should render service icons', () => {
render(<ServicesSection />);
const icons = document.querySelectorAll('svg');
expect(icons.length).toBeGreaterThan(0);
});
});
describe('Call to Action', () => {
it('should render view all services button', () => {
render(<ServicesSection />);
expect(screen.getByRole('link', { name: /查看全部服务/ })).toBeInTheDocument();
});
it('should link to services page', () => {
render(<ServicesSection />);
const link = screen.getByRole('link', { name: /查看全部服务/ });
expect(link).toHaveAttribute('href', '/services');
});
});
describe('Accessibility', () => {
it('should have section with id', () => {
render(<ServicesSection />);
const section = document.querySelector('section#services');
expect(section).toBeInTheDocument();
});
it('should have aria-labelledby attribute', () => {
render(<ServicesSection />);
const section = document.querySelector('section#services');
expect(section).toHaveAttribute('aria-labelledby', 'services-heading');
});
it('should have accessible heading', () => {
render(<ServicesSection />);
const heading = screen.getByRole('heading', { level: 2 });
expect(heading).toHaveAttribute('id', 'services-heading');
});
});
describe('Styling', () => {
it('should have white background', () => {
render(<ServicesSection />);
const section = document.querySelector('section#services');
expect(section).toHaveClass('bg-white');
});
it('should have proper padding', () => {
render(<ServicesSection />);
const section = document.querySelector('section#services');
expect(section).toHaveClass('py-24');
});
it('should have decorative background elements', () => {
const { container } = render(<ServicesSection />);
const decorativeElements = container.querySelectorAll('.blur-3xl');
expect(decorativeElements.length).toBeGreaterThan(0);
});
});
});
+1 -1
View File
@@ -33,7 +33,7 @@ export function ServicesSection() {
className="text-center max-w-3xl mx-auto mb-16"
>
<h2 id="services-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A]"></span>
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-lg text-[#5C5C5C] max-w-2xl mx-auto">
+91
View File
@@ -0,0 +1,91 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { ArrowRight, Shield, Building2, Users } from 'lucide-react';
const TEAM_HIGHLIGHTS = [
{
icon: Shield,
title: '12年+ 行业深耕',
description: '核心团队长期从事技术咨询、企业数字化等领域,积累了丰富的行业经验和最佳实践。',
},
{
icon: Building2,
title: '大型 IT 企业背景',
description: '开发团队成员来自多个大型传统 IT 企业,具备扎实的工程能力和规范化交付经验。',
},
{
icon: Users,
title: '复合型技术团队',
description: '既懂技术又懂业务,能深入理解客户场景,提供真正落地的解决方案。',
},
];
export function TeamSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<section id="team" role="region" aria-labelledby="team-heading" className="py-24 bg-[#FAFAFA] relative overflow-hidden" ref={ref}>
<div className="container-wide relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
className="text-center max-w-3xl mx-auto mb-16"
>
<h2 id="team-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-lg text-[#5C5C5C] leading-relaxed">
12 + IT
</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto mb-12">
{TEAM_HIGHLIGHTS.map((item, idx) => {
const Icon = item.icon;
return (
<motion.div
key={item.title}
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.1 + idx * 0.15 }}
>
<div className="bg-white rounded-2xl p-8 border border-[#E5E5E5] hover:border-[#C41E3A]/30 hover:shadow-lg transition-all duration-300 h-full text-center">
<div className="w-14 h-14 bg-[#C41E3A] rounded-2xl flex items-center justify-center mb-6 mx-auto">
<Icon className="w-7 h-7 text-white" />
</div>
<h3 className="text-lg font-bold text-[#1C1C1C] mb-3">{item.title}</h3>
<p className="text-sm text-[#5C5C5C] leading-relaxed">{item.description}</p>
</div>
</motion.div>
);
})}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.5 }}
className="text-center"
>
<Button
variant="outline"
size="lg"
asChild
>
<StaticLink href="/team">
<ArrowRight className="ml-2 w-4 h-4" />
</StaticLink>
</Button>
</motion.div>
</div>
</section>
);
}
@@ -1,81 +0,0 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { TestimonialsSection } from './testimonials-section';
jest.mock('@/components/ui/testimonial-card', () => ({
TestimonialCard: ({ author, quote }: any) => (
<div data-testid="testimonial-card">
<div>{author}</div>
<div>{quote}</div>
</div>
),
}));
describe('TestimonialsSection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render testimonials section', () => {
render(<TestimonialsSection />);
const section = document.querySelector('section#testimonials');
expect(section).toBeInTheDocument();
});
it('should render section heading', () => {
render(<TestimonialsSection />);
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
});
it('should render section description', () => {
render(<TestimonialsSection />);
expect(screen.getByText(/听听我们的客户怎么说/)).toBeInTheDocument();
});
it('should render testimonial cards', () => {
render(<TestimonialsSection />);
const cards = screen.getAllByTestId('testimonial-card');
expect(cards.length).toBeGreaterThan(0);
});
it('should render testimonial authors', () => {
render(<TestimonialsSection />);
expect(screen.getByText('张总')).toBeInTheDocument();
});
it('should render testimonial quotes', () => {
render(<TestimonialsSection />);
expect(screen.getByText(/睿新致远的团队非常专业/)).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have section id', () => {
render(<TestimonialsSection />);
const section = document.querySelector('section#testimonials');
expect(section).toBeInTheDocument();
});
});
describe('Styling', () => {
it('should have correct background', () => {
render(<TestimonialsSection />);
const section = document.querySelector('section.bg-white');
expect(section).toBeInTheDocument();
});
it('should have container', () => {
render(<TestimonialsSection />);
const container = document.querySelector('.container-wide');
expect(container).toBeInTheDocument();
});
it('should have grid layout', () => {
render(<TestimonialsSection />);
const grid = document.querySelector('.grid');
expect(grid).toBeInTheDocument();
});
});
});
@@ -1,103 +0,0 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { TestimonialCard } from '@/components/ui/testimonial-card';
interface Testimonial {
id: string;
quote: string;
author: string;
position: string;
company: string;
avatarUrl?: string;
rating?: number;
}
const MOCK_TESTIMONIALS: Testimonial[] = [
{
id: '1',
quote: '睿新致远的团队非常专业,他们不仅理解我们的业务需求,还能提供超出预期的技术解决方案。数字化转型后,我们的运营效率提升了40%。',
author: '张总',
position: '总经理',
company: '某制造企业',
avatarUrl: '/testimonials/avatar-1.jpg',
rating: 5,
},
{
id: '2',
quote: '选择睿新致远是我们最正确的决定。他们的数据中台解决方案帮助我们实现了数据资产的统一管理,决策效率大幅提升。',
author: '李经理',
position: '信息部经理',
company: '某零售集团',
avatarUrl: '/testimonials/avatar-2.jpg',
rating: 5,
},
{
id: '3',
quote: '从需求分析到系统上线,睿新致远的团队都表现出极高的专业素养。他们的ERP系统让我们的业务流程更加标准化、透明化。',
author: '王总监',
position: '运营总监',
company: '某物流企业',
avatarUrl: '/testimonials/avatar-3.jpg',
rating: 5,
},
];
export function TestimonialsSection() {
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.1 }
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => observer.disconnect();
}, []);
return (
<section
id="testimonials"
ref={sectionRef}
className="py-24 bg-white"
>
<div className="container-wide">
<div
className={`
text-center mb-16
opacity-0 translate-y-4
${isVisible ? 'animate-fade-in-up' : ''}
`}
>
<h2 className="text-3xl sm:text-4xl font-semibold text-[#171717] mb-4">
</h2>
<p className="text-lg text-[#737373] max-w-2xl mx-auto">
</p>
</div>
<div
className={`
grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8
opacity-0 translate-y-4
${isVisible ? 'animate-fade-in-up stagger-1' : ''}
`}
>
{MOCK_TESTIMONIALS.map((testimonial) => (
<TestimonialCard key={testimonial.id} {...testimonial} />
))}
</div>
</div>
</section>
);
}
+20 -15
View File
@@ -1,17 +1,27 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BackButton } from './back-button';
jest.mock('next/navigation', () => ({
useRouter: () => ({
back: jest.fn(),
}),
}));
describe('BackButton', () => {
const mockBack = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockBack.mockClear();
// Mock window.history.back
Object.defineProperty(window, 'history', {
value: {
back: mockBack,
forward: jest.fn(),
go: jest.fn(),
length: 1,
pushState: jest.fn(),
replaceState: jest.fn(),
scrollRestoration: 'auto',
state: null,
},
writable: true,
});
});
describe('Rendering', () => {
@@ -33,16 +43,11 @@ describe('BackButton', () => {
});
describe('Interaction', () => {
it('should call router.back() when clicked', () => {
const mockBack = jest.fn();
jest.spyOn(require('next/navigation'), 'useRouter').mockReturnValue({
back: mockBack,
});
it('should call window.history.back() when clicked', () => {
render(<BackButton />);
fireEvent.click(screen.getByRole('button'));
expect(mockBack).toHaveBeenCalled();
expect(mockBack).toHaveBeenCalledTimes(1);
});
});
+2
View File
@@ -1,6 +1,7 @@
'use client';
import { Component, ReactNode } from 'react';
import { trackError } from '@/lib/analytics';
interface Props {
children: ReactNode;
@@ -24,6 +25,7 @@ export class ErrorBoundary extends Component<Props, State> {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
trackError('react_error', error.message, true);
}
render() {
+2 -2
View File
@@ -43,13 +43,13 @@ describe('useFontLoading', () => {
writable: true,
});
const { result } = renderHook(() => useFontLoading('Aoyagi Reisho'));
const { result } = renderHook(() => useFontLoading('Ma Shan Zheng'));
await waitFor(() => {
expect(result.current).toBe(true);
});
expect(mockLoad).toHaveBeenCalledWith('1em "Aoyagi Reisho"');
expect(mockLoad).toHaveBeenCalledWith('1em "Ma Shan Zheng"');
});
it('should return true when font loading fails', async () => {
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react';
export function useFontLoading(fontFamily: string = 'Aoyagi Reisho') {
export function useFontLoading(fontFamily: string = 'Ma Shan Zheng') {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
+125 -3
View File
@@ -2,7 +2,11 @@ export const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || ''
declare global {
interface Window {
gtag: (command: string, targetId: string, config?: Record<string, unknown>) => void;
gtag: (
command: string,
targetIdOrParams: string | Record<string, unknown>,
config?: Record<string, unknown>
) => void;
}
}
@@ -24,8 +28,16 @@ export const event = (action: string, category: string, label?: string, value?:
}
};
export const trackContactForm = (_formData: Record<string, string>) => {
event('submit', 'contact_form', 'contact_form_submission');
export const trackContactForm = (formData: Record<string, string>) => {
event('generate_lead', 'engagement', 'contact_form_submission');
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
window.gtag('event', 'contact_form', {
event_category: 'lead_generation',
event_label: formData.company || 'unknown_company',
company_size: formData.company ? 'provided' : 'not_provided',
});
}
};
export const trackButtonClick = (buttonName: string, location: string) => {
@@ -35,3 +47,113 @@ export const trackButtonClick = (buttonName: string, location: string) => {
export const trackPageView = (pageTitle: string, _pagePath: string) => {
event('page_view', 'navigation', pageTitle);
};
export const trackConversion = (conversionName: string, value?: number) => {
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
window.gtag('event', 'conversion', {
send_to: `${GA_MEASUREMENT_ID}/${conversionName}`,
value: value,
currency: 'CNY',
});
}
};
export const trackError = (errorType: string, errorMessage: string, fatal: boolean = false) => {
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
window.gtag('event', 'exception', {
description: `${errorType}: ${errorMessage}`,
fatal: fatal,
});
}
};
export const trackPerformance = (metricName: string, value: number) => {
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
window.gtag('event', 'web_vitals', {
name: metricName,
value: Math.round(value),
event_category: 'Web Vitals',
non_interaction: true,
});
}
};
export const trackScrollDepth = (percentage: number) => {
event('scroll', 'engagement', `${percentage}%`, percentage);
};
export const trackDownload = (fileName: string, fileType: string) => {
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
window.gtag('event', 'file_download', {
event_category: 'downloads',
event_label: fileName,
file_extension: fileType,
});
}
};
export const trackOutboundLink = (url: string) => {
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
window.gtag('event', 'click', {
event_category: 'outbound',
event_label: url,
transport_type: 'beacon',
});
}
};
export const trackVideo = (action: 'play' | 'pause' | 'complete' | 'progress', videoTitle: string, progress?: number) => {
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
window.gtag('event', `video_${action}`, {
event_category: 'videos',
event_label: videoTitle,
video_percent: progress,
});
}
};
export const trackEngagement = (action: string, details?: Record<string, unknown>) => {
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
window.gtag('event', action, {
event_category: 'engagement',
...details,
});
}
};
export const trackSectionView = (sectionName: string) => {
event('section_view', 'navigation', sectionName);
};
export const trackCaseView = (caseId: string, caseTitle: string) => {
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
window.gtag('event', 'view_item', {
event_category: 'case_studies',
event_label: caseTitle,
item_id: caseId,
});
}
};
export const trackServiceInterest = (serviceName: string) => {
event('service_interest', 'engagement', serviceName);
};
export const trackProductView = (productId: string, productName: string) => {
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
window.gtag('event', 'view_item', {
event_category: 'products',
event_label: productName,
item_id: productId,
});
}
};
export const updateConsent = (granted: boolean) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('consent', 'update', {
analytics_storage: granted ? 'granted' : 'denied',
ad_storage: 'denied',
});
}
};
+3 -1
View File
@@ -885,7 +885,7 @@ export function CounterWithEffect({
className = '',
effect = 'bounce'
}: CounterWithEffectProps) {
const [count, setCount] = useState(0);
const [count, setCount] = useState(end);
const [_prevCount, setPrevCount] = useState(0);
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
@@ -895,6 +895,8 @@ export function CounterWithEffect({
if (!isInView || hasAnimated.current) {return;}
hasAnimated.current = true;
setCount(0);
const startTime = Date.now();
const animate = () => {
const progress = Math.min((Date.now() - startTime) / duration, 1);
+8 -8
View File
@@ -113,10 +113,10 @@ describe('Constants', () => {
expect(softwareService?.title).toBe('软件开发');
});
it('should have cloud service', () => {
const cloudService = SERVICES.find(s => s.id === 'cloud');
expect(cloudService).toBeDefined();
expect(cloudService?.title).toBe('云服务');
it('should have consulting service', () => {
const consultingService = SERVICES.find(s => s.id === 'consulting');
expect(consultingService).toBeDefined();
expect(consultingService?.title).toBe('技术咨询');
});
it('should have data service', () => {
@@ -125,10 +125,10 @@ describe('Constants', () => {
expect(dataService?.title).toBe('数据分析');
});
it('should have security service', () => {
const securityService = SERVICES.find(s => s.id === 'security');
expect(securityService).toBeDefined();
expect(securityService?.title).toBe('信息安全');
it('should have solutions service', () => {
const solutionsService = SERVICES.find(s => s.id === 'solutions');
expect(solutionsService).toBeDefined();
expect(solutionsService?.title).toBe('解决方案');
});
it('should have features as array', () => {
+184 -33
View File
@@ -1,47 +1,198 @@
export const CASES = [
export interface CaseKeyMoment {
title: string;
description: string;
}
export interface CaseResult {
label: string;
value: string;
}
export interface CaseTestimonial {
quote: string;
author: string;
role: string;
}
export interface CaseItem {
id: string;
title: string;
client: string;
industry: string;
description: string;
/** 客户面临的挑战 */
challenge: string;
/** 我们的解决方案 */
solution: string;
/** 关键时刻 */
keyMoments: CaseKeyMoment[];
/** 成果数据 */
results: CaseResult[];
/** 客户证言 */
testimonial?: CaseTestimonial;
tags: string[];
image: string;
/** 合作时长 */
duration: string;
/** 发布日期 */
date: string;
}
export const CASES: CaseItem[] = [
{
id: 'case-1',
title: '某银行数字化转型项目',
client: '某国有银行',
industry: '金融科技',
description: '为某国有银行提供全面的数字化转型解决方案,包括核心系统升级、数据中台建设、智能风控系统部署等,助力银行实现业务流程自动化和智能化。',
content: '为某国有银行提供全面的数字化转型解决方案,包括核心系统升级、数据中台建设、智能风控系统部署等,助力银行实现业务流程自动化和智能化。',
results: [
{ label: '业务处理效率', value: '提升60%' },
{ label: '客户满意度', value: '提升45%' },
{ label: '运营成本', value: '降低30%' },
title: '藏区酒店信息化管理建设项目',
client: '某藏区精品酒店',
industry: '酒店管理',
description:
'为某偏远藏区精品酒店提供信息化管理建设服务,帮助客户克服高原地区网络条件受限、人员信息化基础薄弱等特殊困难,实现从纯手工运营向数字化管理的跨越,提升旅游旺季接待能力和运营效率。',
challenge:
'该酒店位于川西高原藏区,是当地规模较大的精品酒店,拥有86间客房,每年接待大量自驾游和徒步游客。然而,酒店运营完全依赖手工操作——前台用纸质登记本记录客人信息,客房状态靠手写白板展示,财务用Excel记账,库存管理全凭人工盘点。由于藏区网络信号不稳定、部分员工信息化基础薄弱,此前尝试引入的市面标准酒店管理系统经常断线卡顿,员工不会用也不愿用,最终沦为摆设。旅游旺季时,前台排队登记常常让客人等待超过20分钟,客房清扫调度混乱导致客人入住时房间尚未打扫完毕,OTA平台上的差评中超过40%与入住效率和房间状态有关。',
solution:
'我们深入藏区实地调研后,针对高原酒店的特殊环境量身定制了轻量化、高可用的信息化管理方案。在网络适配方面,系统采用本地优先架构——核心功能支持离线运行,数据在本地缓存,网络恢复后自动同步,彻底解决了信号不稳定导致的系统不可用问题。在易用性方面,界面设计遵循"三步完成"原则,每个操作流程不超过三步点击,并配备藏汉双语界面和语音操作引导,让信息化基础薄弱的当地员工也能快速上手。核心功能模块包括:智能前台系统,支持身份证识别快速入住和扫码退房;客房管理系统,通过移动端实现清扫任务自动派发和房间状态实时更新;财务库存模块,自动生成每日营收报表和物资消耗预警。目前系统已在酒店全面上线运行。',
keyMoments: [
{
title: '攻克网络难题:离线模式保障系统可用',
description:
'系统上线初期,高原频繁的网络波动导致标准SaaS模式频繁断线。我们紧急调整技术方案,用一周时间开发并部署了本地优先+异步同步架构,核心业务流程在断网状态下可正常使用,网络恢复后数据自动回传。这一改造使系统可用率从不足60%提升至99%以上,彻底消除了员工"系统不好用"的抵触心理。',
},
{
title: '旺季实战:入住效率提升5倍',
description:
'系统上线后恰逢国庆旅游旺季,日均入住量达到平时的3倍。得益于身份证识别快速入住和客房状态实时同步,前台平均办理时间从20分钟缩短至4分钟,客房清扫调度井然有序,旺季期间"到店无房可住"的投诉从去年同期的17起降至0起。OTA平台评分从3.8分提升至4.5分。',
},
],
tags: ['金融科技', '数字化转型', '数据中台'],
image: '/images/cases/bank.jpg',
results: [
{ label: '入住办理', value: '20分钟→4分钟' },
{ label: '系统可用率', value: '60%→99%' },
{ label: 'OTA评分', value: '3.8→4.5' },
],
testimonial: {
quote:
'之前也买过酒店管理系统,但网络一断就成了摆设,员工也不会用。睿新致远不一样,他们专门跑到藏区来实地看我们的情况,做的系统能离线用、界面简单、还有藏语,我们的员工两天就学会了。现在旺季再也不手忙脚乱了。',
author: '客户企业',
role: '酒店总经理',
},
tags: ['酒店管理', '信息化建设', '藏区服务'],
image: '/images/cases/biotech.jpg',
duration: '6个月',
date: '2026-04-15',
},
{
id: 'case-2',
title: '智能制造示范工厂项目',
title: '制造企业办公信息化建设项目',
client: '某大型制造企业',
industry: '智能制造',
description: '为制造企业打造智能制造示范工厂,实现生产设备互联、生产过程可视化、质量追溯全覆盖,提升生产效率和产品质量。',
content: '为制造企业打造智能制造示范工厂,实现生产设备互联、生产过程可视化、质量追溯全覆盖,提升生产效率和产品质量。',
results: [
{ label: '生产效率', value: '提升40%' },
{ label: '设备利用率', value: '提升35%' },
{ label: '不良品率', value: '降低50%' },
industry: '制造',
description:
'为某大型制造企业建设统一的办公信息化平台,涵盖OA协同办公、人事管理、财务报销、行政审批等核心模块,帮助企业告别纸质办公和"人肉流转",实现办公效率的全面提升。',
challenge:
'该制造企业员工规模超过3000人,但日常办公仍以纸质流程为主——一份请假申请需要找3位领导签字,一次差旅报销从提交到打款平均需要21天,跨部门文件审批依赖纸质传阅,经常出现"文件找不到、进度查不到、责任追不到"的窘境。各部门使用不同版本的Excel表格管理业务数据,信息孤岛严重。更关键的是,总部与分散在全国的8个分支机构之间缺乏统一的协同平台,远程协作效率极低,每次月度经营分析会都需要各基地提前一周手工汇总数据。',
solution:
'我们为客户规划并实施了统一的办公信息化平台,采用"核心模块优先+分批推广"的策略。第一优先级上线OA协同办公和行政审批模块,覆盖请假、出差、用章、采购等高频流程,实现全流程线上化和移动端审批。第二优先级上线人事管理和财务报销模块,打通员工入离职、考勤排班与薪资核算的全链路,同时实现发票识别、智能审单和自动对账,将报销周期从21天压缩至3天。第三阶段规划建设知识管理、会议协作和经营分析看板等增值模块,构建企业级数字化办公生态。目前OA和行政审批模块已在总部全面上线,财务报销模块正在试点中。',
keyMoments: [
{
title: '审批流程再造:21天变3天',
description:
'我们与客户各部门逐一梳理了87条审批流程,识别出32条可以合并或简化的流程,并针对每条流程设计了最优的线上审批路径。OA系统上线后,平均审批时长从原来的5.2天缩短至0.8天,差旅报销周期从21天压缩至3天,员工满意度调查得分从62分提升至89分。',
},
{
title: '移动审批上线:领导随时随地批',
description:
'在系统上线第二个月,我们推出了移动端审批功能。上线首周,移动端审批量即占总审批量的67%。一位分管生产的副总反馈:"以前出差一周回来,办公桌上堆满了待签文件,现在高铁上就能批完,再也不用加班补签了。"',
},
],
tags: ['智能制造', '工业互联网', 'IoT'],
image: '/images/cases/manufacturing.jpg',
results: [
{ label: '平均审批时长', value: '缩短85%' },
{ label: '报销周期', value: '21天→3天' },
{ label: '员工满意度', value: '62→89分' },
],
testimonial: {
quote:
'以前觉得上OA系统就是买个软件装上,睿新致远让我们明白办公信息化的核心是流程再造。他们不是简单地把线下流程搬到线上,而是帮我们重新思考了每一条流程是否合理、能不能更快。这种"先梳理再上线"的方法论非常专业。',
author: '客户企业',
role: '行政总监',
},
tags: ['办公信息化', 'OA协同', '流程再造'],
image: '/images/cases/manufacturing-consulting.jpg',
duration: '8个月',
date: '2026-04-10',
},
{
id: 'case-3',
title: '企业数据中台建设项目',
client: '某零售集团',
industry: '企业数字化',
description: '为零售集团建设企业级数据中台,整合多渠道数据资源,实现数据资产统一管理,支撑精准营销和智能决策。',
content: '为零售集团建设企业级数据中台,整合多渠道数据资源,实现数据资产统一管理,支撑精准营销和智能决策。',
results: [
{ label: '数据整合效率', value: '提升80%' },
{ label: '决策响应时间', value: '缩短70%' },
{ label: '营销转化率', value: '提升25%' },
title: '政府单位数字化整体解决方案项目',
client: '某市级政府单位',
industry: '政务服务',
description:
'为某市级政府单位提供数字化整体解决方案,涵盖业务流程优化、信息系统整合、在线服务平台建设等,有效提高办事效率,提升公共服务水平和群众满意度。',
challenge:
'该市级政府单位承担着面向全市120万市民的行政审批和公共服务职能。然而,市民办理一项常规审批平均需要跑3个窗口、提交15份纸质材料、等待12个工作日。同时,内部22个科室各自维护独立的业务台账,跨科室协办事项平均流转时间超过20天,群众投诉率居高不下。随着"数字政府"建设的深入推进,该单位亟需加快数字化转型步伐。',
solution:
'我们为客户规划了"一网通办"数字化政务服务平台。方案以"数据多跑路、群众少跑腿"为核心理念,包含三大工程:一是建设统一的政务数据共享交换平台,打通22个科室的数据壁垒,实现"一次采集、多方复用";二是开发面向市民的"全流程网办"系统,覆盖85%的高频事项,支持PC端和移动端;三是构建智能审批辅助引擎,对标准化事项实现"秒批秒办",对复杂事项提供"智能预审+人工复核"的半自动模式。在实施过程中,我们特别注重适老化设计,为老年市民保留了电话预约和线下辅助通道。目前项目已完成整体方案设计和数据共享平台搭建,网办系统正在开发中。',
keyMoments: [
{
title: '需求调研:走访22个科室',
description:
'项目启动后,我们用两周时间逐一走访了全部22个科室,深入了解每个科室的业务流程和数据流转现状。调研中发现的"重复录入"问题尤为突出——同一份企业资料平均被不同科室录入6次,这一发现为后续数据共享方案提供了强有力的支撑。',
},
{
title: '适老化方案获高度评价',
description:
'在方案评审阶段,我们提出的适老化设计方案——包括大字版界面、语音引导、电话预约通道和社区志愿者协助机制——获得了评审专家的一致好评。评审组长评价:"这是少数真正考虑到了每一位市民的方案。"',
},
],
tags: ['数据中台', '大数据', '商业智能'],
image: '/images/cases/retail.jpg',
results: [
{ label: '科室调研', value: '22个全覆盖' },
{ label: '高频事项覆盖', value: '85%' },
{ label: '数据共享平台', value: '已搭建完成' },
],
testimonial: {
quote:
'睿新致远不仅懂技术,更懂"为人民服务"的含义。他们的适老化设计让我们看到了数字化转型的温度——技术进步不应该让任何一个人掉队。项目推进节奏稳健,我们对合作前景非常期待。',
author: '客户单位',
role: '信息化负责人',
},
tags: ['解决方案', '政务服务', '数字化转型'],
image: '/images/cases/government.jpg',
duration: '10个月',
date: '2026-04-18',
},
{
id: 'case-4',
title: '农业种植灌溉信息化建设咨询项目',
client: '某农户专业合作社',
industry: '智慧农业',
description:
'为某农户专业合作社提供种植灌溉信息化建设咨询服务,帮助合作社及辖区农户从传统经验灌溉向数据驱动的精准灌溉转型,以低成本、易操作的方案实现节水降本、提质增效。',
challenge:
'该合作社位于新疆塔城地区,由5户种植大户联合成立,承包经营耕地超过6000亩,以小麦、玉米和甜菜为主。塔城地区虽然依托额敏河和雪山融水,但水资源时空分布极不均衡,春旱频发,灌溉用水配额逐年收紧。长期以来,灌溉完全靠经验——"看天浇水、估摸着放",大水漫灌方式导致水利用率不足40%,水资源浪费严重。合作社曾尝试引入智慧农业系统,但市面上的方案要么针对大规模农场、投入动辄上百万,要么功能复杂、需要专业团队运维,不适合他们这种"几个农户管几千亩地"的模式。每到春灌用水高峰期,农户之间因争水引发的矛盾频发,干旱年份减产损失更为严重。合作社迫切需要一套"买得起、自己能管、真管用"的灌溉信息化方案。',
solution:
'我们深入塔城田间地头实地调研后,为合作社量身定制了"低成本、易运维、接地气"的智慧灌溉方案。在硬件方面,采用国产高性价比传感器,根据6000亩耕地的地块分布,按片区部署土壤墒情监测点和简易气象站,总投入控制在合作社可承受的范围内。在平台方面,搭建轻量级的灌溉数据服务平台,整合传感器数据、当地气象预报和灌区供水计划,结合塔城地区春旱频发、作物以旱作为主的特点,通过简化的灌溉建议模型生成通俗易懂的灌溉指导——"今天该浇多少水、什么时间浇",直接推送到农户手机微信上。在运维方面,系统设计充分考虑了"几个农户自己管"的场景,设备采用太阳能供电、无线传输、免布线安装,日常无需专业IT人员维护。目前系统已全面部署上线,覆盖全部6000亩耕地。',
keyMoments: [
{
title: '免布线安装:两天完成全部部署',
description:
'考虑到农户人手有限、无法承担复杂的施工安装,我们选用了太阳能供电、4G无线传输的传感器设备,免布线、即插即用。整个监测网络仅用两天时间就完成了全部部署和调试,5户农户全程参与,边装边学,部署完成即能独立使用。',
},
{
title: '春灌调度:数据化解争水矛盾',
description:
'往年春灌用水高峰期,5户农户因用水先后顺序和分配比例争执不下,每年都要找村委会协调。系统上线后,各片区土壤墒情和用水量数据实时可见,合作社据此制定了"缺水优先、轮灌调度"的公平分配方案。一个灌溉季下来,争水矛盾彻底化解,农户之间关系反而比以前更融洽了。',
},
],
results: [
{ label: '灌溉用水量', value: '降低28%' },
{ label: '水利用率', value: '从40%提升至65%' },
{ label: '覆盖耕地', value: '6000亩' },
],
testimonial: {
quote:
'我们几个人管着几千亩地,最怕的就是浇水——跑一遍地就得大半天,水还经常不够分。现在手机上就能看到哪块地缺水、该浇多少,省了人工还省了水。睿新致远的方案实在,设备装上去不用管,自己就能用。',
author: '客户单位',
role: '合作社理事长',
},
tags: ['智慧农业', '精准灌溉', '惠农服务'],
image: '/images/cases/agriculture.jpg',
duration: '10个月',
date: '2026-04-20',
},
];
+2
View File
@@ -5,3 +5,5 @@ export { SERVICES } from './services';
export { PRODUCTS } from './products';
export { NEWS, type NewsItem, type NewsCategory } from './news';
export { CASES } from './cases';
export { TEAM_MEMBERS, type TeamMember } from './team';
export { METHODOLOGY, type MethodologyPhase } from './methodology';
+1
View File
@@ -7,6 +7,7 @@ export interface NavigationItem {
export const NAVIGATION: NavigationItem[] = [
{ id: 'home', label: '首页', href: '/' },
{ id: 'services', label: '核心业务', href: '/' },
{ id: 'solutions', label: '解决方案', href: '/' },
{ id: 'products', label: '产品服务', href: '/' },
{ id: 'cases', label: '成功案例', href: '/' },
{ id: 'about', label: '关于我们', href: '/' },
+3 -74
View File
@@ -1,4 +1,4 @@
export type NewsCategory = '公司新闻' | '产品发布' | '合作动态' | '行业资讯';
export type NewsCategory = '公司新闻' | '产品发布';
export interface NewsItem {
id: string;
@@ -17,7 +17,7 @@ export const NEWS: NewsItem[] = [
excerpt: '2026年1月15日,四川睿新致远科技有限公司在成都龙泉驿区正式成立,标志着公司在科技创新领域迈出了坚实的第一步。',
date: '2026-01-15',
category: '公司新闻',
image: '/images/news/founding.jpg',
image: '/images/news/founding.png',
content: `2026年1月15日,四川睿新致远科技有限公司在成都龙泉驿区幸福路12号正式成立。公司注册资本雄厚,拥有一支经验丰富的技术团队。
公司专注于信息技术服务与解决方案,致力于为企业提供全方位的数字化转型支持。成立之初,公司就确立了"专注科技创新,驱动智慧未来"的企业使命。
@@ -32,7 +32,7 @@ export const NEWS: NewsItem[] = [
excerpt: '针对中小企业数字化转型需求,公司推出一站式数字化转型解决方案,帮助企业快速实现数字化升级。',
date: '2026-01-20',
category: '产品发布',
image: '/images/news/solution.jpg',
image: '/images/news/solution.png',
content: `近日,四川睿新致远科技有限公司正式推出企业数字化转型解决方案,该方案整合了云计算、大数据、人工智能等前沿技术,为中小企业提供一站式的数字化升级服务。
该解决方案包括:
@@ -45,75 +45,4 @@ export const NEWS: NewsItem[] = [
目前,该解决方案已在多个行业成功落地,获得了客户的一致好评。`,
},
{
id: '3',
title: '与本地制造企业达成战略合作协议',
excerpt: '公司与成都某知名制造企业签署战略合作协议,双方将共同打造智能制造示范工厂。',
date: '2026-01-25',
category: '合作动态',
image: '/images/news/partnership.jpg',
content: `1月25日,四川睿新致远科技有限公司与成都某知名制造企业正式签署战略合作协议。根据协议,双方将在智能制造、工业互联网、数字化转型等领域展开深度合作。
此次合作的主要内容包括:
- 建设智能制造示范工厂
- 开发工业互联网平台
- 实施生产数字化管理系统
- 开展技术人才培训
该制造企业负责人表示:"选择睿新致远作为合作伙伴,是看中了他们在数字化转型领域的专业能力和丰富经验。我们相信,通过双方的紧密合作,一定能够打造出行业领先的智能制造标杆。"
公司项目团队已进驻现场,开始前期调研和方案设计工作。`,
},
{
id: '4',
title: '公司加入四川省软件行业协会',
excerpt: '公司正式加入四川省软件行业协会,将积极参与行业交流与合作,推动本地软件产业发展。',
date: '2026-02-01',
category: '公司新闻',
image: '/images/news/membership.jpg',
content: `2月1日,四川睿新致远科技有限公司正式加入四川省软件行业协会,成为协会成员单位。这标志着公司在软件行业的专业地位得到了行业认可。
四川省软件行业协会是省内软件行业最具权威性的行业组织,拥有会员单位数百家。加入协会后,公司将享有以下权益:
- 参与行业标准制定
- 获取政策信息和行业动态
- 参加行业培训和交流活动
- 享受会员专属服务
公司表示,将积极参与协会组织的各项活动,与行业同仁加强交流合作,共同推动四川省软件产业高质量发展。同时,公司也将严格遵守行业规范,坚持诚信经营,为客户提供优质的产品和服务。`,
},
{
id: '5',
title: '2026年企业数字化转型趋势报告发布',
excerpt: '公司发布《2026年企业数字化转型趋势报告》,深入分析行业发展趋势,为企业提供转型参考。',
date: '2026-02-02',
category: '行业资讯',
image: '/images/news/report.jpg',
content: `四川睿新致远科技有限公司今日发布《2026年企业数字化转型趋势报告》,该报告基于对数百家企业的调研分析,深入剖析了当前企业数字化转型的现状、挑战与机遇。
报告主要发现:
1. 数字化转型已成为企业共识
调研显示,超过85%的企业已将数字化转型列为战略优先级,较2025年提升15个百分点。
2. 中小企业转型需求迫切`,
},
{
id: '6',
title: '公司获得ISO9001质量管理体系认证',
excerpt: '经过严格审核,公司正式获得ISO9001质量管理体系认证,标志着公司质量管理水平迈上新台阶。',
date: '2026-02-10',
category: '公司新闻',
image: '/images/news/iso9001.jpg',
content: `2月10日,四川睿新致远科技有限公司正式获得ISO9001质量管理体系认证证书。这是公司在质量管理领域取得的重要里程碑。
ISO9001认证是国际公认的质量管理体系标准,获得该认证意味着公司在以下方面达到了国际标准:
- 客户需求识别和满足能力
- 产品和服务质量控制能力
- 持续改进机制
- 风险管理能力
公司质量负责人表示:"获得ISO9001认证是对我们质量管理工作的肯定,也是新的起点。我们将继续坚持'质量第一'的原则,不断提升产品和服务质量,为客户创造更大价值。"
该认证的获得,将有助于公司进一步提升市场竞争力,赢得更多客户的信任。`,
},
] as const;
+27 -6
View File
@@ -1,11 +1,32 @@
import { CASES } from './cases';
export interface StatItem {
value: string;
label: string;
}
export const STATS: StatItem[] = [
{ value: '10+', label: '企业客户' },
{ value: '20+', label: '成功案例' },
{ value: '30+', label: '项目交付' },
{ value: '12+', label: '年行业经验' },
];
function calculateYearsOfExperience(): number {
const startYear = 2014;
const currentYear = new Date().getFullYear();
return currentYear - startYear;
}
function calculateUniqueClients(): number {
const uniqueClients = new Set(CASES.map(c => c.client));
return uniqueClients.size;
}
function getStats(): StatItem[] {
const yearsOfExperience = calculateYearsOfExperience();
const uniqueClients = calculateUniqueClients();
const caseCount = CASES.length;
return [
{ value: `${uniqueClients}+`, label: '企业客户' },
{ value: `${caseCount}+`, label: '成功案例' },
{ value: `${caseCount}+`, label: '项目交付' },
{ value: `${yearsOfExperience}+`, label: '年团队经验' },
];
}
export const STATS: StatItem[] = getStats();
-80
View File
@@ -1,80 +0,0 @@
import { sanitizeHTML, sanitizeInput, sanitizeURL, escapeHTML } from './sanitize';
describe('sanitize', () => {
describe('sanitizeHTML', () => {
it('should allow safe HTML tags', () => {
const result = sanitizeHTML('<p>Hello <b>world</b></p>');
expect(result).toContain('<p>');
expect(result).toContain('<b>');
});
it('should remove dangerous tags', () => {
const result = sanitizeHTML('<script>alert("xss")</script><p>safe</p>');
expect(result).not.toContain('<script>');
expect(result).toContain('<p>');
});
it('should remove dangerous attributes', () => {
const result = sanitizeHTML('<a href="#" onclick="alert(1)">link</a>');
expect(result).not.toContain('onclick');
});
it('should handle empty input', () => {
expect(sanitizeHTML('')).toBe('');
});
});
describe('sanitizeInput', () => {
it('should remove all HTML tags', () => {
const result = sanitizeInput('<p>Hello <b>world</b></p>');
expect(result).not.toContain('<p>');
expect(result).not.toContain('<b>');
expect(result).toContain('Hello');
expect(result).toContain('world');
});
it('should handle special characters', () => {
const result = sanitizeInput('<script>alert("xss")</script>');
expect(result).not.toContain('<script>');
});
});
describe('sanitizeURL', () => {
it('should allow valid http URLs', () => {
expect(sanitizeURL('http://example.com')).toBe('http://example.com');
});
it('should allow valid https URLs', () => {
expect(sanitizeURL('https://example.com')).toBe('https://example.com');
});
it('should allow mailto URLs', () => {
expect(sanitizeURL('mailto:test@example.com')).toBe('mailto:test@example.com');
});
it('should reject javascript URLs', () => {
expect(sanitizeURL('javascript:alert(1)')).toBe('');
});
it('should reject data URLs', () => {
expect(sanitizeURL('data:text/html,<script>alert(1)</script>')).toBe('');
});
});
describe('escapeHTML', () => {
it('should escape HTML special characters', () => {
expect(escapeHTML('<div>')).toBe('&lt;div&gt;');
expect(escapeHTML('&')).toBe('&amp;');
expect(escapeHTML('"')).toBe('&quot;');
expect(escapeHTML("'")).toBe('&#x27;');
});
it('should handle mixed content', () => {
expect(escapeHTML('<script>alert("test")</script>')).toBe('&lt;script&gt;alert(&quot;test&quot;)&lt;&#x2F;script&gt;');
});
it('should handle empty string', () => {
expect(escapeHTML('')).toBe('');
});
});
});