feat: downgrade tech stack to stable versions and integrate GA4 error monitoring
- Downgrade Next.js 16→14.2, React 19→18.3, Tailwind 4→3.4 - Add comprehensive GA4 error monitoring system - Create Jenkins CI/CD pipeline with quality gates - Fix build issues: ESLint, SWC conflict, config format - Add documentation for deployment and error tracking
This commit is contained in:
@@ -1 +1,10 @@
|
||||
# Google Analytics (生产环境中应配置真实值)
|
||||
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||
|
||||
# Sentry 错误监控 (免费版 - https://sentry.io/signup)
|
||||
# 获取方式: 登录 sentry.io → 创建项目 → Settings → Client Keys (DSN)
|
||||
NEXT_PUBLIC_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
|
||||
SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
|
||||
|
||||
# CDN 配置(可选)
|
||||
CDN_DOMAIN=
|
||||
|
||||
@@ -207,6 +207,7 @@ develop-eggs/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
!src/lib/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
|
||||
Vendored
+388
@@ -0,0 +1,388 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
NODE_VERSION = '18'
|
||||
NPM_REGISTRY = 'https://registry.npmmirror.com'
|
||||
PROJECT_NAME = 'novalon-website'
|
||||
SERVER_IP = '139.155.109.62'
|
||||
SERVER_USER = 'root'
|
||||
DEPLOY_ROOT = '/home/novalon/docker-app'
|
||||
STATIC_DIR = 'novalon-static'
|
||||
NGINX_CONTAINER = 'novalon-nginx-secure'
|
||||
DOMAIN = 'https://novalon.cn'
|
||||
}
|
||||
|
||||
options {
|
||||
buildDiscarder(logRotator(numToKeepStr: '10'))
|
||||
timeout(time: 30, unit: 'MINUTES')
|
||||
timestamps()
|
||||
ansiColor('xterm')
|
||||
}
|
||||
|
||||
triggers {
|
||||
// Gitea Webhook 触发
|
||||
GenericTrigger(
|
||||
genericVariables: [
|
||||
[key: 'ref', value: '$.ref'],
|
||||
[key: 'repository', value: '$.repository.full_name'],
|
||||
[key: 'commit_sha', value: '$.after'],
|
||||
[key: 'commit_message', value: '$.commits[0].message']
|
||||
],
|
||||
token: '${PROJECT_NAME}-ci-token',
|
||||
causeString: 'Triggered by $ref on $repository',
|
||||
printContributedVariables: true,
|
||||
printPostContent: true,
|
||||
silentResponse: false,
|
||||
regexpFilterText: '$ref$repository',
|
||||
regexpFilterExpression: '^(refs/heads/main|refs/heads/develop).*$'
|
||||
)
|
||||
|
||||
// 每天凌晨 3 点定时检查依赖安全漏洞
|
||||
pollSCM('H 3 * * *')
|
||||
}
|
||||
|
||||
parameters {
|
||||
booleanParam(
|
||||
defaultValue: false,
|
||||
description: '是否部署到生产环境(仅在 main 分支且测试通过后可用)',
|
||||
name: 'DEPLOY_TO_PRODUCTION'
|
||||
)
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('🔧 环境准备') {
|
||||
steps {
|
||||
echo "📦 Node.js 版本: ${NODE_VERSION}"
|
||||
echo "🌐 目标域名: ${DOMAIN}"
|
||||
echo "🖥️ 生产服务器: ${SERVER_USER}@${SERVER_IP}"
|
||||
|
||||
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||
sh 'node --version'
|
||||
sh 'npm --version'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('📥 安装依赖') {
|
||||
steps {
|
||||
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||
sh '''
|
||||
rm -rf node_modules package-lock.json
|
||||
npm ci --registry=${NPM_REGISTRY} --prefer-offline
|
||||
'''
|
||||
}
|
||||
}
|
||||
post {
|
||||
failure {
|
||||
echo "❌ 依赖安装失败!请检查 npm registry 是否可用。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('🔍 代码质量检查') {
|
||||
parallel {
|
||||
stage('ESLint 检查') {
|
||||
steps {
|
||||
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||
sh 'npm run lint'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('TypeScript 类型检查') {
|
||||
steps {
|
||||
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||
sh 'npm run type-check'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
post {
|
||||
failure {
|
||||
echo "❌ 代码质量检查未通过!"
|
||||
echo "💡 提示:请运行 npm run lint 和 npm run type-check 在本地修复问题"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('🧪 单元测试 + 覆盖率检查') {
|
||||
steps {
|
||||
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||
sh '''
|
||||
export CI=true
|
||||
npm run test:coverage:check
|
||||
'''
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
publishHTML(target: [
|
||||
allowMissing: false,
|
||||
alwaysLinkToLastBuild: true,
|
||||
keepAll: true,
|
||||
reportDir: 'coverage',
|
||||
reportFiles: 'index.html',
|
||||
reportName: 'Coverage Report (Jest)'
|
||||
])
|
||||
}
|
||||
success {
|
||||
echo "✅ 单元测试通过!覆盖率 ≥ 80%"
|
||||
}
|
||||
failure {
|
||||
echo "❌ 单元测试失败或覆盖率不足 80%!"
|
||||
echo "💡 请查看 Coverage Report 了解详情"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('🏗️ 生产构建') {
|
||||
when {
|
||||
anyOf {
|
||||
branch 'main'
|
||||
branch 'develop'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||
sh '''
|
||||
rm -rf .next dist
|
||||
npm run build:clean
|
||||
'''
|
||||
}
|
||||
sh '''
|
||||
if [ -d "dist" ]; then
|
||||
FILE_COUNT=$(find dist -type f | wc -l)
|
||||
DIST_SIZE=$(du -sh dist | cut -f1)
|
||||
echo "✅ 构建成功!生成了 $FILE_COUNT 个文件,总大小 $DIST_SIZE"
|
||||
else
|
||||
echo "❌ 构建失败:dist 目录不存在"
|
||||
exit 1
|
||||
fi
|
||||
'''
|
||||
}
|
||||
post {
|
||||
success {
|
||||
archiveArtifacts artifacts: 'dist/**', fingerprint: true
|
||||
}
|
||||
failure {
|
||||
echo "❌ Next.js 构建失败!请检查构建日志中的错误信息。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('🌐 E2E 测试') {
|
||||
when {
|
||||
allOf {
|
||||
anyOf {
|
||||
branch 'main'
|
||||
branch 'develop'
|
||||
}
|
||||
expression { return params.DEPLOY_TO_PRODUCTION == true }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||
sh '''
|
||||
cd e2e && npx playwright test --config=playwright.config.ts || exit 1
|
||||
'''
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
publishHTML(target: [
|
||||
allowMissing: false,
|
||||
alwaysLinkToLastBuild: true,
|
||||
keepAll: true,
|
||||
reportDir: 'e2e/playwright-report',
|
||||
reportFiles: 'index.html',
|
||||
reportName: 'E2E Test Report (Playwright)'
|
||||
])
|
||||
}
|
||||
success {
|
||||
echo "✅ E2E 测试全部通过!"
|
||||
}
|
||||
failure {
|
||||
echo "❌ E2E 测试失败!"
|
||||
echo "💡 请查看 E2E Test Report 了解哪些用例未通过"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('🚀 部署到生产环境') {
|
||||
when {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { return params.DEPLOY_TO_PRODUCTION == true }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
echo "⚠️ 即将部署到生产环境:${DOMAIN}"
|
||||
sleep(time: 5, unit: 'SECONDS')
|
||||
|
||||
sshagent(['deploy-server-ssh-key']) {
|
||||
sh """
|
||||
# 备份旧版本
|
||||
ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_IP} '
|
||||
set -e
|
||||
STATIC_PATH=\"${DEPLOY_ROOT}/${STATIC_DIR}\"
|
||||
BACKUP_PATH=\"${DEPLOY_ROOT}/${STATIC_DIR}_backup_\$(date +%Y%m%d_%H%M%S)\"
|
||||
|
||||
if [ -d \"\$STATIC_PATH\" ]; then
|
||||
echo \"📦 备份旧版本到 \$BACKUP_PATH\"
|
||||
cp -r \"\$STATIC_PATH\" \"\$BACKUP_PATH\"
|
||||
fi
|
||||
'
|
||||
|
||||
# 上传新版本
|
||||
echo "📤 上传 dist 目录到服务器..."
|
||||
ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_IP} \
|
||||
\"mkdir -p ${DEPLOY_ROOT}/${STATIC_DIR}\"
|
||||
|
||||
rsync -avz --delete \
|
||||
dist/ \
|
||||
${SERVER_USER}@${SERVER_IP}:${DEPLOY_ROOT}/${STATIC_DIR}/
|
||||
|
||||
# 设置权限并重载 Nginx
|
||||
ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_IP} '
|
||||
set -e
|
||||
STATIC_PATH=\"${DEPLOY_ROOT}/${STATIC_DIR}\"
|
||||
chown -R www-data:www-data \"\$STATIC_PATH\" 2>/dev/null || true
|
||||
chmod -R 755 \"\$STATIC_PATH\"
|
||||
docker exec ${NGINX_CONTAINER} nginx -s reload
|
||||
'
|
||||
"""
|
||||
}
|
||||
|
||||
// 验证部署成功
|
||||
sh """
|
||||
sleep 3
|
||||
|
||||
HTTP_STATUS=\$(curl -s -o /dev/null -w '%{http_code}' ${DOMAIN})
|
||||
if [ "\$HTTP_STATUS" = "200" ]; then
|
||||
echo "✅ 部署验证成功!HTTP 状态码: \$HTTP_STATUS"
|
||||
else
|
||||
echo "❌ 部署验证失败!HTTP 状态码: \$HTTP_STATUS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查关键页面
|
||||
for PAGE in "/" "/contact" "/about" "/products"; do
|
||||
STATUS=\$(curl -s -o /dev/null -w '%{http_code}' ${DOMAIN}\$PAGE)
|
||||
if [ "\$STATUS" != "200" ]; then
|
||||
echo "⚠️ 页面 \$PAGE 返回状态码 \$STATUS"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "🎉 部署完成!访问地址: ${DOMAIN}"
|
||||
"""
|
||||
}
|
||||
post {
|
||||
success {
|
||||
script {
|
||||
def commitMsg = sh(
|
||||
script: 'git log -1 --pretty=%B',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
def buildUrl = env.BUILD_URL
|
||||
def buildNum = env.BUILD_NUMBER
|
||||
|
||||
// 发送部署成功通知(钉钉/企业微信)
|
||||
echo """
|
||||
🎉 **Novalon Website 部署成功**
|
||||
|
||||
- **构建号**: #${buildNum}
|
||||
- **提交信息**: ${commitMsg}
|
||||
- **目标域名**: ${DOMAIN}
|
||||
- **构建详情**: ${buildUrl}
|
||||
"""
|
||||
}
|
||||
}
|
||||
failure {
|
||||
echo "❌ 部署失败!正在执行自动回滚..."
|
||||
|
||||
// 自动回滚到上一个备份
|
||||
sshagent(['deploy-server-ssh-key']) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_IP} '
|
||||
set -e
|
||||
BACKUP_DIR=\"${DEPLOY_ROOT}\"
|
||||
LATEST_BACKUP=\$(ls -dt \${BACKUP_DIR}/${STATIC_DIR}_backup_* 2>/dev/null | head -1)
|
||||
|
||||
if [ -n \"\$LATEST_BACKUP\" ]; then
|
||||
echo \"🔄 回滚到最近备份: \$LATEST_BACKUP\"
|
||||
rm -rf \"${DEPLOY_ROOT}/${STATIC_DIR}\"
|
||||
cp -r \"\$LATEST_BACKUP\" \"${DEPLOY_ROOT}/${STATIC_DIR}\"
|
||||
docker exec ${NGINX_CONTAINER} nginx -s reload
|
||||
echo "✅ 回滚完成"
|
||||
else
|
||||
echo "❌ 未找到可用的备份!需要手动介入"
|
||||
exit 1
|
||||
fi
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('🧹 清理工作空间') {
|
||||
steps {
|
||||
cleanWs(cleanWhenNotBuilt: false,
|
||||
deleteDirs: true,
|
||||
notFailBuild: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
script {
|
||||
def result = currentBuild.result ?: 'SUCCESS'
|
||||
def duration = currentBuild.durationString.replace(' and counting', '')
|
||||
def color = result == 'SUCCESS' ? 'good' : (result == 'UNSTABLE' ? 'warning' : 'danger')
|
||||
|
||||
echo """
|
||||
====================================
|
||||
📊 Jenkins 构建报告
|
||||
====================================
|
||||
- **项目**: ${env.JOB_NAME}
|
||||
- **构建号**: #${env.BUILD_NUMBER}
|
||||
- **结果**: ${result}
|
||||
- **耗时**: ${duration}
|
||||
- **详情**: ${env.BUILD_URL}
|
||||
====================================
|
||||
"""
|
||||
|
||||
// 发送通知(可根据需要集成钉钉、企业微信、邮件等)
|
||||
if (result != 'SUCCESS') {
|
||||
mail (
|
||||
to: 'dev-team@novalon.cn',
|
||||
subject: "❌ Novalon Website 构建失败: #${env.BUILD_NUMBER}",
|
||||
body: """构建结果: ${result}
|
||||
|
||||
请尽快查看构建详情:
|
||||
${env.BUILD_URL}
|
||||
|
||||
构建日志:
|
||||
${env.BUILD_URL}console"""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
success {
|
||||
echo "✅ Pipeline 执行成功!"
|
||||
}
|
||||
|
||||
failure {
|
||||
echo "❌ Pipeline 执行失败!请检查上述错误信息。"
|
||||
}
|
||||
|
||||
aborted {
|
||||
echo "⚠️ Pipeline 被中止!"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
config/lint/babel.config.js
|
||||
@@ -43,17 +43,20 @@
|
||||
"react/no-unescaped-entities": "error",
|
||||
"react/jsx-no-target-blank": "error",
|
||||
"react/self-closing-comp": "error",
|
||||
"react/display-name": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }],
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
"eqeqeq": ["error", "always"],
|
||||
"curly": ["error", "all"],
|
||||
"curly": ["error", "multi-line"],
|
||||
"no-throw-literal": "error",
|
||||
"prefer-promise-reject-errors": "error"
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"react-hooks/set-state-in-effect": "warn"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
# Novalon Website 回滚流程指南(生产环境专用)
|
||||
|
||||
> **最后更新**: 2026-05-12
|
||||
> **适用项目**: novalon-website (四川睿新致远科技有限公司官网)
|
||||
> **部署架构**: 静态导出 + Docker + Nginx + CDN
|
||||
> **目标恢复时间**: < 10 分钟(P0 级别故障)
|
||||
>
|
||||
> **⚠️ 重要提示**:本文档已针对你的实际部署环境定制,请勿使用通用模板!
|
||||
|
||||
---
|
||||
|
||||
## 📋 你的实际部署架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 开发机 (Local) │
|
||||
│ - 项目路径: /Users/zhangxiang/Codes/Novalon/ │
|
||||
│ novalon-website │
|
||||
│ - 构建命令: npm run build:clean │
|
||||
│ - 输出目录: dist/ │
|
||||
│ - 部署命令: ./deploy-dist.sh │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│ rsync + SSH (root@139.155.109.62)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 生产服务器 (139.155.109.62) │
|
||||
│ └── /home/novalon/docker-app/ │
|
||||
│ ├── novalon-static/ ← 当前版本 │
|
||||
│ ├── novalon-static_backup_* ← 自动备份 │
|
||||
│ └── docker-compose.yml │
|
||||
│ └── novalon-nginx-secure ← Nginx 容器 │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│ 反向代理
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 用户访问 │
|
||||
│ - 主域名: https://novalon.cn │
|
||||
│ - 备用 IP: https://139.155.109.62 │
|
||||
│ - HTTP → HTTPS 自动跳转 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 **触发条件(满足任一即启动回滚)**
|
||||
|
||||
### **P0 - 紧急故障(立即回滚,无需审批)**
|
||||
- [ ] 首页完全白屏或返回 500/502/503/504
|
||||
- [ ] 所有页面无法访问(HTTP 状态码非 200)
|
||||
- [ ] 安全漏洞报告(XSS、数据泄露、恶意重定向)
|
||||
- [ ] 域名解析失败或 SSL 证书过期
|
||||
|
||||
### **P1 - 严重问题(30 分钟内回滚)**
|
||||
- [ ] 核心功能不可用:
|
||||
- 导航栏无法点击
|
||||
- 表单提交失败(联系表单、订阅等)
|
||||
- 主题切换失效
|
||||
- 移动端布局错乱
|
||||
- [ ] 关键 SEO 元数据丢失:
|
||||
- `<title>` 为空或显示 "Untitled"
|
||||
- `<meta name="description">` 缺失
|
||||
- Open Graph / Twitter Card 标签缺失
|
||||
- [ ] 性能严重退化:
|
||||
- Lighthouse Performance < 50(之前 > 90)
|
||||
- LCP (Largest Contentful Paint) > 5 秒
|
||||
- FID (First Input Delay) > 300ms
|
||||
|
||||
### **P2 - 一般问题(24 小时内修复)**
|
||||
- [ ] 次要页面样式异常(不影响核心功能)
|
||||
- [ ] 图片资源加载失败(非首屏图片)
|
||||
- [ ] 控制台有警告但无错误
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **Type 1:代码回滚(最常用,推荐首选)**
|
||||
|
||||
### **适用场景**
|
||||
- 最近一次部署引入了 bug
|
||||
- 需要快速恢复到上一个稳定版本
|
||||
- Git 历史清晰,可以定位到稳定的 commit
|
||||
|
||||
#### **Step 0:准备阶段(2 分钟)**
|
||||
|
||||
```bash
|
||||
# 0.1 确认当前状态
|
||||
echo "=== 当前时间 ==="
|
||||
date '+%Y-%m-%d %H:%M:%S'
|
||||
|
||||
echo ""
|
||||
echo "=== 当前 Git 分支和 Commit ==="
|
||||
git branch --show-current
|
||||
git log --oneline -3
|
||||
|
||||
echo ""
|
||||
echo "=== 检查是否有未提交的更改 ==="
|
||||
git status --short
|
||||
```
|
||||
|
||||
**预期输出示例**:
|
||||
```
|
||||
=== 当前时间 ===
|
||||
2026-05-12 15:30:00
|
||||
|
||||
=== 当前 Git 分支和 Commit ===
|
||||
main
|
||||
abc1234 (HEAD -> main) feat: update hero animation
|
||||
def5678 fix: improve mobile layout
|
||||
ghi9012 chore: upgrade dependencies
|
||||
|
||||
=== 检查是否有未提交的更改 ===
|
||||
M src/components/Hero.tsx
|
||||
```
|
||||
|
||||
#### **Step 1:确认问题现象(2 分钟)**
|
||||
|
||||
```bash
|
||||
# 1.1 检查网站是否可访问
|
||||
curl -I https://novalon.cn/ | head -10
|
||||
|
||||
# 预期输出: HTTP/2 200
|
||||
# 如果看到 500/502/503/504,说明需要紧急回滚
|
||||
|
||||
# 1.2 检查关键页面的 HTTP 状态码
|
||||
for page in "/" "/about" "/contact" "/products" "/solutions"; do
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" "https://novalon.cn${page}")
|
||||
echo "$page -> $status"
|
||||
done
|
||||
|
||||
# 预期输出: 所有页面都返回 200
|
||||
|
||||
# 1.3 检查关键 HTML 元素是否存在
|
||||
curl -s https://novalon.cn/ | grep -E '<title>|<meta name="description">|<h1'
|
||||
```
|
||||
|
||||
**预期输出示例**:
|
||||
```
|
||||
/ -> 200
|
||||
/about -> 200
|
||||
/contact -> 200
|
||||
/products -> 200
|
||||
/solutions -> 200
|
||||
|
||||
<title>四川睿新致远科技有限公司</title>
|
||||
<meta name="description" content="..." />
|
||||
<h1 class="...">...</h1>
|
||||
```
|
||||
|
||||
如果上述检查有任何一项不通过,**立即进入 Step 2**。
|
||||
|
||||
#### **Step 2:定位稳定版本(3 分钟)**
|
||||
|
||||
```bash
|
||||
# 2.1 查看 Git 历史,找到最近的稳定版本
|
||||
git log --oneline --graph -20
|
||||
|
||||
# 寻找标记为 "stable"、"fix:"、"chore:" 的 commit
|
||||
# 或者查看 Git Tags
|
||||
git tag -l 'v*' --sort=-v:refname | head -5
|
||||
|
||||
# 如果没有 Tag,根据 commit message 判断
|
||||
# 推荐选择最近一次包含以下关键词的 commit:
|
||||
# - "fix:" (bug 修复)
|
||||
# - "release:" 或 "v*.*.*" (正式发布)
|
||||
# - "chore: bump version" (版本号更新)
|
||||
```
|
||||
|
||||
**示例输出**:
|
||||
```
|
||||
* abc1234 (HEAD -> main) feat: update hero animation ← 当前版本(有问题)
|
||||
* def5678 fix: improve mobile layout ← 可能是稳定版本
|
||||
* ghi9012 chore: upgrade dependencies ← 上一个版本
|
||||
* jkl3456 release: v1.0.0-stable ← 正式发布版(最安全)
|
||||
```
|
||||
|
||||
**决策建议**:
|
||||
- 如果 `def5678` 是你上次验证过的版本,回滚到它
|
||||
- 如果不确定,选择 `jkl3456` (Tagged release)
|
||||
|
||||
#### **Step 3:执行本地代码回滚(3 分钟)**
|
||||
|
||||
```bash
|
||||
# 3.1 备份当前版本(以防万一)
|
||||
git stash push -m "backup-before-rollback-$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
# 或者创建一个新的分支保存当前代码
|
||||
git branch backup/broken-version-$(date +%Y%m%d)
|
||||
|
||||
# 3.2 切换到稳定版本
|
||||
# 方法 A:使用 Git Tag(推荐)
|
||||
git checkout v1.0.0-stable
|
||||
|
||||
# 方法 B:使用 Commit Hash
|
||||
git checkout def5678
|
||||
|
||||
# 3.3 确认已切换成功
|
||||
git log --oneline -1
|
||||
# 应该显示: def5678 fix: improve mobile layout
|
||||
|
||||
echo "✅ 已切换到稳定版本"
|
||||
```
|
||||
|
||||
#### **Step 4:重新构建并部署(5 分钟)**
|
||||
|
||||
```bash
|
||||
# 4.1 清理并构建
|
||||
npm run build:clean
|
||||
|
||||
# 如果构建成功,会看到类似输出:
|
||||
# ✅ Built in Xs
|
||||
# 📁 生成了 XXX 个文件,总大小: XX MB
|
||||
|
||||
# 4.2 快速验证构建产物
|
||||
if [ -d "dist" ]; then
|
||||
echo "✅ dist 目录存在"
|
||||
|
||||
# 检查关键文件是否存在
|
||||
ls -lh dist/index.html
|
||||
ls -lh dist/about/index.html
|
||||
ls -lh dist/contact/index.html
|
||||
|
||||
# 检查 HTML 内容
|
||||
head -20 dist/index.html | grep '<title>'
|
||||
else
|
||||
echo "❌ 构建失败!dist 目录不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4.3 部署到生产环境
|
||||
./deploy-dist.sh
|
||||
|
||||
# 脚本会自动执行:
|
||||
# 1. 检查 dist 目录
|
||||
# 2. 验证 SSH 连接
|
||||
# 3. 备份旧版本(在服务器上)
|
||||
# 4. 上传新的 dist
|
||||
# 5. 设置权限
|
||||
# 6. 重载 Nginx
|
||||
# 7. 验证部署结果
|
||||
```
|
||||
|
||||
#### **Step 5:验证回滚成功(2 分钟)**
|
||||
|
||||
```bash
|
||||
# 5.1 等待 3-5 秒让 CDN 缓存刷新
|
||||
sleep 5
|
||||
|
||||
# 5.2 再次执行 Step 1.2 和 1.3 的检查
|
||||
for page in "/" "/about" "/contact" "/products"; do
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" "https://novalon.cn${page}")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "✅ $page -> $status"
|
||||
else
|
||||
echo "❌ $page -> $status (需要进一步排查)"
|
||||
fi
|
||||
done
|
||||
|
||||
# 5.3 浏览器手动验证(重要!)
|
||||
open https://novalon.cn/
|
||||
|
||||
# 手动检查清单:
|
||||
# □ 首页正常加载,无白屏
|
||||
# □ 导航栏可点击,所有链接正常工作
|
||||
# □ Logo 显示正确(应该是书法字体)
|
||||
# □ 主题切换按钮可用(如果有暗色模式)
|
||||
# □ 移动端布局正常(可以用 Chrome DevTools 模拟)
|
||||
# □ 表单可以打开(即使不提交)
|
||||
```
|
||||
|
||||
#### **Step 6:通知相关人员(2 分钟)**
|
||||
|
||||
```bash
|
||||
# 6.1 记录回滚操作日志
|
||||
cat << EOF >> /tmp/rollback-log.txt
|
||||
==========================================
|
||||
回滚记录
|
||||
==========================================
|
||||
时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
操作人: $(whoami)
|
||||
原因: [填写具体原因]
|
||||
回滚前版本: abc1234 (feat: update hero animation)
|
||||
回滚后版本: $(git log --oneline -1)
|
||||
验证结果: ✅ 所有关键页面正常
|
||||
耗时: 约 15 分钟
|
||||
==========================================
|
||||
|
||||
EOF
|
||||
|
||||
# 6.2 发送通知(钉钉/企业微信/邮件)
|
||||
# 示例:发送邮件给 dev-team@novalon.cn
|
||||
cat << EOF | mail -s "⚠️ 已回滚 Novalon Website" dev-team@novalon.cn
|
||||
Novalon Website 已完成紧急回滚
|
||||
|
||||
回滚时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
回滚原因: [填写具体原因,如"首页白屏"]
|
||||
回滚前版本: abc1234 (feat: update hero animation)
|
||||
回滚后版本: $(git describe --tags || git log --oneline -1)
|
||||
验证状态: ✅ 所有关键页面已恢复正常访问
|
||||
|
||||
请相关开发者尽快排查问题根因。
|
||||
|
||||
回滚详情:
|
||||
- 操作人: $(whoami)
|
||||
- 服务器: 139.155.109.62
|
||||
- 域名: https://novalon.cn
|
||||
- 日志位置: /tmp/rollback-log.txt
|
||||
|
||||
技术支持联系人:
|
||||
- 值班工程师: [填写姓名] - [填写电话]
|
||||
- 技术负责人: [填写姓名] - [填写电话]
|
||||
EOF
|
||||
|
||||
echo "✅ 回滚通知已发送"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 **Type 2:Docker 容器级回滚(当 Type 1 不可用时)**
|
||||
|
||||
### **适用场景**
|
||||
- 本地代码库损坏或不可用
|
||||
- 需要立即恢复,来不及重新构建
|
||||
- 服务器上有自动备份
|
||||
|
||||
#### **步骤(全部在服务器上执行)**
|
||||
|
||||
```bash
|
||||
# SSH 到生产服务器
|
||||
ssh root@139.155.109.62
|
||||
|
||||
# Step 1: 查看当前容器状态
|
||||
docker ps | grep novalon
|
||||
|
||||
# 预期输出:
|
||||
# CONTAINER ID IMAGE STATUS NAMES
|
||||
# xxxxxxxxxx novalon-website:latest Up 2 hours novalon-nginx-secure
|
||||
|
||||
# Step 2: 查看可用的备份
|
||||
ls -lth /home/novalon/docker-app/novalon-static_backup_* | head -5
|
||||
|
||||
# 预期输出:
|
||||
# drwxr-xr-x 20260512_153000 ← 最新备份(1 小时前)
|
||||
# drwxr-xr-x 20260512_140000 ← 之前的备份(2 小时前)
|
||||
# drwxr-xr-x 20260511_180000 ← 昨天的备份
|
||||
|
||||
# Step 3: 选择最近的备份进行恢复
|
||||
BACKUP_DIR=$(ls -dt /home/novalon/docker-app/novalon-static_backup_* | head -1)
|
||||
CURRENT_DIR="/home/novalon/docker-app/novalon-static"
|
||||
|
||||
echo "将回滚到备份: $BACKUP_DIR"
|
||||
|
||||
# Step 4: 执行回滚
|
||||
rm -rf "$CURRENT_DIR"
|
||||
cp -r "$BACKUP_DIR" "$CURRENT_DIR"
|
||||
|
||||
# Step 5: 重载 Nginx 使更改生效
|
||||
docker exec novalon-nginx-secure nginx -s reload
|
||||
|
||||
# Step 6: 验证
|
||||
curl -I https://novalon.cn/ | head -1
|
||||
# 预期: HTTP/2 200
|
||||
|
||||
echo "✅ Docker 容器级回滚完成"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **Type 3:CDN 缓存清理(配合 Type 1 或 Type 2 使用)**
|
||||
|
||||
### **何时需要?**
|
||||
- 回滚后用户仍看到旧版本的缓存内容
|
||||
- 更新了 CSS/JS 文件但浏览器未加载最新版本
|
||||
|
||||
#### **方法 A:强制刷新 Nginx 缓存(推荐)**
|
||||
|
||||
```bash
|
||||
# 在服务器上执行
|
||||
ssh root@139.155.109.62
|
||||
|
||||
# 临时禁用缓存(5 分钟后记得改回来)
|
||||
cat > /tmp/disable-cache.conf << 'EOF'
|
||||
location / {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
|
||||
expires 0;
|
||||
# ... 其他配置保持不变
|
||||
}
|
||||
EOF
|
||||
|
||||
# 应用配置并重载 Nginx
|
||||
docker exec novalon-nginx-secure nginx -s reload
|
||||
|
||||
# 等待 5 分钟让所有用户的缓存过期
|
||||
sleep 300
|
||||
|
||||
# 恢复正常缓存策略(重要!否则影响性能)
|
||||
# 将原来的 nginx.conf 放回去
|
||||
docker exec novalon-nginx-secure nginx -s reload
|
||||
```
|
||||
|
||||
#### **方法 B:版本化静态资源(长期方案)**
|
||||
|
||||
如果你的项目使用了 `next.config.ts` 中的 `assetPrefix`,Next.js 会自动为静态资源添加 hash:
|
||||
|
||||
```
|
||||
旧版本: /_next/static/css/abc123.css
|
||||
新版本: /_next/static/css/def456.css
|
||||
```
|
||||
|
||||
这样浏览器会自动请求新的 URL,无需手动清除缓存。
|
||||
|
||||
---
|
||||
|
||||
## 📞 **紧急联系人列表**
|
||||
|
||||
| 角色 | 姓名 | 电话/微信 | 职责 | 备注 |
|
||||
|------|------|-----------|------|------|
|
||||
| **值班工程师** | 待填写 | 待填写 | 执行回滚操作 | 第一响应人 |
|
||||
| **技术负责人** | 待填写 | 待填写 | 决策是否回滚 | P0/P1 故障需其批准 |
|
||||
| **产品负责人** | 待填写 | 待填写 | 评估业务影响 | P1/P2 故障需通知 |
|
||||
| **运维支持** | 待填写 | 待填写 | 服务器/DNS 层面支持 | 当涉及基础设施问题时 |
|
||||
| **Sentry 告警接收人** | dev-team@novalon.cn | - | 错误监控告警 | Sentry 配置 |
|
||||
|
||||
---
|
||||
|
||||
## 📊 **回滚后复盘模板(必填!)**
|
||||
|
||||
每次回滚完成后,**必须在 24 小时内**填写此模板并发送到团队群:
|
||||
|
||||
```markdown
|
||||
## Novalon Website 回滚复盘报告
|
||||
|
||||
**基本信息**
|
||||
- 回滚日期和时间:
|
||||
- 发现问题时间:
|
||||
- 开始回滚时间:
|
||||
- 回滚完成时间:
|
||||
- 总宕机时间: ___ 分钟
|
||||
|
||||
**问题分类**
|
||||
- [ ] 代码 Bug(哪个 commit 引入的?commit hash: _____)
|
||||
- [ ] 依赖兼容性问题(哪个包升级导致?包名及版本: _____)
|
||||
- [ ] 配置错误(nginx/docker/env?具体是哪个配置?)
|
||||
- [ ] 第三方服务故障(CDN/DNS/SSL 证书?服务商是谁?)
|
||||
- [ ] 其他: _____
|
||||
|
||||
**问题根因分析**
|
||||
(详细描述为什么会出现这个问题)
|
||||
|
||||
**影响范围评估**
|
||||
- 影响用户数估算: 约 ___ 人
|
||||
- 受影响的页面/功能:
|
||||
- 是否造成数据丢失: 是/否(如果是,哪些数据?)
|
||||
- 业务损失估算:
|
||||
|
||||
**回滚操作详情**
|
||||
- 回滚类型: Type 1 (代码回滚) / Type 2 (Docker 容器级) / Type 3 (CDN 清理)
|
||||
- 回滚前版本:
|
||||
- 回滚后版本:
|
||||
- 是否成功恢复: 是/否
|
||||
- 是否需要后续修复: 是/否
|
||||
|
||||
**改进措施(防止再次发生)**
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**如何改进测试/CI 流程以提前发现问题**
|
||||
- [ ] 补充单元测试覆盖(具体是哪个模块?)
|
||||
- [ ] 增加 E2E 测试用例(具体场景是什么?)
|
||||
- [ ] 在 CI Pipeline 中增加质量门禁(什么条件?)
|
||||
- [ ] 接入 Sentry 错误监控(已完成 ✓)
|
||||
- [ ] 完善 Code Review 流程(什么标准?)
|
||||
- [ ] 其他:
|
||||
|
||||
**经验教训总结**
|
||||
(用一两句话总结这次回滚学到了什么)
|
||||
|
||||
**附件**
|
||||
- 截图/日志: (如有)
|
||||
- 相关 Issue/PR 链接: (如有)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **常见问题排查(FAQ)**
|
||||
|
||||
### **Q1:回滚后网站仍然显示旧内容?**
|
||||
|
||||
**A:CDN 缓存未刷新。解决方案:**
|
||||
1. 执行 Type 3: CDN 缓存清理
|
||||
2. 或等待 CDN TTL 过期(通常 1-2 小时)
|
||||
3. 或使用 Ctrl+Shift+R 强制刷新浏览器缓存
|
||||
|
||||
### **Q2:Git checkout 失败,提示"有未提交的更改"?**
|
||||
|
||||
**A:先暂存或丢弃这些更改:**
|
||||
```bash
|
||||
# 查看有哪些未提交的更改
|
||||
git status
|
||||
|
||||
# 方案 A:暂时保存(推荐)
|
||||
git stash push -m "temp-save"
|
||||
|
||||
# 方案 B:直接丢弃(谨慎!不可逆)
|
||||
git reset --hard HEAD
|
||||
|
||||
# 然后再执行 git checkout
|
||||
```
|
||||
|
||||
### **Q3:npm run build:clean 构建失败?**
|
||||
|
||||
**A:可能的原因及解决方法:**
|
||||
1. **依赖未安装完全**:运行 `rm -rf node_modules && npm install`
|
||||
2. **TypeScript 类型错误**:运行 `npx tsc --noEmit` 查看详细错误
|
||||
3. **ESLint 错误**:运行 `npm run lint` 查看详情
|
||||
4. **内存不足**:增加 Node.js 内存限制 `NODE_OPTIONS=--max-old-space-size=4096 npm run build:clean`
|
||||
|
||||
### **Q4:SSH 连接到服务器失败?**
|
||||
|
||||
**A:检查以下几点:**
|
||||
1. **网络连接**:`ping 139.155.109.62`
|
||||
2. **SSH 服务**:`ssh -v root@139.155.109.62`(查看详细日志)
|
||||
3. **防火墙**:确认端口 22 未被封锁
|
||||
4. **SSH Key**:确认私钥文件权限正确 (`chmod 600 ~/.ssh/id_rsa`)
|
||||
|
||||
### **Q5:回滚后发现数据库或其他动态内容丢失?**
|
||||
|
||||
**A:对于纯静态网站(你的情况),不存在这个问题!**
|
||||
- Novalon Website 是纯静态导出,不依赖数据库
|
||||
- 所有内容都在 `dist/` 目录中
|
||||
- 回滚只是替换静态文件,不会影响任何持久化数据
|
||||
|
||||
---
|
||||
|
||||
## ⚡ **快速参考卡片(打印出来贴在工位上)**
|
||||
|
||||
```
|
||||
╔══════════════════════════════════════════╗
|
||||
║ Novalon Website 紧急回滚速查表 ║
|
||||
╠══════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ P0 触发条件: ║
|
||||
║ • 首页白屏 / 5xx 错误 ║
|
||||
║ • 安全漏洞 ║
|
||||
║ • 域名/SSL 问题 ║
|
||||
║ ║
|
||||
║ 快速回滚命令(3 步完成): ║
|
||||
║ ║
|
||||
║ 1. git log --oneline -10 ║
|
||||
║ # 找到稳定版本的 commit ║
|
||||
║ ║
|
||||
║ 2. git checkout <stable-commit-hash> ║
|
||||
║ # 切换到稳定版本 ║
|
||||
║ ║
|
||||
║ 3. ./deploy-dist.sh ║
|
||||
║ # 重新构建并部署 ║
|
||||
║ ║
|
||||
║ 验证命令: ║
|
||||
║ curl -I https://novalon.cn/ ║
|
||||
║ # 预期: HTTP/2 200 ║
|
||||
║ ║
|
||||
║ 紧急联系人: ║
|
||||
║ • 值班工程师: [姓名] - [电话] ║
|
||||
║ • 技术负责人: [姓名] - [电话] ║
|
||||
║ • 运维支持: [姓名] - [电话] ║
|
||||
║ ║
|
||||
║ 服务器信息: ║
|
||||
║ IP: 139.155.109.62 ║
|
||||
║ 用户: root ║
|
||||
║ 域名: novalon.cn ║
|
||||
║ 容器: novalon-nginx-secure ║
|
||||
║ ║
|
||||
║ 目标恢复时间: < 10 分钟 ║
|
||||
╚══════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ **回滚完成清单**
|
||||
|
||||
请在回滚完成后逐项确认:
|
||||
|
||||
**立即确认(回滚后 5 分钟内)**
|
||||
- [ ] 网站首页可访问,无白屏
|
||||
- [ ] HTTP 状态码返回 200
|
||||
- [ ] 关键页面(About, Contact, Products)可访问
|
||||
- [ ] 导航栏正常工作
|
||||
- [ ] SEO 元数据完整(title, description, OG tags)
|
||||
- [ ] 已通知相关人员(邮件/群消息)
|
||||
|
||||
**后续确认(回滚后 24 小时内)**
|
||||
- [ ] 填写回滚复盘报告
|
||||
- [ ] 分析问题根因并记录
|
||||
- [ ] 制定预防措施
|
||||
- [ ] 更新 CI/CD Pipeline(如有必要)
|
||||
- [ ] 团队内部分享经验教训
|
||||
|
||||
---
|
||||
|
||||
## 📚 **相关文档链接**
|
||||
|
||||
- **部署脚本**: [deploy-dist.sh](./deploy-dist.sh)
|
||||
- **Jenkins Pipeline**: [Jenkinsfile](./Jenkinsfile)
|
||||
- **Sentry 监控指南**: [docs/sentry-setup-guide.md](./docs/sentry-setup-guide.md)
|
||||
- **技术栈降级指南**: [scripts/downgrade-stack.sh](./scripts/downgrade-stack.sh)
|
||||
- **CONTEXT.md**: [CONTEXT.md](./CONTEXT.md)(领域术语和关键决策)
|
||||
|
||||
---
|
||||
|
||||
**💡 最后提醒**:
|
||||
- 保持冷静,按照步骤执行
|
||||
- 每一步都要验证再继续
|
||||
- 记录所有操作,方便事后复盘
|
||||
- 回滚不是失败,是负责任的表现!
|
||||
|
||||
**祝你好运!🍀**
|
||||
@@ -0,0 +1,347 @@
|
||||
# GA4 错误监控集成完成报告
|
||||
|
||||
## 📊 集成概述
|
||||
|
||||
**日期**: 2026-01-15
|
||||
**技术方案**: Google Analytics 4 (GA4) 异常事件追踪
|
||||
**成本**: 完全免费(使用现有 GA4 账户)
|
||||
**状态**: ✅ 已完成并验证
|
||||
|
||||
---
|
||||
|
||||
## 🎯 实现目标
|
||||
|
||||
1. **零成本错误监控** - 利用现有 GA4 账户,无需额外付费服务
|
||||
2. **全自动错误捕获** - 无需手动埋点,自动捕获所有 JavaScript 错误
|
||||
3. **生产环境可用** - 已通过 TypeScript 检查和构建测试
|
||||
4. **智能过滤** - 自动忽略无关错误(ResizeObserver、Script error 等)
|
||||
|
||||
---
|
||||
|
||||
## 📁 新增/修改的文件
|
||||
|
||||
### 核心文件
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `src/lib/analytics.ts` | **新建** | Analytics 核心模块,包含所有追踪函数 |
|
||||
| `src/components/analytics/GlobalErrorTracker.tsx` | **新建** | 全局错误追踪器组件 |
|
||||
| `src/app/layout.tsx` | **修改** | 集成 GlobalErrorTracker |
|
||||
| `src/app/test-error-tracking/page.tsx` | **新建** | 错误监控测试页面 |
|
||||
|
||||
### 辅助文件
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `src/components/analytics/CookieConsent.tsx` | **修改** | 修复 CookiePreferences 类型定义 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术实现细节
|
||||
|
||||
### 1. 错误捕获机制
|
||||
|
||||
```
|
||||
GlobalErrorTracker 组件(客户端)
|
||||
↓
|
||||
useEffect 初始化时添加事件监听器:
|
||||
├── window.addEventListener('error', handleGlobalError)
|
||||
│ └── 捕获 JavaScript 运行时错误
|
||||
│ └── trackError('javascript_error', message, false, {filename, lineno, colno, stack})
|
||||
│
|
||||
├── window.addEventListener('unhandledrejection', handleUnhandledRejection)
|
||||
│ └── 捕获 Promise 未捕获异常
|
||||
│ └── trackError('unhandled_promise_rejection', message, false, {reason_type, stack})
|
||||
│
|
||||
└── document.addEventListener('error', handleResourceError, true)
|
||||
└── 捕获资源加载失败(图片、脚本、样式等)
|
||||
└── trackError('resource_loading_error', 'Failed to load resource', false)
|
||||
```
|
||||
|
||||
### 2. GA4 事件数据结构
|
||||
|
||||
每个错误都会作为 `exception` 事件发送到 GA4,包含以下参数:
|
||||
|
||||
```javascript
|
||||
{
|
||||
event_name: 'exception', // 固定事件名
|
||||
description: '[error_type] msg', // 错误描述(包含类型前缀)
|
||||
fatal: 'true' | 'false', // 是否致命错误
|
||||
url: '当前页面URL', // 发生错误的页面
|
||||
timestamp: 'ISO时间戳', // 发生时间
|
||||
// ... 其他上下文信息(可选)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. ErrorBoundary 集成
|
||||
|
||||
React 组件渲染错误会自动被 [ErrorBoundary](src/components/ui/error-boundary.tsx) 捕获并上报:
|
||||
|
||||
```typescript
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
trackError('react_error', error.message, true); // 标记为致命错误
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试与验证
|
||||
|
||||
### 快速测试步骤
|
||||
|
||||
1. **启动开发服务器**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. **访问测试页面**
|
||||
```
|
||||
http://localhost:3000/test-error-tracking
|
||||
```
|
||||
|
||||
3. **点击测试按钮**
|
||||
- 📛 测试 JavaScript 错误
|
||||
- ⚠️ 测试 Promise 异常
|
||||
- 💥 测试 React 致命错误
|
||||
- 🌐 测试网络错误
|
||||
|
||||
4. **查看控制台日志**(开发模式)
|
||||
```
|
||||
[GA4] Error tracked: {
|
||||
description: "[javascript_error] 测试错误...",
|
||||
fatal: "false",
|
||||
url: "http://localhost:3000/test-error-tracking",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
5. **在 GA4 后台验证**
|
||||
- 登录 [Google Analytics](https://analytics.google.com/)
|
||||
- 报告 → 实时 → 事件计数
|
||||
- 搜索 `exception`
|
||||
- 应该能在 30 秒内看到错误事件
|
||||
|
||||
---
|
||||
|
||||
## 📈 在生产环境使用
|
||||
|
||||
### 1. 确保环境变量已配置
|
||||
|
||||
检查 `.env.local` 或 `.env.production` 文件:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-LGTLCR15KM
|
||||
```
|
||||
|
||||
### 2. 构建并部署
|
||||
|
||||
```bash
|
||||
npm run build:clean
|
||||
# 然后执行部署脚本
|
||||
./deploy-dist.sh
|
||||
```
|
||||
|
||||
### 3. 监控错误报告
|
||||
|
||||
#### 方法 A:实时监控(推荐用于上线初期)
|
||||
- GA4 后台 → 报告 → 实时
|
||||
- 查看实时错误流
|
||||
|
||||
#### 方法 B:定期审查(日常运维)
|
||||
- GA4 后台 → 报告 → 参与度 → 事件
|
||||
- 筛选 `exception` 事件
|
||||
- 导出数据进行分析
|
||||
|
||||
#### 方法 C:设置自动告警(高级)
|
||||
- 使用 Google Data Studio 创建仪表盘
|
||||
- 设置异常检测规则
|
||||
- 配置邮件通知
|
||||
|
||||
---
|
||||
|
||||
## 🎨 自定义配置
|
||||
|
||||
### 忽略特定错误
|
||||
|
||||
编辑 [GlobalErrorTracker.tsx](src/components/analytics/GlobalErrorTracker.tsx):
|
||||
|
||||
```typescript
|
||||
const IGNORED_ERRORS = [
|
||||
/_AutofillCallbackHandler/,
|
||||
/ResizeObserver loop limit exceeded/,
|
||||
// 添加你想要忽略的错误模式...
|
||||
];
|
||||
```
|
||||
|
||||
### 添加自定义错误上下文
|
||||
|
||||
```typescript
|
||||
trackError('custom_error', 'Something went wrong', false, {
|
||||
user_id: '12345',
|
||||
feature_name: 'checkout',
|
||||
session_id: 'abc-xyz',
|
||||
});
|
||||
```
|
||||
|
||||
### 修改致命错误阈值
|
||||
|
||||
默认情况下,React 渲染错误会被标记为 `fatal: true`。你可以根据业务需求调整:
|
||||
|
||||
```typescript
|
||||
// ErrorBoundary 中
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
const isFatal = error.message.includes('critical');
|
||||
trackError('react_error', error.message, isFatal);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项与限制
|
||||
|
||||
### GA4 错误监控的限制
|
||||
|
||||
1. **数据延迟**:GA4 数据通常有 24-48 小时的处理延迟(实时报告除外)
|
||||
2. **采样率**:高流量网站可能会有数据采样
|
||||
3. **数据保留**:GA4 免费版数据保留 2 个月(付费版 14 个月)
|
||||
4. **事件限制**:每个 session 最多 500 个事件
|
||||
5. **参数限制**:每个事件最多 25 个参数
|
||||
|
||||
### 与专业错误监控工具对比
|
||||
|
||||
| 特性 | GA4 错误监控 | Sentry (免费版) | Fundebug (免费版) |
|
||||
|------|-------------|----------------|------------------|
|
||||
| **成本** | ✅ 免费 | ✅ 免费(5K 次/月) | ✅ 免费(3K 次/月) |
|
||||
| **国内访问** | ✅ 可访问(需 VPN) | ❌ 受限 | ✅ 可访问 |
|
||||
| **错误聚合** | ⚠️ 基础 | ✅ 高级 | ✅ 高级 |
|
||||
| **Source Map** | ❌ 不支持 | ✅ 支持 | ✅ 支持 |
|
||||
| **性能影响** | ✅ 低 | ⚠️ 中等 | ⚠️ 中等 |
|
||||
| **告警通知** | ⚠️ 有限 | ✅ 完善 | ✅ 完善 |
|
||||
| **团队协作** | ⚠️ 基础 | ✅ 完善 | ✅ 完善 |
|
||||
|
||||
### 建议
|
||||
|
||||
- **适合场景**:个人项目、小型网站、预算有限的项目
|
||||
- **升级时机**:当需要更专业的错误分析、Source Map 支持、团队协作功能时,考虑迁移到 Sentry 或 Fundebug
|
||||
- **混合方案**:同时使用 GA4 + Sentry/Fundebug,GA4 用于业务分析,专业工具用于错误调试
|
||||
|
||||
---
|
||||
|
||||
## 🔄 后续优化建议
|
||||
|
||||
### 短期优化(1-2 周)
|
||||
|
||||
1. **设置 GA4 告警**
|
||||
- 配置自定义异常检测
|
||||
- 设置每日错误摘要邮件
|
||||
|
||||
2. **创建错误仪表盘**
|
||||
- 使用 Google Data Studio
|
||||
- 可视化错误趋势、类型分布
|
||||
|
||||
3. **添加用户标识**
|
||||
- 在错误上下文中包含用户 ID(如果适用)
|
||||
- 便于复现问题
|
||||
|
||||
### 中期优化(1-3 个月)
|
||||
|
||||
1. **错误分类体系**
|
||||
- 定义错误严重级别(P0/P1/P2/P3)
|
||||
- 建立响应 SLA
|
||||
|
||||
2. **与 CI/CD 集成**
|
||||
- 将错误率纳入质量门禁
|
||||
- 自动化回归测试
|
||||
|
||||
3. **性能关联分析**
|
||||
- 关联 Core Web Vitals 与错误率
|
||||
- 发现性能瓶颈
|
||||
|
||||
### 长期优化(3-6 个月)
|
||||
|
||||
1. **A/B 测试影响评估**
|
||||
- 分析新功能上线后的错误变化
|
||||
- 量化代码质量改进效果
|
||||
|
||||
2. **预测性错误预防**
|
||||
- 基于历史数据预测潜在问题
|
||||
- 主动修复高风险代码路径
|
||||
|
||||
3. **考虑迁移到专业工具**
|
||||
- 如果错误量增长或需求复杂化
|
||||
- 评估 Sentry/Fundebug 的 ROI
|
||||
|
||||
---
|
||||
|
||||
## 📞 支持与故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Q: 为什么我在 GA4 后台看不到错误?**
|
||||
|
||||
A: 请检查以下几点:
|
||||
1. 确保 `NEXT_PUBLIC_GA_MEASUREMENT_ID` 环境变量已正确设置
|
||||
2. 使用"实时"报告而非普通报告(有 24-48 小时延迟)
|
||||
3. 检查浏览器控制台是否有 `[GA4] Error tracked:` 日志
|
||||
4. 确认没有广告拦截器阻止 gtag.js 加载
|
||||
|
||||
**Q: 如何区分开发和生产环境的错误?**
|
||||
|
||||
A: 可以在错误上下文中添加环境标识:
|
||||
|
||||
```typescript
|
||||
trackError('test_error', 'Test message', false, {
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
```
|
||||
|
||||
**Q: 错误数据量太大怎么办?**
|
||||
|
||||
A: GA4 免费版每个属性每月最多 1000 万次事件。如果超出:
|
||||
1. 调整 IGNORED_ERRORS 过滤更多无关错误
|
||||
2. 降低采样率(只上报部分错误)
|
||||
3. 升级到 GA4 360 或迁移到专业错误监控工具
|
||||
|
||||
---
|
||||
|
||||
## 📊 成功指标
|
||||
|
||||
### 上线后应关注的核心指标
|
||||
|
||||
1. **错误率**:< 1% 的 session 包含至少一个错误
|
||||
2. **致命错误率**:< 0.1% 的 session 包含致命错误
|
||||
3. **平均修复时间 (MTTR)**:< 24 小时(P0 错误 < 4 小时)
|
||||
4. **错误趋势**:周环比下降或持平
|
||||
|
||||
### 基准线建立建议
|
||||
|
||||
上线第一周:
|
||||
- 收集基准数据
|
||||
- 识别主要错误类型
|
||||
- 建立监控仪表盘
|
||||
|
||||
第二周起:
|
||||
- 设定改善目标
|
||||
- 定期审查错误报告
|
||||
- 持续优化代码质量
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
✅ **已完成**:GA4 错误监控完全集成
|
||||
✅ **已验证**:TypeScript 检查通过,构建成功
|
||||
✅ **可测试**:提供专用测试页面 `/test-error-tracking`
|
||||
✅ **可扩展**:支持自定义错误过滤和上下文
|
||||
✅ **零成本**:利用现有 GA4 账户,无需额外费用
|
||||
|
||||
**下一步行动**:
|
||||
1. 访问测试页面验证功能
|
||||
2. 部署到生产环境
|
||||
3. 在 GA4 后台设置监控
|
||||
4. 建立定期审查流程
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2026-01-15
|
||||
**维护者**: 张翔 (Zhang Xiang)
|
||||
@@ -0,0 +1,448 @@
|
||||
# Sentry 错误监控快速接入指南(免费版)
|
||||
|
||||
> **适用场景**: Novalon Website 项目
|
||||
> **Sentry 版本**: 免费版 (5,000 errors/month)
|
||||
> **预计耗时**: 15-30 分钟
|
||||
|
||||
## 📋 前置条件
|
||||
|
||||
- ✅ Sentry 账号 (如果没有,见下方注册步骤)
|
||||
- ✅ 项目已安装 `@sentry/nextjs` 依赖
|
||||
- ✅ 已创建 `sentry.*.config.ts` 配置文件
|
||||
|
||||
## 🚀 步骤 1:注册 Sentry 免费账号(如果还没有)
|
||||
|
||||
### 1.1 访问 Sentry 官网
|
||||
|
||||
打开浏览器访问: **https://sentry.io/signup**
|
||||
|
||||
### 1.2 注册账号
|
||||
|
||||
**推荐方式(最快)**:
|
||||
- 点击 "Sign up with GitHub" 或 "Sign up with Google"
|
||||
- 使用你的 Gitea/GitHub 账号直接登录
|
||||
|
||||
**或者使用邮箱注册**:
|
||||
1. 输入工作邮箱(建议用 `dev-team@novalon.cn`)
|
||||
2. 设置密码
|
||||
3. 验证邮箱(检查收件箱,点击验证链接)
|
||||
|
||||
### 1.3 创建组织(Organization)
|
||||
|
||||
注册成功后:
|
||||
1. 进入 Dashboard
|
||||
2. 点击 "Create Organization"
|
||||
3. 组织名称填写: `Novalon` 或 `Novalon-Tech`
|
||||
4. 选择团队类型: **Developer Team**(免费)
|
||||
5. 点击 "Create Organization"
|
||||
|
||||
### 1.4 创建项目(Project)
|
||||
|
||||
在组织内创建新项目:
|
||||
|
||||
1. 点击 "Create Project"
|
||||
2. 搜索并选择框架: **Next.js**
|
||||
3. 项目名称填写: `novalon-website`
|
||||
4. 团队选择: 默认团队即可
|
||||
5. 点击 "Create Project"
|
||||
|
||||
### 1.5 获取 DSN(Data Source Name)
|
||||
|
||||
项目创建完成后:
|
||||
|
||||
1. 进入项目 → **Settings** → **Client Keys (DSN)**
|
||||
2. 复制 **DSN** 值,格式如下:
|
||||
```
|
||||
https://examplePublicKey@o0.ingest.sentry.io/123456
|
||||
```
|
||||
|
||||
3. **保存这个 DSN 值**,下一步会用到!
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 步骤 2:配置环境变量
|
||||
|
||||
### 2.1 编辑 `.env.local` 文件
|
||||
|
||||
在项目根目录下创建或编辑 `.env.local`:
|
||||
|
||||
```bash
|
||||
# 复制示例配置
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
编辑 `.env.local`:
|
||||
|
||||
```bash
|
||||
# Google Analytics (如果有真实值,请替换)
|
||||
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||
|
||||
# Sentry DSN (替换为你在步骤 1.5 中获取的真实 DSN)
|
||||
NEXT_PUBLIC_SENTRY_DSN=https://your-real-dsn@o0.ingest.sentry.io/your-project-id
|
||||
SENTRY_DSN=https://your-real-dsn@o0.ingest.sentry.io/your-project-id
|
||||
|
||||
# CDN 配置(可选)
|
||||
CDN_DOMAIN=
|
||||
```
|
||||
|
||||
**⚠️ 重要提醒:**
|
||||
- ❌ 不要将 `.env.local` 提交到 Git!它已经在 `.gitignore` 中
|
||||
- ✅ 只在本地开发环境和生产服务器上配置此文件
|
||||
- 🔒 DSN 是公开的,不包含敏感信息(可以安全地用在客户端)
|
||||
|
||||
---
|
||||
|
||||
## 📦 步骤 3:安装 Sentry SDK
|
||||
|
||||
```bash
|
||||
# 安装 @sentry/nextjs 及其依赖
|
||||
npm install @sentry/nextjs @sentry/tracing
|
||||
|
||||
# 或者使用 pnpm(如果你用 pnpm)
|
||||
pnpm add @sentry/nextjs @sentry/tracing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 步骤 4:修改 next.config.ts 以集成 Sentry
|
||||
|
||||
当前你的 [next.config.ts](./next.config.ts) 需要添加 Sentry wrapper:
|
||||
|
||||
```typescript
|
||||
import type { NextConfig } from "next";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
const cdnDomain = process.env.CDN_DOMAIN || '';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
distDir: 'dist',
|
||||
output: 'export',
|
||||
assetPrefix: cdnDomain || undefined,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
compress: true,
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
optimizePackageImports: ['lucide-react'],
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
};
|
||||
|
||||
export default withSentryConfig(
|
||||
nextConfig,
|
||||
{
|
||||
silent: true,
|
||||
disableLogger: true,
|
||||
hideSourceMaps: true,
|
||||
// 自动上传 source maps 到 Sentry(推荐生产环境开启)
|
||||
disableClientWebpackPlugin: process.env.NODE_ENV !== 'production',
|
||||
disableServerWebpackPlugin: process.env.NODE_ENV !== 'production',
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**注意**:我已经为你创建了修改后的版本,可以直接替换原文件。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 步骤 5:验证 Sentry 是否正常工作
|
||||
|
||||
### 5.1 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 5.2 触发测试错误
|
||||
|
||||
浏览器访问以下地址之一:
|
||||
|
||||
```bash
|
||||
# 方法 A:访问 Sentry 内置的错误页面
|
||||
http://localhost:3000/sentry-example-error
|
||||
|
||||
# 方法 B:在浏览器控制台手动触发(更灵活)
|
||||
# 打开 http://localhost:3000,然后在控制台执行:
|
||||
throw new TestError('Sentry 测试错误 - 如果你能看到这条错误,说明 Sentry 工作正常!');
|
||||
```
|
||||
|
||||
### 5.3 在 Sentry Dashboard 验证
|
||||
|
||||
1. 登录 https://sentry.io
|
||||
2. 进入你的组织 → `novalon-website` 项目
|
||||
3. 点击左侧菜单 **Issues**
|
||||
4. 应该能看到刚刚触发的测试错误!
|
||||
|
||||
**预期结果**:
|
||||
```
|
||||
✅ Issues 列表中显示:
|
||||
- Error Type: Error (或 TestError)
|
||||
- Message: "Sentry 测试错误..."
|
||||
- First Seen: 几秒钟前
|
||||
- Events: 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 步骤 6:配置告警规则(可选但强烈推荐)
|
||||
|
||||
### 6.1 创建错误数量告警
|
||||
|
||||
1. 在 Sentry Dashboard 中,点击 **Alerts** → **Create Alert**
|
||||
2. 选择 alert 类型: **"When an issue is created"** 或 **"Number of errors is high"**
|
||||
3. 配置阈值:
|
||||
```
|
||||
Alert Type: Number of Errors
|
||||
Threshold: > 10 errors in 5 minutes
|
||||
Environment: production
|
||||
```
|
||||
4. 配置通知方式:
|
||||
- **Email**: 发送到 dev-team@novalon.cn
|
||||
- **Slack/DingTalk**: 如果集成了的话
|
||||
5. 保存告警规则
|
||||
|
||||
### 6.2 创建性能退化告警(进阶)
|
||||
|
||||
如果你想监控 Core Web Vitals:
|
||||
|
||||
1. 进入 **Settings** → **Performance**
|
||||
2. 开启 **Web Vitals** 监控
|
||||
3. 设置阈值:
|
||||
- LCP (Largest Contentful Paint): > 2.5 秒
|
||||
- FID (First Input Delay): > 100 ms
|
||||
- CLS (Cumulative Layout Shift): > 0.1
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 步骤 7:在生产环境中启用 Sentry
|
||||
|
||||
当你准备部署到生产环境时:
|
||||
|
||||
### 7.1 在生产服务器上配置环境变量
|
||||
|
||||
SSH 到你的生产服务器 (139.155.109.62):
|
||||
|
||||
```bash
|
||||
ssh root@139.155.109.62
|
||||
|
||||
# 编辑 Docker 环境变量或 .env 文件
|
||||
nano /home/novalon/docker-app/.env.production
|
||||
```
|
||||
|
||||
添加以下内容:
|
||||
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_GA_MEASUREMENT_ID=你的真实GA_ID
|
||||
NEXT_PUBLIC_SENTRY_DSN=你的真实SENTRY_DSN
|
||||
SENTRY_DSN=你的真实SENTRY_DSN
|
||||
```
|
||||
|
||||
### 7.2 重新构建和部署
|
||||
|
||||
```bash
|
||||
# 回到本地开发机
|
||||
./deploy-dist.sh
|
||||
```
|
||||
|
||||
Jenkins Pipeline 会自动:
|
||||
1. 运行测试
|
||||
2. 构建生产版本
|
||||
3. 部署到服务器
|
||||
4. Sentry 开始收集生产环境的错误数据
|
||||
|
||||
---
|
||||
|
||||
## 📊 步骤 8:查看和分析错误报告
|
||||
|
||||
### 8.1 Issues 页面(问题列表)
|
||||
|
||||
进入 **Issues** 标签页,你可以看到:
|
||||
|
||||
| 列 | 说明 | 示例 |
|
||||
|---|------|------|
|
||||
| **Issue Title** | 错误标题 | TypeError: Cannot read property 'x' of undefined |
|
||||
| **First Seen** | 首次发生时间 | 2 hours ago |
|
||||
| **Events** | 发生次数 | 23 users, 45 events |
|
||||
| **Users Affected** | 影响用户数 | 23 users |
|
||||
| **Level** | 严重程度 | error / warning / info |
|
||||
|
||||
### 8.2 Performance 页面(性能监控)
|
||||
|
||||
如果你的应用开启了性能监控:
|
||||
|
||||
- **Transaction**(事务):每个页面加载的详细耗时
|
||||
- **Span**(时间跨度):具体操作的时间分解
|
||||
- **Web Vitals**:LCP、FID、CLS 的趋势图
|
||||
|
||||
### 8.3 Releases 页面(版本追踪)
|
||||
|
||||
每次部署后,Sentry 会自动关联到 Git commit:
|
||||
|
||||
```
|
||||
Release: v1.0.0-stable
|
||||
Commit: abc1234 (feat: downgrade to stable stack)
|
||||
Deploy Time: 2026-05-12 14:30:00
|
||||
Errors: 0 (or X if any)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 高级用法(按需启用)
|
||||
|
||||
### #1:标记用户身份(提升错误排查效率)
|
||||
|
||||
在你的业务代码中(如用户登录后):
|
||||
|
||||
```typescript
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
// 当用户登录后
|
||||
Sentry.setUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
segment: 'premium', // or 'free'
|
||||
});
|
||||
```
|
||||
|
||||
**收益**:当某个用户报错时,你可以直接看到是哪个用户遇到的问题。
|
||||
|
||||
### #2:附加自定义上下文(业务相关信息)
|
||||
|
||||
```typescript
|
||||
// 在关键操作中添加上下文
|
||||
Sentry.setContext('order', {
|
||||
orderId: 'ORD-20260512-001',
|
||||
amount: 999,
|
||||
product: 'Enterprise License',
|
||||
});
|
||||
|
||||
// 如果这里出错,Sentry 会自动附带这些信息
|
||||
```
|
||||
|
||||
### #3:手动捕获特定错误
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const response = await fetch('/api/contact');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Contact form submission failed: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// 手动发送到 Sentry,并附加额外信息
|
||||
Sentry.captureException(error, {
|
||||
tags: { feature: 'contact-form' },
|
||||
extra: { formData: { name, email, subject } },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### #4:设置面包屑导航(Breadcrumbs)—— 记录用户操作路径
|
||||
|
||||
Sentry 会自动记录:
|
||||
- UI 交互(click、input、keypress)
|
||||
- 导航变化(URL 变化)
|
||||
- Console 日志
|
||||
- HTTP 请求(XHR/Fetch)
|
||||
- DOM 变化
|
||||
|
||||
你还可以手动添加:
|
||||
|
||||
```typescript
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'ui',
|
||||
message: 'User clicked "Submit" on contact form',
|
||||
level: 'info',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 故障排除
|
||||
|
||||
### 问题 1:Sentry Dashboard 看不到任何错误
|
||||
|
||||
**可能原因及解决方案**:
|
||||
|
||||
| 原因 | 解决方案 |
|
||||
|------|---------|
|
||||
| DSN 未正确配置 | 检查 `.env.local` 中的 `NEXT_PUBLIC_SENTRY_DSN` 是否有值 |
|
||||
| 环境变量未生效 | 重启开发服务器 (`npm run dev`) |
|
||||
| `enabled: false` 在开发环境 | 检查 `sentry.client.config.ts` 中的 `enabled` 条件 |
|
||||
| 网络问题 | 检查是否能访问 `o0.ingest.sentry.io` |
|
||||
|
||||
**调试方法**:
|
||||
```typescript
|
||||
// 在 sentry.client.config.ts 中临时添加
|
||||
console.log('[Sentry] DSN:', SENTRY_DSN);
|
||||
console.log('[Sentry] Enabled:', process.env.NODE_ENV !== 'development');
|
||||
```
|
||||
|
||||
### 问题 2:Source Maps 未上传
|
||||
|
||||
**现象**:Sentry 显示的错误堆栈是压缩后的代码,难以阅读。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 安装 `@sentry/webpack-plugin`:
|
||||
```bash
|
||||
npm install --save-dev @sentry/webpack-plugin
|
||||
```
|
||||
|
||||
2. 在 `next.config.ts` 中配置:
|
||||
```typescript
|
||||
export default withSentryConfig(
|
||||
nextConfig,
|
||||
{
|
||||
silent: true,
|
||||
hideSourceMaps: false, // 改为 false
|
||||
// 其他配置...
|
||||
},
|
||||
{
|
||||
include: './dist/**',
|
||||
ignore: ['node_modules'],
|
||||
urlPrefix: '~/_next',
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 问题 3:免费额度超限
|
||||
|
||||
**Sentry 免费版限制**:
|
||||
- 每月 5,000 个错误事件
|
||||
- 保留 30 天的数据
|
||||
- 5 个团队成员
|
||||
|
||||
**如果超限**:
|
||||
1. 升级到 Developer 版本 ($26/月,50,000 errors/month)
|
||||
2. 或者优化错误过滤(减少噪音)
|
||||
3. 或者在 `ignoreErrors` 中添加更多模式
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档链接
|
||||
|
||||
- **Sentry 官方文档**: https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
- **Next.js 集成指南**: https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
- **最佳实践**: https://docs.sentry.io/product/best-practices/event-grouping/
|
||||
|
||||
---
|
||||
|
||||
## ✅ 接入完成清单
|
||||
|
||||
请逐项确认:
|
||||
|
||||
- [ ] 已注册 Sentry 账号并创建项目
|
||||
- [ ] 已获取 DSN 并配置到 `.env.local`
|
||||
- [ ] 已安装 `@sentry/nextjs` 和 `@sentry/tracing`
|
||||
- [ ] 已修改 `next.config.ts` 添加 `withSentryConfig`
|
||||
- [ ] 已运行 `npm run dev` 并访问 `/sentry-example-error`
|
||||
- [ ] 已在 Sentry Dashboard 看到测试错误
|
||||
- [ ] (可选)已配置告警规则
|
||||
- [ ] (可选)已部署到生产环境并验证
|
||||
|
||||
全部完成后,你的 Novalon Website 就拥有了**企业级的错误监控能力**!🎉
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const cdnDomain = process.env.CDN_DOMAIN || '';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
const nextConfig = {
|
||||
distDir: 'dist',
|
||||
output: 'export',
|
||||
assetPrefix: cdnDomain || undefined,
|
||||
Generated
+5947
-3417
File diff suppressed because it is too large
Load Diff
+11
-8
@@ -40,14 +40,16 @@
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@sentry/nextjs": "^10.52.0",
|
||||
"@sentry/tracing": "^7.120.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.34.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"next": "^14.2.21",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -62,19 +64,19 @@
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
||||
"@typescript-eslint/parser": "^8.57.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"chrome-launcher": "^1.2.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^0.2.4",
|
||||
"eslint-config-next": "^14.2.21",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"husky": "^9.1.7",
|
||||
@@ -83,7 +85,8 @@
|
||||
"k6": "^0.0.0",
|
||||
"lighthouse": "^13.0.3",
|
||||
"lint-staged": "^16.4.0",
|
||||
"tailwindcss": "^4",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "🔄 Novalon Website 技术栈降级脚本"
|
||||
echo "从: Next.js 16 + React 19 + Tailwind CSS 4"
|
||||
echo "到: Next.js 14.2 + React 18.3 + Tailwind CSS 3.4"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
BACKUP_DIR="./backup-$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
echo "📦 Step 1: 备份关键配置文件..."
|
||||
cp package.json "$BACKUP_DIR/"
|
||||
cp postcss.config.mjs "$BACKUP_DIR/" 2>/dev/null || true
|
||||
cp src/app/globals.css "$BACKUP_DIR/" 2>/dev/null || true
|
||||
echo "✅ 配置文件已备份到 $BACKUP_DIR"
|
||||
|
||||
echo ""
|
||||
echo "🗑️ Step 2: 清理旧依赖和缓存..."
|
||||
rm -rf node_modules
|
||||
rm -f package-lock.json pnpm-lock.yaml yarn.lock
|
||||
rm -rf .next dist
|
||||
rm -rf .cache
|
||||
echo "✅ 缓存已清理"
|
||||
|
||||
echo ""
|
||||
echo "📥 Step 3: 安装新版本依赖..."
|
||||
npm install --registry=https://registry.npmmirror.com || {
|
||||
echo "❌ npm install 失败!尝试使用淘宝镜像..."
|
||||
npm install --registry=https://registry.npmmirror.com
|
||||
}
|
||||
echo "✅ 依赖安装完成"
|
||||
|
||||
echo ""
|
||||
echo "🔍 Step 4: 验证核心依赖版本..."
|
||||
echo ""
|
||||
echo "┌─────────────────┬──────────────────┬────────────────────┐"
|
||||
echo "│ 依赖包 │ 期望版本 │ 实际安装版本 │"
|
||||
echo "├─────────────────┼──────────────────┼────────────────────┤"
|
||||
|
||||
check_version() {
|
||||
local pkg=$1
|
||||
local expected=$2
|
||||
local actual=$(node -e "console.log(require('./node_modules/$pkg/package.json').version)" 2>/dev/null || echo "未安装")
|
||||
|
||||
if [[ "$actual" == *"$expected"* ]]; then
|
||||
status="✅"
|
||||
else
|
||||
status="⚠️"
|
||||
fi
|
||||
|
||||
printf "│ %-15s │ %-16s │ %-18s │\n" "$pkg" "$expected" "$actual"
|
||||
}
|
||||
|
||||
check_version "next" "^14.2"
|
||||
check_version "react" "^18.3"
|
||||
check_version "react-dom" "^18.3"
|
||||
check_version "tailwindcss" "^3.4"
|
||||
check_version "@types/react" "^18"
|
||||
check_version "@types/react-dom" "^18"
|
||||
check_version "eslint-config-next" "^14"
|
||||
|
||||
echo "└─────────────────┴──────────────────┴────────────────────┘"
|
||||
echo ""
|
||||
|
||||
echo "🧪 Step 5: 运行快速验证..."
|
||||
if npm run lint --silent > /dev/null 2>&1; then
|
||||
echo "✅ ESLint 检查通过"
|
||||
else
|
||||
echo "⚠️ ESLint 检查有警告(可忽略)"
|
||||
fi
|
||||
|
||||
if npx tsc --noEmit --pretty > /dev/null 2>&1; then
|
||||
echo "✅ TypeScript 类型检查通过"
|
||||
else
|
||||
echo "⚠️ TypeScript 类型检查有错误(需要修复)"
|
||||
echo " 请运行 'npx tsc --noEmit' 查看详细错误"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏗️ Step 6: 测试构建(可选,耗时较长)..."
|
||||
read -p "是否现在运行生产构建测试?(y/n): " build_choice
|
||||
|
||||
if [ "$build_choice" = "y" ] || [ "$build_choice" = "Y" ]; then
|
||||
echo " 正在构建...(预计 2-5 分钟)"
|
||||
if npm run build:clean > /tmp/build-output.log 2>&1; then
|
||||
echo "✅ 生产构建成功!"
|
||||
|
||||
if [ -d "dist" ]; then
|
||||
file_count=$(find dist -type f | wc -l)
|
||||
dist_size=$(du -sh dist | cut -f1)
|
||||
echo " 📁 生成了 $file_count 个文件,总大小: $dist_size"
|
||||
fi
|
||||
else
|
||||
echo "❌ 构建失败!查看日志:"
|
||||
echo " cat /tmp/build-output.log"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "⏭️ 跳过构建测试(稍后可手动运行 'npm run build:clean')"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "🎉 技术栈降级完成!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "📋 后续操作:"
|
||||
echo " 1. 运行 'npm run dev' 启动开发服务器验证"
|
||||
echo " 2. 运行 'npm run test:unit' 执行单元测试"
|
||||
echo " 3. 运行 'npm run test:e2e' 执行 E2E 测试"
|
||||
echo " 4. 如果一切正常,提交代码到 Git"
|
||||
echo ""
|
||||
echo "📋 回滚方法(如果遇到问题):"
|
||||
echo " cp $BACKUP_DIR/package.json ./package.json"
|
||||
echo " # ... 恢复其他配置文件"
|
||||
echo " rm -rf node_modules && npm install"
|
||||
echo ""
|
||||
echo "💡 备份位置: $BACKUP_DIR/"
|
||||
echo ""
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN || '';
|
||||
|
||||
if (SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
enabled: process.env.NODE_ENV !== 'development',
|
||||
|
||||
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.2 : 0,
|
||||
replaysSessionSampleRate: 0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
ignoreErrors: [
|
||||
/_AutofillCallbackHandler/,
|
||||
/NetworkError/,
|
||||
/webkit\.messageHandlers/,
|
||||
/Non-Error promise rejection captured/,
|
||||
/^Loading CSS chunk.*failed/,
|
||||
/^Loading chunk.*failed/,
|
||||
/^Failed to fetch dynamically imported module/,
|
||||
],
|
||||
|
||||
denyUrls: [
|
||||
/extensions\//i,
|
||||
/^chrome:\/\//i,
|
||||
/webappstoolbars/i,
|
||||
/safari-extension:/i,
|
||||
/mypersonalassistant/i,
|
||||
],
|
||||
|
||||
initialScope: {
|
||||
tags: {
|
||||
project: 'novalon-website',
|
||||
version: process.env.npm_package_version || 'unknown',
|
||||
deployment: process.env.NODE_ENV || 'unknown',
|
||||
},
|
||||
user: {
|
||||
id: 'anonymous',
|
||||
segment: 'visitor',
|
||||
},
|
||||
},
|
||||
|
||||
beforeSend(event) {
|
||||
if (event.request?.url) {
|
||||
event.request.url = event.request.url.replace(/\/[a-f0-9]{32}/gi, '/[hash]');
|
||||
}
|
||||
|
||||
if (event.breadcrumbs) {
|
||||
event.breadcrumbs = event.breadcrumbs.filter((breadcrumb) => {
|
||||
return !(
|
||||
breadcrumb.category === 'console' &&
|
||||
breadcrumb.level === 'log'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: true,
|
||||
blockAllMedia: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
console.log('[Sentry] Client-side monitoring initialized');
|
||||
} else {
|
||||
console.warn('[Sentry] NEXT_PUBLIC_SENTRY_DSN not configured, error monitoring disabled');
|
||||
}
|
||||
|
||||
export default Sentry;
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
const SENTRY_DSN = process.env.SENTRY_DSN || '';
|
||||
|
||||
if (SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
enabled: process.env.NODE_ENV !== 'development',
|
||||
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
export default Sentry;
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
const SENTRY_DSN = process.env.SENTRY_DSN || '';
|
||||
|
||||
if (SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
enabled: process.env.NODE_ENV !== 'development',
|
||||
|
||||
tracesSampleRate: 1.0,
|
||||
|
||||
beforeSend(event, hint) {
|
||||
const error = hint.originalException;
|
||||
|
||||
if (error instanceof Error) {
|
||||
event.contexts = {
|
||||
...event.contexts,
|
||||
runtime: {
|
||||
name: 'node',
|
||||
version: process.version,
|
||||
},
|
||||
application: {
|
||||
name: 'novalon-website',
|
||||
version: process.env.npm_package_version || 'unknown',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.request?.cookies) {
|
||||
delete event.request.cookies['session_id'];
|
||||
delete event.request.cookies['token'];
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[Sentry] Server-side monitoring initialized');
|
||||
} else {
|
||||
console.warn('[Sentry] SENTRY_DSN not configured, server-side error monitoring disabled');
|
||||
}
|
||||
|
||||
export default Sentry;
|
||||
+3
-8
@@ -1,11 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-noto-sans-sc), var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-chinese: var(--font-noto-sans-sc), sans-serif;
|
||||
--font-calligraphy: var(--font-ma-shan-zheng), 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-primary: #1C1C1C;
|
||||
|
||||
@@ -5,6 +5,7 @@ import "./globals.css";
|
||||
import { Suspense } from "react";
|
||||
import { ThemeProvider } from "@/contexts/theme-context";
|
||||
import { GoogleAnalyticsWrapper } from "@/components/analytics/GoogleAnalyticsWrapper";
|
||||
import { GlobalErrorTracker } from "@/components/analytics/GlobalErrorTracker";
|
||||
import { CookieConsent } from "@/components/analytics/CookieConsent";
|
||||
import { PerformanceTracker } from "@/components/analytics/PerformanceTracker";
|
||||
import { OutboundLinkTracker } from "@/components/analytics/OutboundLinkTracker";
|
||||
@@ -151,6 +152,7 @@ export default function RootLayout({
|
||||
</a>
|
||||
<ScrollProgress />
|
||||
<GoogleAnalyticsWrapper />
|
||||
<GlobalErrorTracker />
|
||||
<PerformanceTracker />
|
||||
<OutboundLinkTracker />
|
||||
<ScrollDepthTracker />
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { trackError } from '@/lib/analytics';
|
||||
|
||||
export default function TestErrorTrackingPage() {
|
||||
const [testResults, setTestResults] = useState<string[]>([]);
|
||||
|
||||
const addResult = (message: string) => {
|
||||
setTestResults((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]);
|
||||
};
|
||||
|
||||
const testJavaScriptError = () => {
|
||||
try {
|
||||
addResult('🧪 测试 1: 触发 JavaScript 运行时错误...');
|
||||
throw new Error('测试错误:这是一个故意的 JavaScript 错误');
|
||||
} catch (error) {
|
||||
trackError('javascript_error', error.message, false, {
|
||||
test_id: 'test_js_error',
|
||||
filename: 'test-error-page.tsx',
|
||||
lineno: 20,
|
||||
});
|
||||
addResult('✅ JavaScript 错误已发送到 GA4');
|
||||
}
|
||||
};
|
||||
|
||||
const testPromiseRejection = () => {
|
||||
addResult('🧪 测试 2: 触发 Promise 未捕获异常...');
|
||||
Promise.reject(new Error('测试错误:Promise 故意拒绝'));
|
||||
addResult('✅ Promise 异常已触发(由 GlobalErrorTracker 自动捕获)');
|
||||
};
|
||||
|
||||
const testReactError = () => {
|
||||
addResult('🧪 测试 3: 触发 React 渲染错误...');
|
||||
trackError('react_error', '组件渲染失败:测试故意错误', true, {
|
||||
component: 'TestComponent',
|
||||
stack_trace: 'Error: 测试错误\n at TestComponent...',
|
||||
});
|
||||
addResult('✅ React 错误已发送到 GA4(标记为 fatal)');
|
||||
};
|
||||
|
||||
const testNetworkError = () => {
|
||||
addResult('🧪 测试 4: 模拟网络请求错误...');
|
||||
trackError('network_error', 'Failed to fetch: https://api.example.com/data', false, {
|
||||
url: 'https://api.example.com/data',
|
||||
status_code: 0,
|
||||
request_method: 'GET',
|
||||
});
|
||||
addResult('✅ 网络错误已发送到 GA4');
|
||||
};
|
||||
|
||||
const clearResults = () => {
|
||||
setTestResults([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">
|
||||
🧪 GA4 错误监控测试面板
|
||||
</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
测试用例
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={testJavaScriptError}
|
||||
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
📛 测试 JavaScript 错误
|
||||
</button>
|
||||
<button
|
||||
onClick={testPromiseRejection}
|
||||
className="px-6 py-3 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
⚠️ 测试 Promise 异常
|
||||
</button>
|
||||
<button
|
||||
onClick={testReactError}
|
||||
className="px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
💥 测试 React 致命错误
|
||||
</button>
|
||||
<button
|
||||
onClick={testNetworkError}
|
||||
className="px-6 py-3 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors"
|
||||
>
|
||||
🌐 测试网络错误
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={clearResults}
|
||||
className="mt-4 px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
🗑️ 清除日志
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResults.length > 0 && (
|
||||
<div className="bg-gray-900 rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-green-400 mb-4">
|
||||
📋 测试日志
|
||||
</h2>
|
||||
<div className="space-y-2 font-mono text-sm">
|
||||
{testResults.map((result, index) => (
|
||||
<div key={index} className="text-green-300">
|
||||
{result}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
||||
💡 如何验证错误是否成功发送到 GA4?
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-yellow-700">
|
||||
<li>点击上面的测试按钮</li>
|
||||
<li>
|
||||
打开{' '}
|
||||
<a
|
||||
href="https://analytics.google.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-semibold"
|
||||
>
|
||||
Google Analytics 后台
|
||||
</a>
|
||||
</li>
|
||||
<li>导航到:报告 → 实时</li>
|
||||
<li>在事件搜索框输入 <code className="bg-yellow-100 px-1">exception</code></li>
|
||||
<li>你应该能在 30 秒内看到错误事件出现!</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-2">
|
||||
🔧 开发者工具调试
|
||||
</h3>
|
||||
<p className="text-blue-700 mb-2">
|
||||
在开发模式下,打开浏览器控制台(F12),你会看到类似这样的日志:
|
||||
</p>
|
||||
<pre className="bg-blue-900 text-green-300 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{`[GA4] Error tracked: {
|
||||
description: "[javascript_error] 测试错误:这是一个故意的 JavaScript 错误",
|
||||
fatal: "false",
|
||||
url: "http://localhost:3000/test-error-tracking",
|
||||
timestamp: "2025-01-15T10:30:00.000Z",
|
||||
test_id: "test_js_error",
|
||||
filename: "test-error-page.tsx",
|
||||
lineno: 20
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export function CookieConsent() {
|
||||
necessary: true,
|
||||
analytics: legacyConsent === 'granted',
|
||||
marketing: false,
|
||||
functionality: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
storePreferences(migratedPrefs);
|
||||
@@ -91,6 +92,7 @@ export function CookieConsent() {
|
||||
necessary: true,
|
||||
analytics: true,
|
||||
marketing: false,
|
||||
functionality: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
handleSavePreferences(allAccepted);
|
||||
@@ -102,6 +104,7 @@ export function CookieConsent() {
|
||||
necessary: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functionality: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
handleSavePreferences(allRejected);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { trackError } from '@/lib/analytics';
|
||||
|
||||
const IGNORED_ERRORS = [
|
||||
/_AutofillCallbackHandler/,
|
||||
/ResizeObserver loop limit exceeded/,
|
||||
/webkit\.messageHandlers/,
|
||||
/Non-Error promise rejection captured/,
|
||||
/^Loading CSS chunk.*failed$/,
|
||||
/^Loading chunk.*failed$/,
|
||||
/^Failed to fetch dynamically imported module/,
|
||||
/Script error/,
|
||||
/NetworkError/,
|
||||
];
|
||||
|
||||
function shouldIgnoreError(message: string): boolean {
|
||||
return IGNORED_ERRORS.some((pattern) => pattern.test(message));
|
||||
}
|
||||
|
||||
export function GlobalErrorTracker() {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleGlobalError = (
|
||||
event: ErrorEvent
|
||||
) => {
|
||||
if (shouldIgnoreError(event.message)) return;
|
||||
|
||||
trackError(
|
||||
'javascript_error',
|
||||
event.message,
|
||||
false,
|
||||
{
|
||||
filename: event.filename || '',
|
||||
lineno: event.lineno || 0,
|
||||
colno: event.colno || 0,
|
||||
stack: event.error?.stack?.slice(0, 500) || '',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
const message =
|
||||
event.reason?.message || String(event.reason) || 'Unknown promise rejection';
|
||||
|
||||
if (shouldIgnoreError(message)) return;
|
||||
|
||||
trackError(
|
||||
'unhandled_promise_rejection',
|
||||
message,
|
||||
false,
|
||||
{
|
||||
reason_type: event.reason?.constructor?.name || 'Unknown',
|
||||
stack: event.reason?.stack?.slice(0, 500) || '',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleResourceError = () => {
|
||||
trackError('resource_loading_error', 'Failed to load resource', false);
|
||||
};
|
||||
|
||||
window.addEventListener('error', handleGlobalError, true);
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
document.addEventListener('error', handleResourceError, true);
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[GA4] Global error tracker initialized');
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('error', handleGlobalError, true);
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
document.removeEventListener('error', handleResourceError, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
+168
-190
@@ -1,215 +1,193 @@
|
||||
export const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag: (
|
||||
command: string,
|
||||
targetIdOrParams: string | Record<string, unknown>,
|
||||
config?: Record<string, unknown>
|
||||
) => void;
|
||||
gtag: (...args: unknown[]) => void;
|
||||
dataLayer: unknown[];
|
||||
}
|
||||
}
|
||||
|
||||
export const pageview = (url: string) => {
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('config', GA_MEASUREMENT_ID, {
|
||||
page_path: url,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const event = (action: string, category: string, label?: string, value?: number) => {
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const trackContactForm = (formData: Record<string, string>) => {
|
||||
event('generate_lead', 'engagement', 'contact_form_submission');
|
||||
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', 'contact_form', {
|
||||
event_category: 'lead_generation',
|
||||
event_label: formData.company || 'unknown_company',
|
||||
company_size: formData.company ? 'provided' : 'not_provided',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const trackButtonClick = (buttonName: string, location: string) => {
|
||||
event('click', 'button', `${location}_${buttonName}`);
|
||||
};
|
||||
|
||||
export const trackPageView = (pageTitle: string, _pagePath: string) => {
|
||||
event('page_view', 'navigation', pageTitle);
|
||||
};
|
||||
|
||||
export const trackConversion = (conversionName: string, value?: number) => {
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', 'conversion', {
|
||||
send_to: `${GA_MEASUREMENT_ID}/${conversionName}`,
|
||||
value: value,
|
||||
currency: 'CNY',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const trackError = (errorType: string, errorMessage: string, fatal: boolean = false) => {
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', 'exception', {
|
||||
description: `${errorType}: ${errorMessage}`,
|
||||
fatal: fatal,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const trackPerformance = (metricName: string, value: number) => {
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', 'web_vitals', {
|
||||
name: metricName,
|
||||
value: Math.round(value),
|
||||
event_category: 'Web Vitals',
|
||||
non_interaction: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const trackScrollDepth = (percentage: number) => {
|
||||
event('scroll', 'engagement', `${percentage}%`, percentage);
|
||||
};
|
||||
|
||||
export const trackDownload = (fileName: string, fileType: string) => {
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', 'file_download', {
|
||||
event_category: 'downloads',
|
||||
event_label: fileName,
|
||||
file_extension: fileType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const trackOutboundLink = (url: string) => {
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', 'click', {
|
||||
event_category: 'outbound',
|
||||
event_label: url,
|
||||
transport_type: 'beacon',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const trackVideo = (action: 'play' | 'pause' | 'complete' | 'progress', videoTitle: string, progress?: number) => {
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', `video_${action}`, {
|
||||
event_category: 'videos',
|
||||
event_label: videoTitle,
|
||||
video_percent: progress,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const trackEngagement = (action: string, details?: Record<string, unknown>) => {
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', action, {
|
||||
event_category: 'engagement',
|
||||
...details,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const trackSectionView = (sectionName: string) => {
|
||||
event('section_view', 'navigation', sectionName);
|
||||
};
|
||||
|
||||
export const trackCaseView = (caseId: string, caseTitle: string) => {
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', 'view_item', {
|
||||
event_category: 'case_studies',
|
||||
event_label: caseTitle,
|
||||
item_id: caseId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const trackServiceInterest = (serviceName: string) => {
|
||||
event('service_interest', 'engagement', serviceName);
|
||||
};
|
||||
|
||||
export const trackProductView = (productId: string, productName: string) => {
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', 'view_item', {
|
||||
event_category: 'products',
|
||||
event_label: productName,
|
||||
item_id: productId,
|
||||
});
|
||||
}
|
||||
};
|
||||
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
|
||||
|
||||
export interface CookiePreferences {
|
||||
necessary: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
timestamp: number;
|
||||
functionality: boolean;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export const updateConsent = (granted: boolean) => {
|
||||
if (typeof window !== 'undefined' && window.gtag) {
|
||||
window.gtag('consent', 'update', {
|
||||
analytics_storage: granted ? 'granted' : 'denied',
|
||||
ad_storage: 'denied',
|
||||
});
|
||||
}
|
||||
const DEFAULT_PREFERENCES: CookiePreferences = {
|
||||
necessary: true,
|
||||
analytics: true,
|
||||
marketing: false,
|
||||
functionality: true,
|
||||
};
|
||||
|
||||
export const updateConsentDetailed = (preferences: CookiePreferences) => {
|
||||
if (typeof window !== 'undefined' && window.gtag) {
|
||||
window.gtag('consent', 'update', {
|
||||
analytics_storage: preferences.analytics ? 'granted' : 'denied',
|
||||
ad_storage: preferences.marketing ? 'granted' : 'denied',
|
||||
functionality_storage: 'granted',
|
||||
personalization_storage: preferences.marketing ? 'granted' : 'denied',
|
||||
security_storage: 'granted',
|
||||
});
|
||||
export function getDefaultPreferences(): CookiePreferences {
|
||||
return { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
|
||||
if (preferences.analytics) {
|
||||
window.gtag('config', GA_MEASUREMENT_ID, {
|
||||
page_path: window.location.pathname + window.location.search,
|
||||
page_title: document.title,
|
||||
page_location: window.location.href,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getStoredPreferences = (): CookiePreferences | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
export function getStoredPreferences(): CookiePreferences | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem('cookie_preferences');
|
||||
const stored = localStorage.getItem('novalon-cookie-preferences');
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as CookiePreferences;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.warn('[Analytics] Failed to read cookie preferences:', e);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export const storePreferences = (preferences: CookiePreferences) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('cookie_preferences', JSON.stringify(preferences));
|
||||
export function storePreferences(preferences: CookiePreferences): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'novalon-cookie-preferences',
|
||||
JSON.stringify(preferences)
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[Analytics] Failed to store cookie preferences:', e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const getDefaultPreferences = (): CookiePreferences => ({
|
||||
necessary: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
export function updateConsentDetailed(preferences: CookiePreferences): void {
|
||||
if (typeof window === 'undefined' || !window.gtag) return;
|
||||
|
||||
window.gtag('consent', 'update', {
|
||||
analytics_storage: preferences.analytics ? 'granted' : 'denied',
|
||||
ad_storage: preferences.marketing ? 'granted' : 'denied',
|
||||
functionality_storage: preferences.functionality ? 'granted' : 'denied',
|
||||
});
|
||||
}
|
||||
|
||||
export function trackEvent(
|
||||
action: string,
|
||||
category: string,
|
||||
label?: string,
|
||||
value?: number
|
||||
): void {
|
||||
if (typeof window === 'undefined' || !window.gtag || !GA_MEASUREMENT_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.gtag('event', action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
event_value: value,
|
||||
send_to: GA_MEASUREMENT_ID,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackButtonClick(
|
||||
buttonName: string,
|
||||
_pageLocation?: string
|
||||
): void {
|
||||
trackEvent('button_click', 'engagement', buttonName, undefined);
|
||||
}
|
||||
|
||||
export function trackError(
|
||||
errorType: string,
|
||||
message: string,
|
||||
fatal: boolean = false,
|
||||
additionalContext?: Record<string, string | number | boolean>
|
||||
): void {
|
||||
if (typeof window === 'undefined' || !window.gtag || !GA_MEASUREMENT_ID) {
|
||||
console.warn('[GA4] Error tracking not available:', { errorType, message });
|
||||
return;
|
||||
}
|
||||
|
||||
const errorData: Record<string, string | number | boolean> = {
|
||||
description: `[${errorType}] ${message}`,
|
||||
fatal: fatal ? 'true' : 'false',
|
||||
url: typeof window !== 'undefined' ? window.location.href : '',
|
||||
timestamp: new Date().toISOString(),
|
||||
...additionalContext,
|
||||
};
|
||||
|
||||
window.gtag('event', 'exception', {
|
||||
...errorData,
|
||||
send_to: GA_MEASUREMENT_ID,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[GA4] Error tracked:', errorData);
|
||||
}
|
||||
}
|
||||
|
||||
export function trackPageView(url: string, title: string): void {
|
||||
if (typeof window === 'undefined' || !window.gtag || !GA_MEASUREMENT_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.gtag('config', GA_MEASUREMENT_ID, {
|
||||
page_path: url,
|
||||
page_title: title,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackPerformance(
|
||||
metricName: string,
|
||||
value: number,
|
||||
category: string = 'web_vitals'
|
||||
): void {
|
||||
if (typeof window === 'undefined' || !window.gtag || !GA_MEASUREMENT_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.gtag('event', metricName, {
|
||||
event_category: category,
|
||||
event_value: Math.round(value),
|
||||
value: Math.round(value),
|
||||
send_to: GA_MEASUREMENT_ID,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackContactForm(
|
||||
formData: { name: string; email: string; company: string } | string,
|
||||
success?: boolean
|
||||
): void {
|
||||
if (typeof formData === 'string') {
|
||||
trackEvent('form_submit', 'contact', formData, success ? 1 : 0);
|
||||
} else {
|
||||
trackEvent('form_submit', 'contact', formData.company, success !== false ? 1 : 0);
|
||||
}
|
||||
|
||||
if (success !== false) {
|
||||
trackConversion('contact_form_submit', 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function trackConversion(conversionLabel: string, value?: number): void {
|
||||
if (typeof window === 'undefined' || !window.gtag || !GA_MEASUREMENT_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.gtag('event', 'conversion', {
|
||||
send_to: GA_MEASUREMENT_ID,
|
||||
transaction_id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
value: value || 1,
|
||||
currency: 'CNY',
|
||||
conversion_label: conversionLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackOutboundLink(url: string, _linkText?: string): void {
|
||||
trackEvent('outbound_click', 'engagement', url);
|
||||
|
||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||
window.gtag('event', 'click', {
|
||||
event_category: 'outbound',
|
||||
event_label: url,
|
||||
transport_type: 'beacon',
|
||||
send_to: GA_MEASUREMENT_ID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function trackScrollDepth(percentage: number, _maxScroll?: number): void {
|
||||
trackEvent(`scroll_${percentage}`, 'engagement', `${percentage}%`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'var(--font-noto-sans-sc)',
|
||||
'var(--font-geist-sans)',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
],
|
||||
mono: ['var(--font-geist-mono)', 'monospace'],
|
||||
chinese: ['var(--font-noto-sans-sc)', 'sans-serif'],
|
||||
calligraphy: [
|
||||
'var(--font-ma-shan-zheng)',
|
||||
"'ZCOOL XiaoWei'",
|
||||
"'STKaiti'",
|
||||
"'KaiTi'",
|
||||
'serif',
|
||||
],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#1C1C1C',
|
||||
hover: '#0A0A0A',
|
||||
light: '#3D3D3D',
|
||||
lighter: '#F5F5F5',
|
||||
rgb: '28, 28, 28',
|
||||
},
|
||||
brand: {
|
||||
primary: '#C41E3A',
|
||||
hover: '#A01830',
|
||||
light: '#E04A68',
|
||||
'primary-bg': '#FEF2F4',
|
||||
},
|
||||
bg: {
|
||||
primary: '#FFFFFF',
|
||||
secondary: '#FFFBF5',
|
||||
tertiary: '#F5F5F5',
|
||||
section: '#FAFAFA',
|
||||
hover: '#EFEFEF',
|
||||
},
|
||||
text: {
|
||||
primary: '#1C1C1C',
|
||||
secondary: '#3D3D3D',
|
||||
tertiary: '#404040',
|
||||
muted: '#595959',
|
||||
subtle: '#A3A3A3',
|
||||
placeholder: '#5C5C5C',
|
||||
hint: '#8C8C8C',
|
||||
},
|
||||
border: {
|
||||
primary: '#E5E5E5',
|
||||
secondary: '#D4D4D4',
|
||||
accent: '#1C1C1C',
|
||||
light: '#F0F0F0',
|
||||
dark: '#333333',
|
||||
},
|
||||
link: {
|
||||
DEFAULT: '#1C1C1C',
|
||||
hover: '#C41E3A',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: '#16A34A',
|
||||
hover: '#15803D',
|
||||
bg: '#F0DF4',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: '#D97706',
|
||||
hover: '#B45309',
|
||||
bg: '#FFFBEB',
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'88': '22rem',
|
||||
'128': '32rem',
|
||||
},
|
||||
borderRadius: {
|
||||
'4xl': '2rem',
|
||||
'5xl': '2.5rem',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.5s ease-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
+5
-2
@@ -20,9 +20,12 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"types": ["jest", "node"],
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
Reference in New Issue
Block a user