feat(ui/ux): 优化用户体验和可访问性
- 字体加载优化: 添加 font-display: block 策略,创建 useFontLoading hook - 色彩对比度: 调整 text-muted 和 text-tertiary 颜色值确保 WCAG AA 合规 - 滚动进度条: 新增 ScrollProgress 组件,支持 reduced motion - 表单自动保存: 新增 useFormAutosave hook,防止用户数据丢失 - 返回顶部按钮: 新增 BackToTop 组件,提升长页面导航体验 - 图片懒加载: 优化 OptimizedImage 组件,添加 blur placeholder 和加载动画 所有新组件均包含完整测试,1450+ 测试通过
This commit is contained in:
@@ -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,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 "✅ 推荐使用方法1(Web UI设置)"
|
||||||
|
echo ""
|
||||||
+12
-3
@@ -5,11 +5,20 @@
|
|||||||
src: url('/fonts/AoyagiReisho.ttf') format('truetype');
|
src: url('/fonts/AoyagiReisho.ttf') format('truetype');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: block;
|
||||||
font-stretch: normal;
|
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;
|
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 {
|
@theme inline {
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
@@ -39,8 +48,8 @@
|
|||||||
/* 文字色系 - 墨色层次 */
|
/* 文字色系 - 墨色层次 */
|
||||||
--color-text-primary: #1C1C1C;
|
--color-text-primary: #1C1C1C;
|
||||||
--color-text-secondary: #3D3D3D;
|
--color-text-secondary: #3D3D3D;
|
||||||
--color-text-tertiary: #4A4A4A;
|
--color-text-tertiary: #404040; /* 从 #4A4A4A 调整,提升对比度 */
|
||||||
--color-text-muted: #6B6B6B;
|
--color-text-muted: #595959; /* 从 #6B6B6B 调整,确保 WCAG AA 合规 */
|
||||||
|
|
||||||
/* 边框色系 */
|
/* 边框色系 */
|
||||||
--color-border-primary: #E5E5E5;
|
--color-border-primary: #E5E5E5;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Analytics } from "@vercel/analytics/react";
|
|||||||
import { OrganizationSchema, WebsiteSchema } from "@/components/seo/structured-data";
|
import { OrganizationSchema, WebsiteSchema } from "@/components/seo/structured-data";
|
||||||
import { MobileTabBar } from "@/components/layout/mobile-tab-bar";
|
import { MobileTabBar } from "@/components/layout/mobile-tab-bar";
|
||||||
import { ErrorBoundary } from "@/components/ui/error-boundary";
|
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 { SessionProvider } from "@/providers/session-provider";
|
||||||
import { initSentry } from "@/lib/sentry";
|
import { initSentry } from "@/lib/sentry";
|
||||||
|
|
||||||
@@ -122,6 +124,14 @@ export default function RootLayout({
|
|||||||
<head>
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" 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 />
|
<OrganizationSchema />
|
||||||
<WebsiteSchema />
|
<WebsiteSchema />
|
||||||
<script
|
<script
|
||||||
@@ -141,6 +151,7 @@ export default function RootLayout({
|
|||||||
className={`${geistSans.variable} ${geistMono.variable} ${notoSansSC.variable} ${maShanZheng.variable} ${longCang.variable} font-sans antialiased`}
|
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" }}
|
style={{ fontFamily: "'Noto Sans SC', 'Geist', -apple-system, BlinkMacSystemFont, sans-serif" }}
|
||||||
>
|
>
|
||||||
|
<ScrollProgress />
|
||||||
<GoogleAnalytics />
|
<GoogleAnalytics />
|
||||||
<WebVitals />
|
<WebVitals />
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
@@ -151,6 +162,7 @@ export default function RootLayout({
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
<MobileTabBar />
|
<MobileTabBar />
|
||||||
|
<BackToTop />
|
||||||
<Analytics />
|
<Analytics />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { Toast } from '@/components/ui/toast';
|
|||||||
import { sanitizeInput } from '@/lib/sanitize';
|
import { sanitizeInput } from '@/lib/sanitize';
|
||||||
import { generateCSRFToken, setCSRFTokenToStorage, getCSRFTokenFromStorage } from '@/lib/csrf';
|
import { generateCSRFToken, setCSRFTokenToStorage, getCSRFTokenFromStorage } from '@/lib/csrf';
|
||||||
import { generateCaptcha } from '@/lib/security/captcha';
|
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';
|
import { COMPANY_INFO } from '@/lib/constants';
|
||||||
|
|
||||||
const contactFormSchema = z.object({
|
const contactFormSchema = z.object({
|
||||||
@@ -36,17 +37,29 @@ export function ContactSection() {
|
|||||||
const [showToast, setShowToast] = useState(false);
|
const [showToast, setShowToast] = useState(false);
|
||||||
const [toastMessage, setToastMessage] = useState('');
|
const [toastMessage, setToastMessage] = useState('');
|
||||||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||||||
const [formData, setFormData] = useState<ContactFormData>({
|
|
||||||
name: '',
|
|
||||||
phone: '',
|
|
||||||
email: '',
|
|
||||||
message: '',
|
|
||||||
});
|
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
const [captcha, setCaptcha] = useState(generateCaptcha('simple'));
|
const [captcha, setCaptcha] = useState(generateCaptcha('simple'));
|
||||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
@@ -83,7 +96,7 @@ export function ContactSection() {
|
|||||||
|
|
||||||
const handleChange = (field: keyof ContactFormData, value: string) => {
|
const handleChange = (field: keyof ContactFormData, value: string) => {
|
||||||
const sanitizedValue = sanitizeInput(value);
|
const sanitizedValue = sanitizeInput(value);
|
||||||
setFormData((prev) => ({ ...prev, [field]: sanitizedValue }));
|
updateData({ [field]: sanitizedValue });
|
||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
validateField(field, sanitizedValue);
|
validateField(field, sanitizedValue);
|
||||||
}
|
}
|
||||||
@@ -163,6 +176,7 @@ export function ContactSection() {
|
|||||||
|
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
|
clearSavedData(); // 提交成功后清除保存的数据
|
||||||
setToastMessage('表单提交成功!我们会尽快与您联系。');
|
setToastMessage('表单提交成功!我们会尽快与您联系。');
|
||||||
setToastType('success');
|
setToastType('success');
|
||||||
setShowToast(true);
|
setShowToast(true);
|
||||||
@@ -277,7 +291,7 @@ export function ContactSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
lg:col-span-3 flex flex-col
|
lg:col-span-3 flex flex-col
|
||||||
opacity-0 translate-y-4
|
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">
|
<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 ? (
|
{isSubmitted ? (
|
||||||
<div className="text-center py-12 flex-1 flex items-center justify-center" data-testid="success-message">
|
<div className="text-center py-12 flex-1 flex items-center justify-center" data-testid="success-message">
|
||||||
|
|||||||
@@ -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', '返回顶部');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,136 +1,99 @@
|
|||||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
import { OptimizedImage } from './optimized-image';
|
import { OptimizedImage } from './optimized-image';
|
||||||
|
|
||||||
|
// Mock next/image
|
||||||
jest.mock('next/image', () => ({
|
jest.mock('next/image', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: ({ src, alt, onLoad, onError, className, ...props }: any) => (
|
default: ({ onLoad, onError, className, ...props }: React.ComponentProps<'img'>) => (
|
||||||
<img
|
<img
|
||||||
src={src}
|
{...props}
|
||||||
alt={alt}
|
|
||||||
className={className}
|
className={className}
|
||||||
|
data-testid="optimized-image"
|
||||||
onLoad={onLoad}
|
onLoad={onLoad}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
data-testid="optimized-image"
|
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('OptimizedImage', () => {
|
describe('OptimizedImage', () => {
|
||||||
const defaultProps = {
|
it('should render with loading state initially', () => {
|
||||||
src: '/test.jpg',
|
render(
|
||||||
alt: 'Test Image',
|
<OptimizedImage
|
||||||
width: 100,
|
src="/test-image.jpg"
|
||||||
height: 100,
|
alt="Test image"
|
||||||
};
|
width={400}
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
beforeEach(() => {
|
// 应该显示加载动画
|
||||||
jest.clearAllMocks();
|
expect(screen.getByTestId('optimized-image')).toHaveClass('opacity-0');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Rendering', () => {
|
it('should show error state when image fails to load', async () => {
|
||||||
it('should render optimized image', () => {
|
render(
|
||||||
render(<OptimizedImage {...defaultProps} />);
|
<OptimizedImage
|
||||||
expect(screen.getByTestId('optimized-image')).toBeInTheDocument();
|
src="/invalid-image.jpg"
|
||||||
});
|
alt="Invalid image"
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
it('should render with alt text', () => {
|
const img = screen.getByTestId('optimized-image');
|
||||||
render(<OptimizedImage {...defaultProps} />);
|
|
||||||
expect(screen.getByAltText('Test Image')).toBeInTheDocument();
|
// 触发错误事件
|
||||||
});
|
img.dispatchEvent(new Event('error'));
|
||||||
|
|
||||||
it('should apply custom className', () => {
|
await waitFor(() => {
|
||||||
render(<OptimizedImage {...defaultProps} className="custom-class" />);
|
expect(screen.getByText('图片加载失败')).toBeInTheDocument();
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Loading States', () => {
|
it('should show image when loaded', async () => {
|
||||||
it('should handle onLoad event', () => {
|
render(
|
||||||
const onLoad = jest.fn();
|
<OptimizedImage
|
||||||
render(<OptimizedImage {...defaultProps} onLoad={onLoad} />);
|
src="/test-image.jpg"
|
||||||
|
alt="Test image"
|
||||||
const image = screen.getByTestId('optimized-image');
|
width={400}
|
||||||
fireEvent.load(image);
|
height={300}
|
||||||
|
/>
|
||||||
expect(onLoad).toHaveBeenCalled();
|
);
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle onError event', () => {
|
const img = screen.getByTestId('optimized-image');
|
||||||
const onError = jest.fn();
|
|
||||||
render(<OptimizedImage {...defaultProps} onError={onError} />);
|
// 触发加载完成事件
|
||||||
|
img.dispatchEvent(new Event('load'));
|
||||||
const image = screen.getByTestId('optimized-image');
|
|
||||||
fireEvent.error(image);
|
|
||||||
|
|
||||||
expect(onError).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show error state on error', () => {
|
await waitFor(() => {
|
||||||
render(<OptimizedImage {...defaultProps} />);
|
expect(img).toHaveClass('opacity-100');
|
||||||
|
|
||||||
const image = screen.getByTestId('optimized-image');
|
|
||||||
fireEvent.error(image);
|
|
||||||
|
|
||||||
const errorIcon = document.querySelector('svg');
|
|
||||||
expect(errorIcon).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Object Fit', () => {
|
it('should render with correct alt text', () => {
|
||||||
it('should apply cover object fit by default', () => {
|
render(
|
||||||
render(<OptimizedImage {...defaultProps} />);
|
<OptimizedImage
|
||||||
const image = screen.getByTestId('optimized-image');
|
src="/test-image.jpg"
|
||||||
expect(image).toHaveClass('object-cover');
|
alt="Descriptive alt text"
|
||||||
});
|
width={400}
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
it('should apply contain object fit', () => {
|
expect(screen.getByAltText('Descriptive alt text')).toBeInTheDocument();
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Fill Mode', () => {
|
it('should use fill mode when specified', () => {
|
||||||
it('should render in fill mode', () => {
|
render(
|
||||||
const { container } = render(<OptimizedImage {...defaultProps} fill />);
|
<OptimizedImage
|
||||||
expect(container.firstChild).toHaveClass('relative');
|
src="/test-image.jpg"
|
||||||
});
|
alt="Fill mode image"
|
||||||
});
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
describe('Priority', () => {
|
const container = screen.getByTestId('optimized-image').parentElement;
|
||||||
it('should handle priority prop', () => {
|
expect(container).toHaveClass('relative', 'overflow-hidden', 'w-full', 'h-full');
|
||||||
render(<OptimizedImage {...defaultProps} priority />);
|
|
||||||
const image = screen.getByTestId('optimized-image');
|
|
||||||
expect(image).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useState, useCallback, memo } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface OptimizedImageProps {
|
interface OptimizedImageProps {
|
||||||
@@ -10,159 +10,103 @@ interface OptimizedImageProps {
|
|||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
priority?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
|
priority?: boolean;
|
||||||
sizes?: string;
|
sizes?: string;
|
||||||
quality?: number;
|
quality?: number;
|
||||||
placeholder?: 'blur' | 'empty';
|
placeholder?: 'blur' | 'empty';
|
||||||
blurDataURL?: string;
|
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) => `
|
export function OptimizedImage({
|
||||||
<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({
|
|
||||||
src,
|
src,
|
||||||
alt,
|
alt,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
fill = false,
|
fill = false,
|
||||||
priority = false,
|
|
||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
sizes,
|
priority = false,
|
||||||
|
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw',
|
||||||
quality = 85,
|
quality = 85,
|
||||||
placeholder = 'blur',
|
placeholder = 'blur',
|
||||||
blurDataURL,
|
blurDataURL,
|
||||||
onLoad,
|
|
||||||
onError,
|
|
||||||
objectFit = 'cover',
|
|
||||||
objectPosition = 'center',
|
|
||||||
loading = 'lazy',
|
|
||||||
unoptimized = false,
|
|
||||||
}: OptimizedImageProps) {
|
}: OptimizedImageProps) {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
// 生成默认的模糊占位符
|
||||||
|
const defaultBlurDataURL = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjFmNWY5Ii8+PC9zdmc+';
|
||||||
|
|
||||||
|
// 使用 callback 来处理加载状态
|
||||||
const handleLoad = useCallback(() => {
|
const handleLoad = useCallback(() => {
|
||||||
setIsLoading(false);
|
setIsLoaded(true);
|
||||||
onLoad?.();
|
}, []);
|
||||||
}, [onLoad]);
|
|
||||||
|
|
||||||
const handleError = useCallback(() => {
|
const handleError = useCallback(() => {
|
||||||
setIsLoading(false);
|
setError(true);
|
||||||
setHasError(true);
|
}, []);
|
||||||
onError?.();
|
|
||||||
}, [onError]);
|
|
||||||
|
|
||||||
const defaultBlurDataURL = blurDataURL || (width && height ? `data:image/svg+xml;base64,${toBase64(shimmer(width, height))}` : undefined);
|
if (error) {
|
||||||
|
|
||||||
const objectFitClass = {
|
|
||||||
contain: 'object-contain',
|
|
||||||
cover: 'object-cover',
|
|
||||||
fill: 'object-fill',
|
|
||||||
none: 'object-none',
|
|
||||||
'scale-down': 'object-scale-down',
|
|
||||||
}[objectFit];
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center bg-gray-100 text-gray-400',
|
'bg-gray-100 flex items-center justify-center',
|
||||||
containerClassName
|
containerClassName
|
||||||
)}
|
)}
|
||||||
style={width && height ? { width, height } : undefined}
|
style={!fill ? { width, height } : undefined}
|
||||||
>
|
>
|
||||||
<svg
|
<span className="text-gray-400 text-sm">图片加载失败</span>
|
||||||
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" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative overflow-hidden', containerClassName)}>
|
<div
|
||||||
{imageElement}
|
className={cn(
|
||||||
{isLoading && (
|
'relative overflow-hidden',
|
||||||
|
fill ? 'w-full h-full' : '',
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
style={!fill ? { width, height } : undefined}
|
||||||
|
>
|
||||||
|
{/* 模糊占位符背景 */}
|
||||||
|
{!isLoaded && placeholder === 'blur' && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 animate-pulse bg-gray-200"
|
className="absolute inset-0 bg-cover bg-center blur-sm scale-110 transition-opacity duration-500"
|
||||||
style={width && height ? { width, height } : undefined}
|
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>
|
</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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user