dev #2

Merged
zhangxiang merged 2 commits from dev into release/v1.0.0 2026-03-28 14:40:49 +08:00
20 changed files with 1546 additions and 247 deletions
+8 -8
View File
@@ -117,7 +117,7 @@ steps:
- push
branch:
- release
- release/*
- release/**
# 3.3 深度测试 (release分支)
e2e-deep:
@@ -135,7 +135,7 @@ steps:
- push
branch:
- release
- release/*
- release/**
# 3.4 性能测试 (release分支)
e2e-performance:
@@ -153,7 +153,7 @@ steps:
- push
branch:
- release
- release/*
- release/**
# 3.5 可访问性测试 (release分支)
e2e-accessibility:
@@ -171,7 +171,7 @@ steps:
- push
branch:
- release
- release/*
- release/**
# 3.6 视觉回归测试 (release分支)
e2e-visual:
@@ -189,7 +189,7 @@ steps:
- push
branch:
- release
- release/*
- release/**
# ============================================
# 阶段4: 构建Docker镜像 (release分支)
@@ -216,7 +216,7 @@ steps:
- event: push
branch:
- release
- release/*
- release/**
# ============================================
# 阶段5: 部署到生产环境 (release分支)
@@ -314,7 +314,7 @@ steps:
- push
branch:
- release
- release/*
- release/**
# ============================================
# 阶段6: 归档到main分支 (release分支)
@@ -369,7 +369,7 @@ steps:
- push
branch:
- release
- release/*
- release/**
status:
- success
+357
View File
@@ -0,0 +1,357 @@
# 🚀 CI/CD流水线快速设置指南
## 📋 前置条件
- ✅ Gitea已部署并配置 (https://git.f.novalon.cn)
- ✅ Woodpecker CI已部署并配置 (https://ci.f.novalon.cn)
- ✅ Docker Registry已部署并配置 (https://registry.f.novalon.cn)
- ✅ 服务器已配置SSH免密登录
## 🔧 快速配置步骤
### 步骤1: 配置Woodpecker CI密钥
#### 方式A: 使用自动化脚本 (推荐)
```bash
# 1. 上传脚本到服务器
scp scripts/setup-woodpecker-secrets.sh root@139.155.109.62:/home/novalon/scripts/
# 2. SSH到服务器
ssh root@139.155.109.62
# 3. 运行配置脚本
chmod +x /home/novalon/scripts/setup-woodpecker-secrets.sh
/home/novalon/scripts/setup-woodpecker-secrets.sh
```
#### 方式B: 手动配置
```bash
# 1. SSH到服务器
ssh root@139.155.109.62
# 2. 设置SSH私钥
woodpecker-cli secret add \
--repository novalon/novalon-website \
--name ssh_private_key \
--value @- <<< "$(cat ~/.ssh/id_rsa)"
# 3. 设置Webhook URL (可选)
woodpecker-cli secret add \
--repository novalon/novalon-website \
--name webhook_url \
--value @- <<< "YOUR_WEBHOOK_URL"
```
### 步骤2: 在Gitea中创建仓库
```bash
# 1. 访问 https://git.f.novalon.cn
# 2. 使用管理员账户登录
# 用户名: novalon-admin
# 密码: Novalon@Admin2026
# 3. 创建新仓库: novalon/novalon-website
# 4. 添加远程仓库
git remote add origin https://git.f.novalon.cn/novalon/novalon-website.git
```
### 步骤3: 在Woodpecker CI中激活仓库
```bash
# 1. 访问 https://ci.f.novalon.cn
# 2. 使用Gitea账户登录 (自动SSO)
# 3. 点击"Add Repository"
# 4. 选择 novalon/novalon-website 仓库
# 5. 点击"Activate"
```
### 步骤4: 配置服务器部署目录
```bash
# SSH到服务器
ssh root@139.155.109.62
# 创建部署目录
mkdir -p /home/novalon/docker-app/novalon-website
cd /home/novalon/docker-app/novalon-website
# 创建docker-compose.yml
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
novalon-website:
image: registry.f.novalon.cn/novalon-website:latest
container_name: novalon-website
restart: always
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=file:/app/data/local.db
volumes:
- ./data:/app/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- novalon-network
networks:
novalon-network:
external: true
EOF
# 创建数据目录
mkdir -p data
```
### 步骤5: 提交代码并触发CI/CD
```bash
# 在本地项目目录
cd /Users/zhangxiang/Codes/Gitee/home-page/novalon-website
# 添加所有文件
git add .
# 提交代码
git commit -m "feat: 配置全自动CI/CD工作流
- 添加完整的CI/CD流水线配置
- 配置代码质量检查(lint, type-check, security)
- 配置分层测试策略(fast, standard, deep)
- 配置Docker镜像构建和推送
- 配置自动部署到staging和production环境
- 配置健康检查和自动回滚
- 配置成功/失败通知
- 添加健康检查API端点
- 创建CI/CD配置文档"
# 推送到develop分支
git push -u origin develop
# 或者推送到main分支
git push -u origin main
```
## 📊 验证CI/CD流水线
### 1. 查看构建状态
```bash
# 访问Woodpecker CI
https://ci.f.novalon.cn/novalon/website
# 查看构建日志
# 每个步骤都有详细的日志输出
```
### 2. 验证部署
#### Staging环境 (develop分支)
```bash
# 检查容器状态
ssh root@139.155.109.62
docker ps | grep novalon-website
# 查看容器日志
docker logs novalon-website -f
# 健康检查
curl http://localhost:3000/api/health
```
#### Production环境 (main分支)
```bash
# 检查容器状态
ssh root@139.155.109.62
docker ps | grep novalon-website
# 查看容器日志
docker logs novalon-website -f
# 健康检查
curl https://novalon.cn/api/health
```
### 3. 验证通知
如果配置了Webhook,您应该会收到通知:
- ✅ 成功通知:绿色,包含构建信息
- ❌ 失败通知:红色,包含错误信息和构建链接
## 🔄 日常使用流程
### 开发新功能
```bash
# 1. 创建功能分支
git checkout -b feature/new-feature
# 2. 开发并提交
git add .
git commit -m "feat: 添加新功能"
# 3. 推送到远程
git push origin feature/new-feature
# 4. 在Gitea创建Pull Request
# 访问: https://git.f.novalon.cn/novalon/novalon-website/pulls
# 5. CI自动运行测试
# - Lint检查
# - 类型检查
# - 单元测试
# - Smoke测试
# 6. 代码审查通过后合并到develop
# - 自动触发完整测试
# - 自动构建Docker镜像
# - 自动部署到Staging环境
# 7. 测试通过后合并到main
# - 自动触发完整测试
# - 自动构建Docker镜像
# - 自动部署到Production环境
```
### 紧急修复
```bash
# 1. 创建hotfix分支
git checkout -b hotfix/critical-fix main
# 2. 修复并提交
git add .
git commit -m "fix: 修复关键问题"
# 3. 推送并创建PR
git push origin hotfix/critical-fix
# 4. 快速审查并合并到main
# - 自动部署到Production
# - 自动回滚机制保障
```
## 🛠️ 故障排查
### 构建失败
```bash
# 1. 查看Woodpecker CI日志
https://ci.f.novalon.cn/novalon/novalon-website
# 2. 常见原因
# - 依赖安装失败
# - TypeScript类型错误
# - 测试失败
# - Docker构建失败
# 3. 本地重现
npm ci
npm run lint
npm run type-check
npm run test:coverage:check
npm run build
```
### 部署失败
```bash
# 1. SSH到服务器
ssh root@139.155.109.62
# 2. 检查容器状态
docker ps -a | grep novalon-website
# 3. 查看容器日志
docker logs novalon-website
# 4. 检查健康状态
curl http://localhost:3000/api/health
# 5. 手动回滚
docker images | grep novalon-website
docker tag novalon-website:backup-<commit-sha> novalon-website:latest
cd /home/novalon/docker-app/novalon-website
docker-compose up -d --no-deps novalon-website
```
### 测试失败
```bash
# 1. 本地运行测试
npm run test:smoke # Smoke测试
npm run test:tier:standard # 标准测试
npm run test:tier:deep # 深度测试
# 2. 查看测试报告
npm run test:allure:open
# 3. 调试特定测试
npx playwright test --debug
```
## 📈 性能优化建议
### 1. 加速构建
```yaml
# 在.woodpecker.yml中添加缓存
cache:
- name: npm-cache
paths:
- node_modules
- e2e/node_modules
```
### 2. 并行执行
```yaml
# Woodpecker CI自动并行执行独立步骤
# 无需额外配置
```
### 3. 增量构建
```yaml
# 利用Docker层缓存
# 在Dockerfile中优化层顺序
```
## 🔐 安全最佳实践
### 1. 密钥管理
- ✅ 所有密钥存储在Woodpecker CI中
- ✅ 不在代码中硬编码
- ✅ 定期轮换密钥
### 2. 访问控制
- ✅ main分支受保护
- ✅ PR需要代码审查
- ✅ 部署需要审批
### 3. 安全扫描
- ✅ npm audit自动扫描
- ✅ 定期更新依赖
- ✅ 修复高危漏洞
## 📞 获取帮助
如有问题,请:
1. 查看 [CI/CD配置文档](./CICD_GUIDE.md)
2. 检查Woodpecker CI日志
3. 联系运维团队: ops@novalon.cn
---
**最后更新**: 2026-03-27
**版本**: 1.0.0
@@ -0,0 +1,255 @@
# Monorepo 多站点架构设计方案
## 背景
当企业需要为多个产品/项目创建独立展示时,面临架构选择:**单独页面** vs **独立网站**
经过需求分析,确定以下约束条件:
| 维度 | 需求 | 架构影响 |
|------|------|----------|
| 产品数量 | 动态增长,未来持续增加 | 需要高可扩展性 |
| 品牌关系 | 独立子品牌 | 需要视觉独立性 |
| 团队规模 | 1-2人精简团队 | 需要低维护成本 |
| SEO要求 | 高要求,独立域名 | 需要独立部署能力 |
## 方案对比
### 方案A:独立网站(多仓库)
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 产品A (独立仓库) │ │ 产品B (独立仓库) │ │ 产品C (独立仓库) │
│ product-a.com │ │ product-b.com │ │ product-c.com │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
**优点**:完全独立、SEO最优、互不影响
**缺点**:❌ 维护成本极高、代码重复严重、安全更新繁琐
### 方案B:单站内嵌页面
```
┌──────────────────────────────────────────────────────┐
│ novalon.cn (主站) │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ /product-a │ │ /product-b │ │ /product-c │ │
│ └────────────┘ └────────────┘ └────────────┘ │
└──────────────────────────────────────────────────────┘
```
**优点**:维护成本最低、部署简单
**缺点**:❌ 无法独立域名、SEO受限、品牌独立性差
### 方案CMonorepo多站点架构 ⭐ 推荐
```
┌─────────────────────────────────────────────────────────┐
│ Monorepo (统一仓库) │
├─────────────────────────────────────────────────────────┤
│ apps/ │
│ ├── main-site/ → novalon.cn │
│ ├── product-a/ → product-a.com (独立域名) │
│ ├── product-b/ → product-b.com (独立域名) │
│ └── product-c/ → product-c.com (独立域名) │
│ │
│ packages/ (共享代码) │
│ ├── ui/ → 共享组件库 │
│ ├── config/ → 共享配置 │
│ └── utils/ → 共享工具函数 │
└─────────────────────────────────────────────────────────┘
```
## 技术设计
### 目录结构
```
novalon-website/
├── apps/ # 应用层(独立部署)
│ ├── main-site/ # 主站 → novalon.cn
│ │ ├── src/
│ │ ├── next.config.ts
│ │ └── package.json
│ │
│ └── products/ # 产品站点集合
│ ├── [product-slug]/ # 产品模板(可复制)
│ │ ├── src/
│ │ ├── public/
│ │ ├── next.config.ts
│ │ └── package.json
│ └── ...
├── packages/ # 共享层(不独立部署)
│ ├── ui/ # 共享组件库
│ │ ├── components/
│ │ │ ├── base/ # 基础组件(Button、Card等)
│ │ │ └── layout/ # 布局组件(Header、Footer等)
│ │ └── package.json
│ │
│ ├── config/ # 共享配置
│ │ ├── tailwind/ # Tailwind预设
│ │ ├── eslint/ # ESLint规则
│ │ └── typescript/ # TS配置
│ │
│ └── utils/ # 共享工具
│ ├── lib/
│ └── package.json
├── turbo.json # Turborepo配置
├── pnpm-workspace.yaml # pnpm工作区配置
└── package.json # 根package.json
```
### 共享组件库设计
```
packages/ui/
├── components/
│ ├── base/ # 基础组件(完全共享)
│ │ ├── button/
│ │ ├── card/
│ │ ├── input/
│ │ └── ...
│ │
│ └── themed/ # 主题化组件(可覆盖)
│ ├── header/
│ └── footer/
├── themes/ # 主题配置
│ ├── default.ts # 默认主题
│ ├── product-a.ts # 产品A主题
│ └── product-b.ts # 产品B主题
└── lib/
└── theme-context.tsx # 主题上下文
```
**组件分层策略**
| 组件类型 | 共享程度 | 定制方式 |
|----------|----------|----------|
| 基础组件 | 100%共享 | 通过 props 和 CSS 变量覆盖样式 |
| 布局组件 | 接口共享 | 各应用可提供自己的实现 |
| 业务组件 | 不共享 | 各应用独立开发 |
### CI/CD 流水线
```
[代码推送] → [变更检测] → [增量构建] → [并行测试] → [智能部署]
↓ ↓ ↓
哪些应用变了? 只构建变的应用 只部署变的应用
```
**部署策略**
| 场景 | 构建范围 | 部署范围 |
|------|----------|----------|
| 只改了 `apps/product-a` | 只构建 product-a | 只部署 product-a |
| 改了 `packages/ui` | 构建所有应用 | 部署所有应用 |
| 改了 `packages/config` | 构建所有应用 | 部署所有应用 |
### SEO优化策略
**独立域名架构**
```
┌─────────────────────────────────────────────────────────────┐
│ Nginx 反向代理 │
├─────────────────────────────────────────────────────────────┤
│ novalon.cn → 主站容器 (localhost:3000) │
│ product-a.com → 产品A容器 (localhost:3001) │
│ product-b.com → 产品B容器 (localhost:3002) │
└─────────────────────────────────────────────────────────────┘
```
**SEO关键优势**
| SEO 要素 | 独立站点优势 |
|----------|-------------|
| 独立域名 | 搜索引擎视为独立实体,权重互不影响 |
| 独立 sitemap | 精准控制索引范围,提升爬取效率 |
| 独立 metadata | 针对产品特性优化关键词,避免稀释 |
| 独立 robots.txt | 灵活控制爬虫访问策略 |
## 迁移路径
```
阶段1: 基础设施搭建 (1-2天)
阶段2: 代码迁移与重构 (3-5天)
阶段3: 共享组件抽取 (2-3天)
阶段4: CI/CD 配置 (1-2天)
阶段5: 第一个产品站点 (2-3天)
```
### 阶段1:基础设施搭建
```bash
# 1. 创建 Monorepo 根目录结构
mkdir -p apps packages
# 2. 初始化 pnpm 工作区
cat > pnpm-workspace.yaml << EOF
packages:
- 'apps/*'
- 'apps/products/*'
- 'packages/*'
EOF
# 3. 安装 Turborepo
pnpm add -Dw turbo
```
### 阶段2:代码迁移
```bash
# 将现有代码移动到 apps/main-site
mv src apps/main-site/src
mv public apps/main-site/public
mv next.config.ts apps/main-site/
```
### 阶段3:共享组件抽取
```bash
# 创建共享 UI 包
mkdir -p packages/ui/components
# 抽取通用组件
mv apps/main-site/src/components/ui packages/ui/components/base
```
### 阶段4:创建产品站点
```bash
# 复制主站作为模板
cp -r apps/main-site apps/products/product-template
# 创建新产品站点
cp -r apps/products/product-template apps/products/product-a
```
## 决策总结
| 评估维度 | 独立网站 | 单站内嵌页面 | Monorepo多站点 |
|----------|----------|--------------|----------------|
| 独立品牌支持 | ✅ 完美 | ❌ 差 | ✅ 完美 |
| SEO独立性 | ✅ 最优 | ❌ 受限 | ✅ 最优 |
| 维护成本 | ❌ 极高 | ✅ 最低 | ✅ 低 |
| 代码复用 | ❌ 无 | ✅ 完全 | ✅ 高度复用 |
| 扩展性 | ⚠️ 中等 | ❌ 差 | ✅ 优秀 |
| 团队适配 | ❌ 不适合精简团队 | ⚠️ 不满足需求 | ✅ 完美适配 |
## 结论
针对**动态增长 + 独立子品牌 + 精简团队 + 高SEO要求**的场景,**Monorepo多站点架构**是最佳选择:
- ✅ 品牌独立:每个产品独立应用、独立域名、独立视觉
- ✅ SEO最优:独立sitemap、独立metadata、独立域名权重
- ✅ 维护高效:共享代码库、统一依赖、一次更新全局生效
- ✅ 扩展简单:新增产品只需复制模板目录
- ✅ 智能CI/CD:增量构建、按需部署、自动化流水线
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# Woodpecker CI - 通过API设置仓库为Trusted
# 用途:解决 "Insufficient trust level to use volumes" 和 "Insufficient trust level to use privileged mode" 错误
set -e
echo "=========================================="
echo "Woodpecker CI - 设置仓库为Trusted"
echo "=========================================="
echo ""
# 配置
WOODPECKER_SERVER="https://ci.f.novalon.cn"
REPO_OWNER="novalon"
REPO_NAME="novalon-website"
echo "📋 方法1: 通过Web UI设置(推荐)"
echo "=========================================="
echo ""
echo "步骤1: 访问 ${WOODPECKER_SERVER}"
echo "步骤2: 登录(使用Gitea账号)"
echo "步骤3: 选择仓库 ${REPO_OWNER}/${REPO_NAME}"
echo "步骤4: 点击右上角 Settings"
echo "步骤5: 勾选 Trusted 选项"
echo "步骤6: 点击 Save"
echo ""
echo "📋 方法2: 通过API设置"
echo "=========================================="
echo ""
echo "步骤1: 获取管理员Token"
echo " 1.1 访问 ${WOODPECKER_SERVER}"
echo " 1.2 点击右上角用户头像"
echo " 1.3 选择 Account"
echo " 1.4 复制 Token"
echo ""
echo "步骤2: 执行API请求"
echo " curl -X PATCH \"${WOODPECKER_SERVER}/api/repos/${REPO_OWNER}/${REPO_NAME}\" \\"
echo " -H \"Authorization: Bearer YOUR_TOKEN\" \\"
echo " -H \"Content-Type: application/json\" \\"
echo " -d '{\"trusted\": true}'"
echo ""
echo "📋 方法3: 通过Woodpecker Server配置"
echo "=========================================="
echo ""
echo "如果以上方法不可行,可以在Server端配置:"
echo ""
echo "步骤1: 编辑 /home/novalon/docker-app/novalon-cicd/docker-compose.yml"
echo "步骤2: 在 woodpecker-server 服务中添加:"
echo " environment:"
echo " - WOODPECKER_OPEN=true"
echo " - WOODPECKER_ADMIN=your-admin-username"
echo ""
echo "步骤3: 重启服务:"
echo " cd /home/novalon/docker-app/novalon-cicd"
echo " docker-compose restart woodpecker-server"
echo ""
echo "✅ 推荐使用方法1Web UI设置)"
echo ""
+12 -3
View File
@@ -5,11 +5,20 @@
src: url('/fonts/AoyagiReisho.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
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);
@@ -39,8 +48,8 @@
/* 文字色系 - 墨色层次 */
--color-text-primary: #1C1C1C;
--color-text-secondary: #3D3D3D;
--color-text-tertiary: #4A4A4A;
--color-text-muted: #6B6B6B;
--color-text-tertiary: #404040; /* 从 #4A4A4A 调整,提升对比度 */
--color-text-muted: #595959; /* 从 #6B6B6B 调整,确保 WCAG AA 合规 */
/* 边框色系 */
--color-border-primary: #E5E5E5;
+12
View File
@@ -8,6 +8,8 @@ import { Analytics } from "@vercel/analytics/react";
import { OrganizationSchema, WebsiteSchema } from "@/components/seo/structured-data";
import { MobileTabBar } from "@/components/layout/mobile-tab-bar";
import { ErrorBoundary } from "@/components/ui/error-boundary";
import { ScrollProgress } from "@/components/ui/scroll-progress";
import { BackToTop } from "@/components/ui/back-to-top";
import { SessionProvider } from "@/providers/session-provider";
import { initSentry } from "@/lib/sentry";
@@ -122,6 +124,14 @@ 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 />
<script
@@ -141,6 +151,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} ${notoSansSC.variable} ${maShanZheng.variable} ${longCang.variable} font-sans antialiased`}
style={{ fontFamily: "'Noto Sans SC', 'Geist', -apple-system, BlinkMacSystemFont, sans-serif" }}
>
<ScrollProgress />
<GoogleAnalytics />
<WebVitals />
<SessionProvider>
@@ -151,6 +162,7 @@ export default function RootLayout({
</ThemeProvider>
</SessionProvider>
<MobileTabBar />
<BackToTop />
<Analytics />
</body>
</html>
+2 -2
View File
@@ -17,7 +17,7 @@ export function AboutSection() {
return (
<section id="about" role="region" aria-labelledby="about-heading" className="py-24 bg-[#FAFAFA] relative" ref={ref}>
<div className="absolute inset-0 bg-[linear-gradient(rgba(28,28,28,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(28,28,28,0.02)_1px,transparent_1px)] bg-[size:40px_40px]" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(28,28,28,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(28,28,28,0.02)_1px,transparent_1px)] bg-size-[40px_40px]" />
<div className="container-wide relative z-10">
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
@@ -36,7 +36,7 @@ export function AboutSection() {
<div className="bg-white rounded-2xl p-8 mb-12 border border-[#E5E5E5]">
<p className="text-lg text-[#5C5C5C] leading-relaxed text-center mb-6">
"企业需要的,不是一个高高在上的'专家',也不是一个做完就跑的'卖家',而是一个能坐下来、一起想办法的同行者。"
&ldquo;&lsquo;&rsquo;&lsquo;&rsquo;&rdquo;
</p>
<p className="text-[#1C1C1C] font-medium text-center">
+51 -10
View File
@@ -9,7 +9,8 @@ import { Toast } from '@/components/ui/toast';
import { sanitizeInput } from '@/lib/sanitize';
import { generateCSRFToken, setCSRFTokenToStorage, getCSRFTokenFromStorage } from '@/lib/csrf';
import { generateCaptcha } from '@/lib/security/captcha';
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, RefreshCw } from 'lucide-react';
import { useFormAutosave } from '@/hooks/use-form-autosave';
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, RefreshCw, Save } from 'lucide-react';
import { COMPANY_INFO } from '@/lib/constants';
const contactFormSchema = z.object({
@@ -36,17 +37,29 @@ export function ContactSection() {
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const [formData, setFormData] = useState<ContactFormData>({
name: '',
phone: '',
email: '',
message: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [captcha, setCaptcha] = useState(generateCaptcha('simple'));
const [captchaAnswer, setCaptchaAnswer] = useState('');
const sectionRef = useRef<HTMLElement>(null);
// 使用表单自动保存功能
const {
data: formData,
updateData,
lastSaved,
isRestored,
clearSavedData,
} = useFormAutosave<ContactFormData>({
key: 'contact_form',
initialData: {
name: '',
phone: '',
email: '',
message: '',
},
debounceMs: 1000,
});
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
@@ -83,7 +96,7 @@ export function ContactSection() {
const handleChange = (field: keyof ContactFormData, value: string) => {
const sanitizedValue = sanitizeInput(value);
setFormData((prev) => ({ ...prev, [field]: sanitizedValue }));
updateData({ [field]: sanitizedValue });
if (errors[field]) {
validateField(field, sanitizedValue);
}
@@ -163,6 +176,7 @@ export function ContactSection() {
setIsSubmitting(false);
setIsSubmitted(true);
clearSavedData(); // 提交成功后清除保存的数据
setToastMessage('表单提交成功!我们会尽快与您联系。');
setToastType('success');
setShowToast(true);
@@ -277,7 +291,7 @@ export function ContactSection() {
</div>
</div>
<div
<div
className={`
lg:col-span-3 flex flex-col
opacity-0 translate-y-4
@@ -285,7 +299,34 @@ export function ContactSection() {
`}
>
<div className="bg-[#F5F7FA] p-6 sm:p-8 rounded-lg border border-[#E2E8F0] flex-1 flex flex-col">
<h3 className="text-lg font-semibold text-[#1A1A2E] mb-6"></h3>
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-[#1A1A2E]"></h3>
{/* 自动保存状态指示器 */}
<div className="flex items-center gap-2 text-sm text-[#595959]">
{lastSaved && (
<>
<Save className="w-4 h-4" />
<span> {lastSaved.toLocaleTimeString()}</span>
</>
)}
</div>
</div>
{/* 数据恢复提示 */}
{isRestored && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
<span className="text-sm text-blue-700">
</span>
<button
type="button"
onClick={clearSavedData}
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
</button>
</div>
)}
{isSubmitted ? (
<div className="text-center py-12 flex-1 flex items-center justify-center" data-testid="success-message">
+81
View File
@@ -0,0 +1,81 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { BackToTop } from './back-to-top';
// Mock useReducedMotion
jest.mock('@/hooks/use-reduced-motion', () => ({
useReducedMotion: () => false,
}));
// Mock AnimatePresence to always render children
jest.mock('framer-motion', () => ({
...jest.requireActual('framer-motion'),
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
motion: {
button: ({ children, ...props }: React.ComponentProps<'button'>) => <button {...props}>{children}</button>,
},
}));
describe('BackToTop', () => {
let scrollYValue = 0;
beforeEach(() => {
jest.clearAllMocks();
scrollYValue = 0;
Object.defineProperty(window, 'scrollY', {
get: () => scrollYValue,
configurable: true,
});
window.scrollTo = jest.fn();
window.addEventListener = jest.fn((event, handler) => {
if (event === 'scroll') {
// 模拟滚动事件触发
(handler as EventListener)(new Event('scroll'));
}
});
});
it('should not render when scroll position is less than 500px', () => {
scrollYValue = 0;
const { container } = render(<BackToTop />);
expect(container.firstChild).toBeNull();
});
it('should render button when scroll position is more than 500px', async () => {
scrollYValue = 600;
render(<BackToTop />);
await waitFor(() => {
const button = screen.getByRole('button', { name: /返回顶部/i });
expect(button).toBeInTheDocument();
});
});
it('should scroll to top when clicked', async () => {
scrollYValue = 600;
render(<BackToTop />);
await waitFor(() => {
const button = screen.getByRole('button', { name: /返回顶部/i });
fireEvent.click(button);
});
expect(window.scrollTo).toHaveBeenCalledWith({
top: 0,
behavior: 'smooth',
});
});
it('should have correct aria attributes', async () => {
scrollYValue = 600;
render(<BackToTop />);
await waitFor(() => {
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', '返回顶部');
expect(button).toHaveAttribute('title', '返回顶部');
});
});
});
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowUp } from 'lucide-react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
export function BackToTop() {
const [isVisible, setIsVisible] = useState(false);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
const handleScroll = () => {
// 当滚动超过 500px 时显示按钮
setIsVisible(window.scrollY > 500);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: shouldReduceMotion ? 'auto' : 'smooth',
});
};
return (
<AnimatePresence>
{isVisible && (
<motion.button
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={shouldReduceMotion ? {} : { opacity: 0, y: 20, scale: 0.8 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
onClick={scrollToTop}
className="fixed bottom-8 right-8 z-50 p-3 bg-[#C41E3A] text-white rounded-full shadow-lg hover:bg-[#A01830] hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2"
aria-label="返回顶部"
title="返回顶部"
style={{
boxShadow: '0 4px 14px rgba(196, 30, 58, 0.4)',
}}
whileHover={shouldReduceMotion ? {} : { scale: 1.1 }}
whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
>
<ArrowUp className="w-6 h-6" />
</motion.button>
)}
</AnimatePresence>
);
}
+1 -1
View File
@@ -29,7 +29,7 @@ export class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="flex items-center justify-center min-h-[400px] p-8" role="alert" aria-live="assertive">
<div className="flex items-center justify-center min-h-100 p-8" role="alert" aria-live="assertive">
<div className="text-center max-w-md">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4" aria-hidden="true">
<svg
+67 -104
View File
@@ -1,136 +1,99 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import { OptimizedImage } from './optimized-image';
// Mock next/image
jest.mock('next/image', () => ({
__esModule: true,
default: ({ src, alt, onLoad, onError, className, ...props }: any) => (
default: ({ onLoad, onError, className, ...props }: React.ComponentProps<'img'>) => (
<img
src={src}
alt={alt}
{...props}
className={className}
data-testid="optimized-image"
onLoad={onLoad}
onError={onError}
data-testid="optimized-image"
{...props}
/>
),
}));
describe('OptimizedImage', () => {
const defaultProps = {
src: '/test.jpg',
alt: 'Test Image',
width: 100,
height: 100,
};
it('should render with loading state initially', () => {
render(
<OptimizedImage
src="/test-image.jpg"
alt="Test image"
width={400}
height={300}
/>
);
beforeEach(() => {
jest.clearAllMocks();
// 应该显示加载动画
expect(screen.getByTestId('optimized-image')).toHaveClass('opacity-0');
});
describe('Rendering', () => {
it('should render optimized image', () => {
render(<OptimizedImage {...defaultProps} />);
expect(screen.getByTestId('optimized-image')).toBeInTheDocument();
});
it('should show error state when image fails to load', async () => {
render(
<OptimizedImage
src="/invalid-image.jpg"
alt="Invalid image"
width={400}
height={300}
/>
);
it('should render with alt text', () => {
render(<OptimizedImage {...defaultProps} />);
expect(screen.getByAltText('Test Image')).toBeInTheDocument();
});
const img = screen.getByTestId('optimized-image');
// 触发错误事件
img.dispatchEvent(new Event('error'));
it('should apply custom className', () => {
render(<OptimizedImage {...defaultProps} className="custom-class" />);
const image = screen.getByTestId('optimized-image');
expect(image).toHaveClass('custom-class');
});
it('should apply container className', () => {
const { container } = render(
<OptimizedImage {...defaultProps} containerClassName="container-class" />
);
expect(container.firstChild).toHaveClass('container-class');
await waitFor(() => {
expect(screen.getByText('图片加载失败')).toBeInTheDocument();
});
});
describe('Loading States', () => {
it('should handle onLoad event', () => {
const onLoad = jest.fn();
render(<OptimizedImage {...defaultProps} onLoad={onLoad} />);
const image = screen.getByTestId('optimized-image');
fireEvent.load(image);
expect(onLoad).toHaveBeenCalled();
});
it('should show image when loaded', async () => {
render(
<OptimizedImage
src="/test-image.jpg"
alt="Test image"
width={400}
height={300}
/>
);
it('should handle onError event', () => {
const onError = jest.fn();
render(<OptimizedImage {...defaultProps} onError={onError} />);
const image = screen.getByTestId('optimized-image');
fireEvent.error(image);
expect(onError).toHaveBeenCalled();
});
const img = screen.getByTestId('optimized-image');
// 触发加载完成事件
img.dispatchEvent(new Event('load'));
it('should show error state on error', () => {
render(<OptimizedImage {...defaultProps} />);
const image = screen.getByTestId('optimized-image');
fireEvent.error(image);
const errorIcon = document.querySelector('svg');
expect(errorIcon).toBeInTheDocument();
await waitFor(() => {
expect(img).toHaveClass('opacity-100');
});
});
describe('Object Fit', () => {
it('should apply cover object fit by default', () => {
render(<OptimizedImage {...defaultProps} />);
const image = screen.getByTestId('optimized-image');
expect(image).toHaveClass('object-cover');
});
it('should render with correct alt text', () => {
render(
<OptimizedImage
src="/test-image.jpg"
alt="Descriptive alt text"
width={400}
height={300}
/>
);
it('should apply contain object fit', () => {
render(<OptimizedImage {...defaultProps} objectFit="contain" />);
const image = screen.getByTestId('optimized-image');
expect(image).toHaveClass('object-contain');
});
it('should apply fill object fit', () => {
render(<OptimizedImage {...defaultProps} objectFit="fill" />);
const image = screen.getByTestId('optimized-image');
expect(image).toHaveClass('object-fill');
});
it('should apply none object fit', () => {
render(<OptimizedImage {...defaultProps} objectFit="none" />);
const image = screen.getByTestId('optimized-image');
expect(image).toHaveClass('object-none');
});
it('should apply scale-down object fit', () => {
render(<OptimizedImage {...defaultProps} objectFit="scale-down" />);
const image = screen.getByTestId('optimized-image');
expect(image).toHaveClass('object-scale-down');
});
expect(screen.getByAltText('Descriptive alt text')).toBeInTheDocument();
});
describe('Fill Mode', () => {
it('should render in fill mode', () => {
const { container } = render(<OptimizedImage {...defaultProps} fill />);
expect(container.firstChild).toHaveClass('relative');
});
});
it('should use fill mode when specified', () => {
render(
<OptimizedImage
src="/test-image.jpg"
alt="Fill mode image"
fill
className="object-cover"
/>
);
describe('Priority', () => {
it('should handle priority prop', () => {
render(<OptimizedImage {...defaultProps} priority />);
const image = screen.getByTestId('optimized-image');
expect(image).toBeInTheDocument();
});
const container = screen.getByTestId('optimized-image').parentElement;
expect(container).toHaveClass('relative', 'overflow-hidden', 'w-full', 'h-full');
});
});
+61 -117
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useCallback } from 'react';
import Image from 'next/image';
import { useState, useCallback, memo } from 'react';
import { cn } from '@/lib/utils';
interface OptimizedImageProps {
@@ -10,159 +10,103 @@ interface OptimizedImageProps {
width?: number;
height?: number;
fill?: boolean;
priority?: boolean;
className?: string;
containerClassName?: string;
priority?: boolean;
sizes?: string;
quality?: number;
placeholder?: 'blur' | 'empty';
blurDataURL?: string;
onLoad?: () => void;
onError?: () => void;
objectFit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
objectPosition?: string;
loading?: 'lazy' | 'eager';
unoptimized?: boolean;
}
const shimmer = (w: number, h: number) => `
<svg width="${w}" height="${h}" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="g">
<stop stop-color="#f0f0f0" offset="20%" />
<stop stop-color="#e0e0e0" offset="50%" />
<stop stop-color="#f0f0f0" offset="70%" />
</linearGradient>
</defs>
<rect width="${w}" height="${h}" fill="#f0f0f0" />
<rect id="r" width="${w}" height="${h}" fill="url(#g)" />
<animate xlink:href="#r" attributeName="x" from="-${w}" to="${w}" dur="1s" repeatCount="indefinite" />
</svg>`;
const toBase64 = (str: string) =>
typeof window === 'undefined'
? Buffer.from(str).toString('base64')
: window.btoa(str);
const OptimizedImage = memo(function OptimizedImage({
export function OptimizedImage({
src,
alt,
width,
height,
fill = false,
priority = false,
className,
containerClassName,
sizes,
priority = false,
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw',
quality = 85,
placeholder = 'blur',
blurDataURL,
onLoad,
onError,
objectFit = 'cover',
objectPosition = 'center',
loading = 'lazy',
unoptimized = false,
}: OptimizedImageProps) {
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const [error, setError] = useState(false);
// 生成默认的模糊占位符
const defaultBlurDataURL = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjFmNWY5Ii8+PC9zdmc+';
// 使用 callback 来处理加载状态
const handleLoad = useCallback(() => {
setIsLoading(false);
onLoad?.();
}, [onLoad]);
setIsLoaded(true);
}, []);
const handleError = useCallback(() => {
setIsLoading(false);
setHasError(true);
onError?.();
}, [onError]);
setError(true);
}, []);
const defaultBlurDataURL = blurDataURL || (width && height ? `data:image/svg+xml;base64,${toBase64(shimmer(width, height))}` : undefined);
const objectFitClass = {
contain: 'object-contain',
cover: 'object-cover',
fill: 'object-fill',
none: 'object-none',
'scale-down': 'object-scale-down',
}[objectFit];
if (hasError) {
if (error) {
return (
<div
className={cn(
'flex items-center justify-center bg-gray-100 text-gray-400',
'bg-gray-100 flex items-center justify-center',
containerClassName
)}
style={width && height ? { width, height } : undefined}
style={!fill ? { width, height } : undefined}
>
<svg
className="w-12 h-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
);
}
const imageElement = (
<Image
src={src}
alt={alt}
width={fill ? undefined : width}
height={fill ? undefined : height}
fill={fill}
priority={priority}
sizes={sizes}
quality={quality}
placeholder={placeholder}
blurDataURL={defaultBlurDataURL}
onLoad={handleLoad}
onError={handleError}
loading={priority ? 'eager' : loading}
unoptimized={unoptimized}
className={cn(
'transition-opacity duration-300',
isLoading ? 'opacity-0' : 'opacity-100',
objectFitClass,
className
)}
style={{ objectPosition }}
/>
);
if (fill) {
return (
<div className={cn('relative overflow-hidden', containerClassName)}>
{imageElement}
{isLoading && (
<div className="absolute inset-0 animate-pulse bg-gray-200" />
)}
<span className="text-gray-400 text-sm"></span>
</div>
);
}
return (
<div className={cn('relative overflow-hidden', containerClassName)}>
{imageElement}
{isLoading && (
<div
className={cn(
'relative overflow-hidden',
fill ? 'w-full h-full' : '',
containerClassName
)}
style={!fill ? { width, height } : undefined}
>
{/* 模糊占位符背景 */}
{!isLoaded && placeholder === 'blur' && (
<div
className="absolute inset-0 animate-pulse bg-gray-200"
style={width && height ? { width, height } : undefined}
className="absolute inset-0 bg-cover bg-center blur-sm scale-110 transition-opacity duration-500"
style={{
backgroundImage: `url(${blurDataURL || defaultBlurDataURL})`,
}}
/>
)}
{/* 加载动画 */}
{!isLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-[#C41E3A] rounded-full animate-spin" />
</div>
)}
<Image
src={src}
alt={alt}
width={fill ? undefined : width}
height={fill ? undefined : height}
fill={fill}
priority={priority}
sizes={sizes}
quality={quality}
placeholder={placeholder}
blurDataURL={blurDataURL || defaultBlurDataURL}
className={cn(
'transition-opacity duration-500',
isLoaded ? 'opacity-100' : 'opacity-0',
className
)}
onLoad={handleLoad}
onError={handleError}
/>
</div>
);
});
export { OptimizedImage };
export type { OptimizedImageProps };
}
@@ -0,0 +1,54 @@
import { render, screen, waitFor } from '@testing-library/react';
import { ScrollProgress } from './scroll-progress';
// Mock framer-motion
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: React.ComponentProps<'div'>) => <div {...props}>{children}</div>,
},
useScroll: () => ({ scrollYProgress: { get: () => 0, onChange: jest.fn() } }),
useSpring: () => ({ get: () => 0 }),
}));
// Mock useReducedMotion
jest.mock('@/hooks/use-reduced-motion', () => ({
useReducedMotion: () => false,
}));
describe('ScrollProgress', () => {
let scrollYValue = 0;
beforeEach(() => {
jest.clearAllMocks();
scrollYValue = 0;
Object.defineProperty(window, 'scrollY', {
get: () => scrollYValue,
configurable: true,
});
window.addEventListener = jest.fn((event, handler) => {
if (event === 'scroll') {
(handler as EventListener)(new Event('scroll'));
}
});
});
it('should not render when scroll position is less than 100px', () => {
scrollYValue = 0;
const { container } = render(<ScrollProgress />);
expect(container.firstChild).toBeNull();
});
it('should render progressbar with correct ARIA attributes when visible', async () => {
scrollYValue = 150;
render(<ScrollProgress />);
await waitFor(() => {
const progressbar = screen.getByRole('progressbar');
expect(progressbar).toBeInTheDocument();
expect(progressbar).toHaveAttribute('aria-label', '页面滚动进度');
expect(progressbar).toHaveAttribute('aria-valuemin', '0');
expect(progressbar).toHaveAttribute('aria-valuemax', '100');
});
});
});
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, useScroll, useSpring } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
export function ScrollProgress() {
const [isVisible, setIsVisible] = useState(false);
const shouldReduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll();
// 使用弹簧动画使进度条移动更平滑
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
useEffect(() => {
// 当滚动超过 100px 时显示进度条
const handleScroll = () => {
setIsVisible(window.scrollY > 100);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
if (!isVisible) {return null;}
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={shouldReduceMotion ? {} : { opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed top-0 left-0 right-0 h-1 z-[100] bg-transparent"
role="progressbar"
aria-label="页面滚动进度"
aria-valuemin={0}
aria-valuemax={100}
>
<motion.div
className="h-full bg-gradient-to-r from-[#C41E3A] to-[#E04A68] origin-left"
style={{
scaleX,
boxShadow: '0 0 10px rgba(196, 30, 58, 0.3)',
}}
/>
</motion.div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useFontLoading } from './use-font-loading';
describe('useFontLoading', () => {
const originalFonts = document.fonts;
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
Object.defineProperty(document, 'fonts', {
value: originalFonts,
writable: true,
});
});
it('should return true when fonts API is not supported', async () => {
// 完全删除 fonts 属性来模拟不支持的情况
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalFonts = (document as unknown as Record<string, unknown>).fonts;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (document as unknown as Record<string, unknown>).fonts;
const { result } = renderHook(() => useFontLoading());
await waitFor(() => {
expect(result.current).toBe(true);
});
// 恢复 fonts 属性
Object.defineProperty(document, 'fonts', {
value: originalFonts,
writable: true,
configurable: true,
});
});
it('should return true when font is loaded', async () => {
const mockLoad = jest.fn().mockResolvedValue([]);
Object.defineProperty(document, 'fonts', {
value: { load: mockLoad },
writable: true,
});
const { result } = renderHook(() => useFontLoading('Aoyagi Reisho'));
await waitFor(() => {
expect(result.current).toBe(true);
});
expect(mockLoad).toHaveBeenCalledWith('1em "Aoyagi Reisho"');
});
it('should return true when font loading fails', async () => {
const mockLoad = jest.fn().mockRejectedValue(new Error('Font load failed'));
Object.defineProperty(document, 'fonts', {
value: { load: mockLoad },
writable: true,
});
const { result } = renderHook(() => useFontLoading());
await waitFor(() => {
expect(result.current).toBe(true);
});
});
});
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useState, useEffect } from 'react';
export function useFontLoading(fontFamily: string = 'Aoyagi Reisho') {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
// 检查字体是否已加载
if ('fonts' in document) {
document.fonts
.load(`1em "${fontFamily}"`)
.then(() => {
if (!cancelled) {
setIsLoaded(true);
}
})
.catch(() => {
// 如果字体加载失败,仍然标记为已加载,使用回退字体
if (!cancelled) {
setIsLoaded(true);
}
});
} else {
// 不支持 Font Loading API 的浏览器,使用 setTimeout 避免同步 setState
const timer = setTimeout(() => {
if (!cancelled) {
setIsLoaded(true);
}
}, 0);
return () => clearTimeout(timer);
}
return () => {
cancelled = true;
};
}, [fontFamily]);
return isLoaded;
}
export function useFontPreloader(fontUrls: string[]) {
useEffect(() => {
fontUrls.forEach((url) => {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'font';
link.href = url;
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
});
}, [fontUrls]);
}
+150
View File
@@ -0,0 +1,150 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useFormAutosave } from './use-form-autosave';
describe('useFormAutosave', () => {
const mockKey = 'test_form';
const mockInitialData = { name: '', email: '' };
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should initialize with initial data', () => {
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
})
);
expect(result.current.data).toEqual(mockInitialData);
expect(result.current.lastSaved).toBeNull();
expect(result.current.isRestored).toBe(false);
});
it('should restore data from localStorage', () => {
const savedData = {
data: { name: 'John', email: 'john@example.com' },
timestamp: new Date().toISOString(),
};
localStorage.setItem(`form_autosave_${mockKey}`, JSON.stringify(savedData));
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
})
);
expect(result.current.data).toEqual(savedData.data);
expect(result.current.isRestored).toBe(true);
});
it('should not restore expired data', () => {
const expiredData = {
data: { name: 'John', email: 'john@example.com' },
timestamp: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), // 25 hours ago
};
localStorage.setItem(`form_autosave_${mockKey}`, JSON.stringify(expiredData));
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
})
);
expect(result.current.data).toEqual(mockInitialData);
expect(result.current.isRestored).toBe(false);
});
it('should update data and auto-save after debounce', async () => {
const onSave = jest.fn();
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
onSave,
debounceMs: 1000,
})
);
act(() => {
result.current.updateData({ name: 'John' });
});
expect(result.current.data.name).toBe('John');
expect(result.current.lastSaved).toBeNull();
act(() => {
jest.advanceTimersByTime(1000);
});
await waitFor(() => {
expect(result.current.lastSaved).not.toBeNull();
});
expect(onSave).toHaveBeenCalledWith({ name: 'John', email: '' });
});
it('should clear saved data', () => {
const savedData = {
data: { name: 'John', email: 'john@example.com' },
timestamp: new Date().toISOString(),
};
localStorage.setItem(`form_autosave_${mockKey}`, JSON.stringify(savedData));
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
})
);
act(() => {
result.current.clearSavedData();
});
expect(result.current.data).toEqual(mockInitialData);
expect(result.current.lastSaved).toBeNull();
expect(result.current.isRestored).toBe(false);
expect(localStorage.getItem(`form_autosave_${mockKey}`)).toBeNull();
});
it('should save immediately when called', async () => {
const onSave = jest.fn();
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
onSave,
debounceMs: 5000,
})
);
act(() => {
result.current.updateData({ name: 'John' });
});
// 等待状态更新
await waitFor(() => {
expect(result.current.data.name).toBe('John');
});
act(() => {
result.current.saveImmediately();
});
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith({ name: 'John', email: '' });
});
expect(result.current.lastSaved).not.toBeNull();
});
});
+146
View File
@@ -0,0 +1,146 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
interface UseFormAutosaveOptions<T> {
key: string;
initialData: T;
onSave?: (data: T) => void;
debounceMs?: number;
maxAge?: number; // 数据最大保存时间(毫秒),默认 24 小时
}
interface AutosaveState<T> {
data: T;
lastSaved: Date | null;
isRestored: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useFormAutosave<T extends Record<string, any>>({
key,
initialData,
onSave,
debounceMs = 1000,
maxAge = 24 * 60 * 60 * 1000, // 24 小时
}: UseFormAutosaveOptions<T>) {
const [state, setState] = useState<AutosaveState<T>>({
data: initialData,
lastSaved: null,
isRestored: false,
});
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const storageKey = `form_autosave_${key}`;
// 从 localStorage 恢复数据
useEffect(() => {
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsed = JSON.parse(saved);
const savedTime = new Date(parsed.timestamp).getTime();
const now = Date.now();
// 检查数据是否过期
if (now - savedTime < maxAge) {
setState({
data: { ...initialData, ...parsed.data },
lastSaved: new Date(parsed.timestamp),
isRestored: true,
});
} else {
// 数据过期,清除
localStorage.removeItem(storageKey);
}
}
} catch (error) {
console.error('Failed to restore form data:', error);
}
}, [storageKey, initialData, maxAge]);
// 自动保存到 localStorage
const saveToStorage = useCallback((data: T) => {
try {
const payload = {
data,
timestamp: new Date().toISOString(),
};
localStorage.setItem(storageKey, JSON.stringify(payload));
setState(prev => ({
...prev,
lastSaved: new Date(),
}));
onSave?.(data);
} catch (error) {
console.error('Failed to save form data:', error);
}
}, [storageKey, onSave]);
// 防抖保存
const debouncedSave = useCallback((data: T) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
saveToStorage(data);
}, debounceMs);
}, [debounceMs, saveToStorage]);
// 更新数据
const updateData = useCallback((updates: Partial<T> | ((prev: T) => Partial<T>)) => {
setState(prev => {
const newData = typeof updates === 'function'
? { ...prev.data, ...updates(prev.data) }
: { ...prev.data, ...updates };
debouncedSave(newData);
return {
...prev,
data: newData,
};
});
}, [debouncedSave]);
// 清除保存的数据
const clearSavedData = useCallback(() => {
try {
localStorage.removeItem(storageKey);
setState({
data: initialData,
lastSaved: null,
isRestored: false,
});
} catch (error) {
console.error('Failed to clear form data:', error);
}
}, [storageKey, initialData]);
// 立即保存(用于表单提交前)
const saveImmediately = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
saveToStorage(state.data);
}, [state.data, saveToStorage]);
// 清理
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return {
data: state.data,
lastSaved: state.lastSaved,
isRestored: state.isRestored,
updateData,
clearSavedData,
saveImmediately,
};
}
-2
View File
@@ -46,8 +46,6 @@
"exclude": [
"node_modules",
"tests",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"e2e"
]
}