dev #11

Merged
zhangxiang merged 5 commits from dev into main 2026-04-22 22:31:39 +08:00
37 changed files with 1845 additions and 646 deletions
View File
+47
View File
@@ -0,0 +1,47 @@
server {
listen 80;
server_name ci.f.novalon.cn;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name ci.f.novalon.cn;
ssl_certificate /etc/nginx/ssl/ci.f.novalon.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/ci.f.novalon.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://jenkins_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_hide_header X-Powered-By;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
client_max_body_size 100m;
}
access_log /var/log/nginx/jenkins-access.log;
error_log /var/log/nginx/jenkins-error.log;
}
+51
View File
@@ -0,0 +1,51 @@
server {
listen 80;
server_name git.f.novalon.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name git.f.novalon.cn;
ssl_certificate /etc/nginx/ssl/git.f.novalon.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/git.f.novalon.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://gitea_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Powered-By;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
access_log /var/log/nginx/gitea-access.log;
error_log /var/log/nginx/gitea-error.log;
}
Executable
+142
View File
@@ -0,0 +1,142 @@
#!/bin/bash
set -e
SERVER_IP="139.155.109.62"
SERVER_USER="root"
DEPLOY_ROOT="/home/novalon/docker-app"
PROJECT_NAME="novalon-website"
DIST_DIR="dist"
STATIC_DIR="novalon-static"
NGINX_CONTAINER="novalon-nginx-secure"
while getopts "i:u:p:h" opt; do
case $opt in
i) SERVER_IP="$OPTARG" ;;
u) SERVER_USER="$OPTARG" ;;
p) PROJECT_NAME="$OPTARG" ;;
h)
echo "用法: $0 [选项]"
echo "选项:"
echo " -i IP地址 服务器IP地址 (默认: 139.155.109.62)"
echo " -u 用户名 SSH用户名 (默认: root)"
echo " -p 项目名 项目名称 (默认: novalon-website)"
echo " -h 显示帮助信息"
exit 0
;;
\?)
echo "无效选项: -$OPTARG" >&2
exit 1
;;
esac
done
LOG_DIR="./logs"
LOG_FILE="$LOG_DIR/deploy_dist_$(date +%Y%m%d_%H%M%S).log"
mkdir -p "$LOG_DIR"
exec > >(tee -a "$LOG_FILE") 2>&1
echo "🚀 开始部署 dist 到服务器 $SERVER_IP"
echo "📁 部署目录: $DEPLOY_ROOT/$STATIC_DIR"
echo "📋 部署日志: $LOG_FILE"
echo ""
echo "📋 步骤1: 检查 dist 目录..."
if [ ! -d "$DIST_DIR" ]; then
echo "❌ dist 目录不存在,请先运行 pnpm build"
exit 1
fi
DIST_SIZE=$(du -sh "$DIST_DIR" | cut -f1)
echo "✅ dist 目录大小: $DIST_SIZE"
echo ""
echo "📋 步骤2: 验证SSH连接..."
if ! ssh -o ConnectTimeout=5 "$SERVER_USER@$SERVER_IP" exit; then
echo "❌ 无法连接到服务器 $SERVER_IP"
exit 1
fi
echo "✅ SSH连接验证成功"
echo ""
echo "📋 步骤3: 备份旧版本..."
ssh "$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\"
echo \"✅ 备份完成\"
else
echo \"️ 无需备份,目录不存在\"
fi
"
echo ""
echo "📋 步骤4: 上传 dist 目录..."
ssh "$SERVER_USER@$SERVER_IP" "mkdir -p '$DEPLOY_ROOT/$STATIC_DIR'"
rsync -avz --delete "$DIST_DIR/" "$SERVER_USER@$SERVER_IP:$DEPLOY_ROOT/$STATIC_DIR/"
echo "✅ dist 目录已上传"
echo ""
echo "📋 步骤5: 设置文件权限..."
ssh "$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\"
echo \"✅ 权限设置完成\"
"
echo ""
echo "📋 步骤6: 清除 Nginx 缓存..."
ssh "$SERVER_USER@$SERVER_IP" "
set -e
docker exec $NGINX_CONTAINER nginx -s reload 2>/dev/null || echo \"⚠️ Nginx 重载失败,尝试重启容器\"
echo \"✅ Nginx 缓存已清除\"
"
echo ""
echo "📋 步骤7: 验证部署..."
if ! ssh "$SERVER_USER@$SERVER_IP" "[ -d '$DEPLOY_ROOT/$STATIC_DIR' ]"; then
echo "❌ 部署目录不存在"
exit 1
fi
FILE_COUNT=$(ssh "$SERVER_USER@$SERVER_IP" "find '$DEPLOY_ROOT/$STATIC_DIR' -type f | wc -l")
echo "✅ 部署完成,共 $FILE_COUNT 个文件"
echo ""
echo "📋 步骤8: 清理旧备份(保留最近3个)..."
ssh "$SERVER_USER@$SERVER_IP" "
set -e
BACKUP_DIR='$DEPLOY_ROOT'
cd \"\$BACKUP_DIR\"
ls -dt ${STATIC_DIR}_backup_* 2>/dev/null | tail -n +4 | xargs rm -rf 2>/dev/null || true
echo \"✅ 清理完成\"
"
echo ""
echo "📋 步骤9: 验证生产环境..."
LOGO_FONT=$(curl -s "https://novalon.cn/logo.svg" | grep -o "font-family: [^;]*" | head -1)
if echo "$LOGO_FONT" | grep -q "aoyagiReisho"; then
echo "✅ 生产环境已更新: $LOGO_FONT"
else
echo "⚠️ 生产环境可能未更新,请手动验证"
fi
echo ""
echo "🎉 部署成功!"
echo ""
echo "📋 访问地址:"
echo " HTTP: http://$SERVER_IP"
echo " HTTPS: https://$SERVER_IP"
echo " 域名: https://novalon.cn"
echo ""
echo "📋 后续步骤:"
echo " 1. 验证网站可访问性"
echo " 2. 检查静态资源加载"
echo " 3. 测试网站主要功能"
+2
View File
@@ -10,10 +10,12 @@ services:
- "443:443"
volumes:
- ./nginx-static-production.conf:/etc/nginx/nginx.conf:ro
- ./conf.d:/etc/nginx/conf.d:ro
- ./ssl:/etc/nginx/ssl:ro
- ./logs:/var/log/nginx
- ../certbot:/var/www/certbot
- ../novalon-static:/var/www/novalon:ro
- ./sites:/var/www/sites:ro
networks:
- novalon-network
+453 -404
View File
@@ -1,475 +1,524 @@
# 部署文档
### 一、Nginx 配置文件(3 个)
## 部署概述
#### 1. [nginx-static-production.conf](computer:///sessions/69e889f08f8c93c2514713f1/workspace/nginx-static-production.conf)
**用途**: 生产环境完整 Nginx 主配置文件(含反向代理),是 `docker-compose-nginx.yml` 挂载的配置。
项目采用 Next.js 静态导出模式,构建生成纯静态 HTML 文件,可部署到任何静态文件服务器或 CDN。
**关键内容**:
- **全局配置**: `worker_processes auto`,使用 epoll,连接数 1024Docker DNS 解析器 `127.0.0.11`
- **安全头**: X-Frame-Options、X-XSS-Protection、X-Content-Type-Options、Referrer-Policy
- **限流**: `limit_req_zone` 全局限速 100r/s
- **Gzip 压缩**: 级别 6,覆盖 text/css/json/javascript/xml 等类型
- **Upstream 定义**: `gitea_app` (gitea:3000)、`jenkins_app` (jenkins:8080)
- **虚拟主机**:
- `novalon.cn / www.novalon.cn` -- 主站静态文件服务,根目录 `/var/www/novalon`SSL/TLS 1.2+1.3HSTS 2年,`_next/static/` 和字体/图片缓存 1 年,`try_files $uri $uri.html $uri/ /404.html`
- `git.f.novalon.cn` -- 反向代理到 Gitea(端口 3000
- `ci.f.novalon.cn` -- 反向代理到 Jenkins(端口 8080),超时 60s
- **SSL**: Let's Encrypt ACME challenge 支持,独立子域名证书路径
## 构建配置
#### 2. [nginx-static.conf](computer:///sessions/69e889f08f8c93c2514713f1/workspace/nginx-static.conf)
**用途**: 简化版 Nginx 配置,仅包含主站静态服务,用于 `Dockerfile` 构建镜像。
### Next.js 配置
**关键内容**:
- 仅配置 `novalon.cn / www.novalon.cn`,无 Gitea/Jenkins 反向代理
- 静态根目录 `/usr/share/nginx/html`
- SSL 配置较简化(`ssl_ciphers HIGH:!aNULL:!MD5`
- 包含与 production 版相同的安全头、Gzip、静态资源缓存策略
- 额外包含 `Permissions-Policy` 头(禁用摄像头/麦克风/地理位置)
```typescript
// next.config.ts
const nextConfig: NextConfig = {
output: 'export', // 静态导出模式
distDir: 'dist', // 输出目录
images: {
unoptimized: true, // 静态导出需要禁用图片优化
},
compress: true,
poweredByHeader: false,
reactStrictMode: true,
};
#### 3. [nginx-internal.conf](computer:///sessions/69e889f08f8c93c2514713f1/workspace/nginx-internal.conf)
**用途**: 内部/开发环境配置,监听端口 3000,无 SSL。
**关键内容**:
- 监听 `localhost:3000`,根目录 `/var/www/novalon`
- 无 SSL、无 HSTS、无 ACME challenge
- 包含基本的 Gzip、安全头、静态资源缓存
- 用于 `Dockerfile.static` 构建的内部容器
---
### 二、Docker 相关文件(5 个)
#### 4. [Dockerfile](computer:///sessions/69e889f08f8c93c2514713f1/workspace/Dockerfile)
**用途**: 多阶段构建 -- Node.js 编译 + Nginx 静态服务。
**关键内容**:
- **构建阶段**: `node:20-alpine``npm ci` 安装依赖,`npm run build` 构建
- **运行阶段**: `nginx:alpine`,将 `dist/` 复制到 `/usr/share/nginx/html`,挂载 `nginx-static.conf`
- 暴露端口 80
#### 5. [Dockerfile.static](computer:///sessions/69e889f08f8c93c2514713f1/workspace/Dockerfile.static)
**用途**: 纯静态文件 Nginx 容器(无构建阶段),用于内部部署。
**关键内容**:
- 基础镜像 `nginx:alpine`
-`html/` 目录复制到 `/var/www/novalon`
- 挂载 `nginx-internal.conf``/etc/nginx/conf.d/default.conf`
- 暴露端口 3000
#### 6. [docker-compose.yml](computer:///sessions/69e889f08f8c93c2514713f1/workspace/docker-compose.yml)
**用途**: 基础 Docker Compose 配置,使用 `Dockerfile` 构建。
**关键内容**:
- 服务名 `novalon-website`,镜像标签 `novalon-website:1.0.0`
- 容器名 `novalon-website`
- 端口映射 `80:80``443:443`
- 挂载 `nginx-static.conf``ssl/` 目录(只读)
#### 7. [docker-compose.server.yml](computer:///sessions/69e889f08f8c93c2514713f1/workspace/docker-compose.server.yml)
**用途**: 服务器端 Docker Compose 配置,使用 `Dockerfile.static` 构建。
**关键内容**:
- 服务名 `novalon-website`,镜像标签 `novalon-website:latest`
- 容器名 `novalon-website`
- 端口映射 `3000:3000`
- 使用外部网络 `novalon-network`bridge 驱动)
#### 8. [docker-compose-nginx.yml](computer:///sessions/69e889f08f8c93c2514713f1/workspace/docker-compose-nginx.yml)
**用途**: 独立 Nginx 反向代理容器配置(生产环境网关)。
**关键内容**:
- 服务名 `nginx`,使用官方 `nginx:alpine` 镜像
- **容器名 `novalon-nginx-secure`**
- 端口映射 `80:80``443:443`
- 挂载:
- `nginx-static-production.conf` -> `/etc/nginx/nginx.conf`(只读)
- `ssl/` -> `/etc/nginx/ssl`(只读)
- `logs/` -> `/var/log/nginx`
- `../certbot` -> `/var/www/certbot`ACME 验证)
- `../novalon-static` -> `/var/www/novalon`(只读,静态文件)
- 使用外部网络 `novalon-network`
---
### 三、部署脚本(2 个)
#### 9. [deploy.sh](computer:///sessions/69e889f08f8c93c2514713f1/workspace/deploy.sh)
**用途**: 完整部署脚本 -- SSH 到远程服务器执行 Docker 构建+部署。
**关键内容**:
- 默认服务器 `139.155.109.62`root 用户),部署路径 `/home/novalon/docker-app/novalon-website`
- 容器名 `novalon-website`Nginx 容器名 `novalon-nginx`
- 流程: 部署前检查 -> SSH 验证 -> SCP 上传文件 -> 服务器端 SSL 配置 -> `docker-compose build --no-cache` -> `docker-compose up -d` -> 健康检查 -> SSL 自动续期 cron 配置
- 支持命令行参数: `-i`(IP)、`-u`(用户)、`-p`(项目名)、`-c`(容器名)、`-v`(版本号)
#### 10. [deploy-dist.sh](computer:///sessions/69e889f08f8c93c2514713f1/workspace/deploy-dist.sh)
**用途**: 轻量部署脚本 -- 仅上传预构建的 `dist/` 目录到服务器。
**关键内容**:
- 同一服务器 `139.155.109.62`,部署到 `/home/novalon/docker-app/novalon-website/dist`
- 流程: 检查 dist 目录 -> SSH 验证 -> 备份旧版本 -> rsync 上传 -> 设置文件权限 -> 验证 -> 清理旧备份(保留最近 3 个)
- 适用于已有 Nginx 容器运行、仅需更新静态文件的场景
---
### 四、引用 "novalon-nginx" / "novalon-website" 的其他文件(29 个)
除上述 10 个核心文件外,以下文件也引用了这两个关键词,按类别归纳:
| 类别 | 文件路径 |
|------|----------|
| **文档** | `README.md`, `DEPLOYMENT.md`, `docs/deployment.md`, `docs/deployment/DEPLOYMENT.md`, `docs/deployment/phase1-deployment-guide.md`, `docs/deployment/rollback-procedure.md`, `docs/development/getting-started.md`, `docs/STRUCTURE_PLAN.md`, `docs/PRODUCTION_RELEASE_REPORT.md`, `docs/PRODUCTION_DEPLOYMENT_LIGHTWEIGHT.md`, `docs/PRODUCTION_DEPLOYMENT.md`, `docs/PERFORMANCE_OPTIMIZATION.md`, `docs/OPTIMIZATION_REPORT.md`, `docs/MONITORING_SETUP.md`, `docs/MONITORING_QUICKSTART.md`, `docs/HMR-ERROR-SOLUTIONS.md`, `docs/CDN_CONFIGURATION.md` |
| **脚本** | `scripts/ssl-wildcard-dns.sh`, `scripts/ssl-individual-http.sh`, `scripts/ssl-individual-http-v2.sh`, `scripts/setup-wildcard-ssl.sh`, `scripts/monitoring/setup-monitoring.sh`, `scripts/deploy-wildcard-domain.sh`, `scripts/deploy-subdomain-ssl.sh` |
---
### 五、架构总结(2026-04-22 更新)
该项目的部署架构已优化为**单层容器**模式:
#### 当前架构
```
┌─────────────────────────────────────────────────────────┐
│ novalon-nginx-secure (唯一容器) - 端口 80/443 │
│ │
│ nginx-static-production.conf (主配置) │
│ └── include /etc/nginx/conf.d/*.conf; │
│ │
│ conf.d/ (模块化配置目录) │
│ ├── git.f.novalon.cn.conf (Gitea 反向代理) │
│ ├── ci.f.novalon.cn.conf (Jenkins 反向代理) │
│ └── *.novalon.cn.conf (产品站点配置) │
│ │
│ sites/ (产品静态文件目录) │
│ ├── product-a/ │
│ └── product-b/ │
│ │
│ 挂载卷: │
│ - ./conf.d → /etc/nginx/conf.d │
│ - ./sites → /var/www/sites │
│ - ../novalon-static → /var/www/novalon │
│ - ./ssl → /etc/nginx/ssl │
└─────────────────────────────────────────────────────────┘
```
### 构建命令
#### 架构优化说明
**已移除的组件**
- `novalon-website` 容器(原端口 3000)-- 功能已合并到 `novalon-nginx-secure` 容器
**优化收益**
- 资源节省:减少一个容器实例
- 架构简化:单一网关,职责明确
- 配置模块化:每个服务独立配置文件,互不影响
#### 新增自动化脚本
| 脚本 | 用途 |
|------|------|
| `scripts/add-product-site.sh` | 一键添加新产品站点(配置文件 + 静态文件) |
| `scripts/ssl-product-site.sh` | 为产品站点申请 SSL 证书 |
#### 添加新产品站点流程
```bash
# 开发模式(不导出)
npm run dev
# 1. 添加 DNS A 记录: product-a.novalon.cn → 服务器IP
# 生产构建(静态导出)
npm run build
# 2. 运行添加脚本
./scripts/add-product-site.sh product-a ./product-a-files
# 输出目录
dist/
# 3. 申请 SSL 证书
./scripts/ssl-product-site.sh product-a.novalon.cn
# 4. 重载 Nginx
docker exec novalon-nginx-secure nginx -s reload
```
## 环境变量
---
### 必需配置
### 历史架构(已废弃)
```env
# .env.production
RESEND_API_KEY=re_xxxxx
COMPANY_EMAIL=contact@novalon.cn
~~该项目的部署架构为**双层容器**模式~~
~~1. **内层**: `novalon-website` 容器(端口 3000-- 由 `Dockerfile.static` 构建,仅提供静态文件服务~~
~~2. **外层**: `novalon-nginx-secure` 容器(端口 80/443-- 由 `docker-compose-nginx.yml` 启动,使用 `nginx-static-production.conf` 作为完整网关,负责 SSL 终止、静态文件服务、以及到 Gitea (`git.f.novalon.cn`) 和 Jenkins (`ci.f.novalon.cn`) 的反向代理~~
~~两个容器通过外部 Docker 网络 `novalon-network`bridge 驱动)互联。域名 `novalon.cn` / `www.novalon.cn` 指向主站静态资源,SSL 证书通过 Let's Encrypt 获取并配置了自动续期 cron 任务。~~
现在我对你的生产环境架构有了清晰的了解。让我再确认几个关键细节:
现在我对你的架构完全清楚了。在给出方案之前,我需要确认几个关键信息:
好的,需求明确了。让我整理完整的部署方案。
## 任务理解
- **目标**:在现有 `novalon-nginx` 网关上,为 6-15 个独立 HTML 产品官网配置子域名路由 + SSL
- **约束**:每个产品用独立子域名(如 `product-a.novalon.cn`),SSL 需要新申请
- **当前架构**`novalon-nginx-secure` 容器(nginx:alpine)作为网关,静态文件通过 volume 挂载
## 方案设计
### 推荐方案:Nginx 模板化 + 目录约定 + Let's Encrypt 自动化
核心思路是利用你现有的 `novalon-nginx-secure` 网关容器,通过**目录约定 + 配置模板**来管理多个产品站点,避免为每个产品创建独立容器。
#### 1. 目录结构约定
在服务器上按以下结构组织产品页面:
```
/home/novalon/docker-app/novalon-nginx/
├── nginx-static-production.conf # 主配置(include 子配置)
├── conf.d/ # 产品站点配置目录(新增)
│ ├── product-a.novalon.cn.conf
│ ├── product-b.novalon.cn.conf
│ └── ...
├── ssl/ # SSL 证书目录
│ ├── novalon.cn/
│ ├── git.f.novalon.cn/
│ ├── ci.f.novalon.cn/
│ ├── product-a.novalon.cn/ # 每个产品独立子目录
│ └── product-b.novalon.cn/
├── sites/ # 产品静态文件目录(新增)
│ ├── product-a/
│ │ └── index.html
│ ├── product-b/
│ │ └── index.html
│ └── ...
└── docker-compose-nginx.yml
```
### 可选配置
#### 2. Nginx 主配置修改
```env
NODE_ENV=production
NEXT_PUBLIC_SITE_URL=https://www.novalon.cn
```
### 环境变量说明
| 变量名 | 必需 | 描述 |
|--------|------|------|
| `RESEND_API_KEY` | 是 | Resend 邮件服务 API 密钥 |
| `COMPANY_EMAIL` | 是 | 公司接收邮件的邮箱地址 |
| `NODE_ENV` | 否 | 环境标识 |
| `NEXT_PUBLIC_SITE_URL` | 否 | 网站公开 URL |
## 部署平台
### 1. Vercel 部署(推荐)
**优势:**
- 零配置部署
- 自动 HTTPS
- 全球 CDN
- 预览部署
- 边缘函数支持
**部署步骤:**
1. 连接 Git 仓库
2. 配置环境变量
3. 部署设置:
- Build Command: `npm run build`
- Output Directory: `dist`
- Install Command: `npm install`
**vercel.json 配置:**
```json
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"framework": "nextjs",
"regions": ["hkg1"]
}
```
### 2. 静态文件服务器部署
**适用场景:**
- Nginx
- Apache
- IIS
- 云存储(阿里云 OSS、腾讯云 COS)
**Nginx 配置示例:**
`nginx-static-production.conf``http {}` 块末尾添加一行 include
```nginx
# 在 http {} 块末尾,现有 server 块之后添加:
include /etc/nginx/conf.d/*.conf;
```
#### 3. 产品站点配置模板
每个产品一个独立配置文件,例如 `conf.d/product-a.novalon.cn.conf`
```nginx
# 产品A官网 - product-a.novalon.cn
server {
listen 80;
server_name www.novalon.cn novalon.cn;
root /var/www/novalon-website/dist;
index index.html;
server_name product-a.novalon.cn;
# 强制 HTTPS
return 301 https://$server_name$request_uri;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name www.novalon.cn novalon.cn;
root /var/www/novalon-website/dist;
index index.html;
server_name product-a.novalon.cn;
# SSL 证书
ssl_certificate /etc/nginx/ssl/novalon.cn.pem;
ssl_certificate_key /etc/nginx/ssl/novalon.cn.key;
ssl_certificate /etc/nginx/ssl/product-a.novalon.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/product-a.novalon.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# 安全头部
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;" always;
# Gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
root /var/www/sites/product-a;
index index.html;
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
try_files $uri =404;
}
# HTML 不缓存
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# SPA 路由支持
location / {
try_files $uri $uri.html $uri/ =404;
limit_req zone=general burst=20 nodelay;
try_files $uri $uri/ =404;
}
# 404 页面
error_page 404 /404.html;
access_log /var/log/nginx/product-a-access.log;
error_log /var/log/nginx/product-a-error.log;
}
```
### 3. Docker 部署
#### 4. Docker Compose 修改
**Dockerfile:**
```dockerfile
# 构建阶段
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 运行阶段
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制 Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
**构建和运行:**
```bash
# 构建镜像
docker build -t novalon-website .
# 运行容器
docker run -d -p 80:80 --name novalon novalon-website
```
### 4. 云存储部署
**阿里云 OSS**
1. 创建 OSS Bucket
2. 配置静态网站托管
3. 上传 `dist/` 目录内容
4. 配置自定义域名
5. 配置 HTTPS 证书
**腾讯云 COS**
1. 创建 COS Bucket
2. 开启静态网站功能
3. 上传构建产物
4. 配置 CDN 加速
## CI/CD 流水线
### Woodpecker CI 配置
`docker-compose-nginx.yml` 中添加新的 volume 挂载:
```yaml
# .woodpecker.yml
pipeline:
install:
image: node:18-alpine
commands:
- npm ci
when:
event:
- push
- pull_request
services:
nginx:
image: nginx:alpine
container_name: novalon-nginx-secure
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx-static-production.conf:/etc/nginx/nginx.conf:ro
- ./conf.d:/etc/nginx/conf.d:ro # 新增:产品站点配置
- ./ssl:/etc/nginx/ssl:ro
- ./logs:/var/log/nginx
- ../certbot:/var/www/certbot
- ../novalon-static:/var/www/novalon:ro
- ./sites:/var/www/sites:ro # 新增:产品静态文件
networks:
- novalon-network
lint:
image: node:18-alpine
commands:
- npm run lint
when:
event:
- push
- pull_request
build:
image: node:18-alpine
environment:
NODE_ENV: production
commands:
- npm run build
when:
event:
- push
branch:
- main
e2e-tests:
image: node:18-alpine
environment:
NODE_ENV: test
CI: true
commands:
- cd e2e
- npm ci
- npx playwright install --with-deps chromium
- npm run test:smoke
when:
event:
- push
- pull_request
deploy:
image: node:18-alpine
commands:
- npm install -g vercel
- vercel --prod --token=$VERCEL_TOKEN
secrets:
- vercel_token
when:
event:
- push
branch:
- main
networks:
novalon-network:
driver: bridge
external: true
```
## 部署检查清单
#### 5. SSL 证书申请脚本
### 部署前检查
- [ ] 环境变量已配置
- [ ] 构建成功无错误
- [ ] E2E 测试通过
- [ ] ESLint 检查通过
- [ ] 图片资源已优化
- [ ] 死链检查通过
### 部署后验证
- [ ] 首页正常加载
- [ ] 所有页面可访问
- [ ] 表单提交正常
- [ ] 移动端适配正常
- [ ] HTTPS 证书有效
- [ ] 性能指标达标
- [ ] SEO 元数据正确
### 性能指标
| 指标 | 目标值 |
|------|--------|
| LCP | < 2.5s |
| FID | < 100ms |
| CLS | < 0.1 |
| TTFB | < 600ms |
| 首屏加载 | < 3s |
## 回滚策略
### Vercel 回滚
为每个产品子域名申请 Let's Encrypt 证书:
```bash
# 列出部署历史
vercel ls
#!/bin/bash
# scripts/ssl-product-site.sh
# 用法: ./ssl-product-site.sh product-a.novalon.cn
# 回滚到指定版本
vercel rollback [deployment-url]
DOMAIN=$1
if [ -z "$DOMAIN" ]; then
echo "用法: $0 <subdomain>.novalon.cn"
exit 1
fi
CERTBOT_DIR="/home/novalon/docker-app/certbot"
SSL_DIR="/home/novalon/docker-app/novalon-nginx/ssl"
# 创建证书目录
mkdir -p "${SSL_DIR}/${DOMAIN}"
# 申请证书(使用 standalone 模式,需临时停止 nginx 的 80 端口)
docker run --rm \
-p 80:80 \
-v "${CERTBOT_DIR}:/var/www/certbot" \
-v "/etc/letsencrypt:/etc/letsencrypt" \
certbot/certbot certonly \
--webroot \
--webroot-path /var/www/certbot \
-d "${DOMAIN}" \
--email admin@novalon.cn \
--agree-tos \
--no-eff-email
# 复制证书到 nginx ssl 目录
cp "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" "${SSL_DIR}/${DOMAIN}/"
cp "/etc/letsencrypt/live/${DOMAIN}/privkey.pem" "${SSL_DIR}/${DOMAIN}/"
echo "${DOMAIN} 证书申请完成"
```
### 静态服务器回滚
#### 6. 一键添加新产品脚本
```bash
# 保留历史版本
/var/www/novalon-website/
├── current -> releases/20260307-1
├── releases/
│ ├── 20260307-1/
│ ├── 20260306-1/
│ └── 20260305-1/
└── shared/
#!/bin/bash
# scripts/add-product-site.sh
# 用法: ./add-product-site.sh product-a /path/to/product-a-files
# 回滚操作
ln -sfn releases/20260306-1 current
PRODUCT_NAME=$1
FILES_SOURCE=$2
if [ -z "$PRODUCT_NAME" ] || [ -z "$FILES_SOURCE" ]; then
echo "用法: $0 <product-name> <html-files-directory>"
echo "示例: $0 product-a ./product-a-website"
exit 1
fi
DOMAIN="${PRODUCT_NAME}.novalon.cn"
NGINX_DIR="/home/novalon/docker-app/novalon-nginx"
CONF_DIR="${NGINX_DIR}/conf.d"
SITES_DIR="${NGINX_DIR}/sites"
SSL_DIR="${NGINX_DIR}/ssl"
# 1. 创建目录
mkdir -p "${SITES_DIR}/${PRODUCT_NAME}"
mkdir -p "${SSL_DIR}/${DOMAIN}"
mkdir -p "${CONF_DIR}"
# 2. 复制静态文件
cp -r "${FILES_SOURCE}/"* "${SITES_DIR}/${PRODUCT_NAME}/"
# 3. 生成 Nginx 配置
cat > "${CONF_DIR}/${DOMAIN}.conf" << 'CONF_TEMPLATE'
# 产品官网 - {{DOMAIN}}
server {
listen 80;
server_name {{DOMAIN}};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name {{DOMAIN}};
ssl_certificate /etc/nginx/ssl/{{DOMAIN}}/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/{{DOMAIN}}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
root /var/www/sites/{{PRODUCT_NAME}};
index index.html;
location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
try_files $uri =404;
}
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
limit_req zone=general burst=20 nodelay;
try_files $uri $uri/ =404;
}
error_page 404 /404.html;
access_log /var/log/nginx/{{PRODUCT_NAME}}-access.log;
error_log /var/log/nginx/{{PRODUCT_NAME}}-error.log;
}
CONF_TEMPLATE
# 替换模板变量
sed -i "s/{{DOMAIN}}/${DOMAIN}/g" "${CONF_DIR}/${DOMAIN}.conf"
sed -i "s/{{PRODUCT_NAME}}/${PRODUCT_NAME}/g" "${CONF_DIR}/${DOMAIN}.conf"
echo "✅ 产品站点 ${PRODUCT_NAME} 配置完成"
echo ""
echo "后续步骤:"
echo " 1. 添加 DNS A 记录: ${DOMAIN} -> 服务器IP"
echo " 2. 申请 SSL 证书: ./ssl-product-site.sh ${DOMAIN}"
echo " 3. 重载 Nginx: docker exec novalon-nginx-secure nginx -s reload"
```
## 监控与告警
#### 7. 完整部署流程
### 推荐工具
添加一个新产品站点的完整步骤:
| 工具 | 用途 |
```
步骤 1: DNS 解析
→ 在域名服务商添加 A 记录: product-a.novalon.cn → 139.155.109.62
步骤 2: 上传文件 + 生成配置
→ ./add-product-site.sh product-a ./product-a-html-files
步骤 3: 申请 SSL 证书
→ ./ssl-product-site.sh product-a.novalon.cn
步骤 4: 验证配置 + 重载 Nginx
→ docker exec novalon-nginx-secure nginx -t
→ docker exec novalon-nginx-secure nginx -s reload
步骤 5: 验证访问
→ curl -I https://product-a.novalon.cn
```
### 方案优势
| 特性 | 说明 |
|------|------|
| Vercel Analytics | 性能监控 |
| Sentry | 错误监控 |
| Uptime Robot | 可用性监控 |
| Google Search Console | SEO 监控 |
| **零额外容器** | 所有产品站点复用现有 `novalon-nginx-secure` 网关 |
| **模板化** | 一键脚本生成配置,避免手动复制粘贴出错 |
| **隔离性** | 每个产品独立配置文件、独立日志、独立 SSL 证书目录 |
| **可扩展** | 新增产品只需 3 条命令 |
| **统一管理** | 所有配置集中在 `conf.d/` 目录,便于审查和维护 |
| **资源高效** | 不需要为每个 HTML 页面启动独立容器 |
### 告警配置
### 注意事项
```yaml
# Uptime Robot 配置示例
monitors:
- name: Novalon Website
url: https://www.novalon.cn
type: https
interval: 300
alert_contacts:
- email: admin@novalon.cn
```
## 安全配置
### 安全头部
```http
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;
Permissions-Policy: camera=(), microphone=(), geolocation=()
```
### HTTPS 配置
- 使用 TLS 1.2 或更高版本
- 配置 HSTS
- 启用 OCSP Stapling
- 使用强加密套件
## 性能优化
### 构建优化
1. **代码分割**
- 动态导入非首屏组件
- 路由级别分割
2. **资源优化**
- 图片压缩和格式转换
- CSS 压缩
- JavaScript 压缩
3. **缓存策略**
- 静态资源长缓存
- HTML 不缓存
- API 响应适当缓存
### CDN 配置
```
# CDN 缓存规则
*.js, *.css -> 缓存 1 年
*.jpg, *.png -> 缓存 1 年
*.woff, *.woff2 -> 缓存 1 年
*.html -> 不缓存
```
## 故障排查
### 常见问题
**1. 页面 404 错误**
- 检查静态文件是否正确上传
- 检查 Nginx 配置的 root 路径
- 检查 SPA 路由配置
**2. 样式加载失败**
- 检查 CSS 文件路径
- 检查 Content-Security-Policy 配置
- 清除浏览器缓存
**3. 表单提交失败**
- 检查 API 路由是否正常
- 检查环境变量配置
- 检查 CORS 配置
**4. 性能问题**
- 检查图片是否优化
- 检查 CDN 是否生效
- 检查服务器响应时间
### 日志查看
```bash
# Nginx 访问日志
tail -f /var/log/nginx/access.log
# Nginx 错误日志
tail -f /var/log/nginx/error.log
# Vercel 日志
vercel logs [deployment-url]
```
## 维护计划
### 定期任务
| 任务 | 频率 |
|------|------|
| 依赖更新 | 每月 |
| 安全扫描 | 每周 |
| 性能测试 | 每周 |
| 备份验证 | 每月 |
| SSL 证书更新 | 到期前 30 天 |
### 更新流程
1. 创建更新分支
2. 执行依赖更新
3. 运行测试套件
4. 部署到预览环境
5. 验证功能正常
6. 合并到主分支
7. 自动部署到生产环境
1. **DNS 解析必须先完成**:SSL 证书申请需要域名已指向服务器 IP
2. **证书续期**:建议在现有 cron 任务中加入产品子域名的自动续期
3. **如果未来产品数量超过 15 个**,建议升级为泛域名证书(`*.novalon.cn`),可大幅简化证书管理
4. **文件更新**:更新某个产品的 HTML 文件后,只需替换 `sites/product-x/` 下的文件,无需重启 Nginx(静态文件通过 volume 挂载)
+1 -98
View File
@@ -136,102 +136,5 @@ http {
tcp_nodelay on;
}
# Git 服务
server {
listen 80;
server_name git.f.novalon.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name git.f.novalon.cn;
ssl_certificate /etc/nginx/ssl/git.f.novalon.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/git.f.novalon.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://gitea_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Powered-By;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
}
# CI 服务
server {
listen 80;
server_name ci.f.novalon.cn;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name ci.f.novalon.cn;
ssl_certificate /etc/nginx/ssl/ci.f.novalon.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/ci.f.novalon.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://jenkins_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_hide_header X-Powered-By;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
client_max_body_size 100m;
}
access_log /var/log/nginx/jenkins-access.log;
error_log /var/log/nginx/jenkins-error.log;
}
include /etc/nginx/conf.d/*.conf;
}
+42 -18
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+37 -18
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
PRODUCT_NAME=$1
FILES_SOURCE=$2
if [ -z "$PRODUCT_NAME" ] || [ -z "$FILES_SOURCE" ]; then
echo "用法: $0 <product-name> <html-files-directory>"
echo "示例: $0 product-a ./product-a-website"
exit 1
fi
DOMAIN="${PRODUCT_NAME}.novalon.cn"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NGINX_DIR="$(dirname "$SCRIPT_DIR")"
CONF_DIR="${NGINX_DIR}/conf.d"
SITES_DIR="${NGINX_DIR}/sites"
SSL_DIR="${NGINX_DIR}/ssl"
if [ ! -d "$FILES_SOURCE" ]; then
echo "错误: 源文件目录不存在: $FILES_SOURCE"
exit 1
fi
mkdir -p "${SITES_DIR}/${PRODUCT_NAME}"
mkdir -p "${SSL_DIR}/${DOMAIN}"
mkdir -p "${CONF_DIR}"
cp -r "${FILES_SOURCE}/"* "${SITES_DIR}/${PRODUCT_NAME}/" 2>/dev/null || true
cp -r "${FILES_SOURCE}/." "${SITES_DIR}/${PRODUCT_NAME}/" 2>/dev/null || true
cat > "${CONF_DIR}/${DOMAIN}.conf" << 'CONF_TEMPLATE'
server {
listen 80;
server_name {{DOMAIN}};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name {{DOMAIN}};
ssl_certificate /etc/nginx/ssl/{{DOMAIN}}/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/{{DOMAIN}}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
root /var/www/sites/{{PRODUCT_NAME}};
index index.html;
location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
try_files $uri =404;
}
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
limit_req zone=general burst=20 nodelay;
try_files $uri $uri/ =404;
}
error_page 404 /404.html;
access_log /var/log/nginx/{{PRODUCT_NAME}}-access.log;
error_log /var/log/nginx/{{PRODUCT_NAME}}-error.log;
}
CONF_TEMPLATE
sed -i.bak "s/{{DOMAIN}}/${DOMAIN}/g" "${CONF_DIR}/${DOMAIN}.conf"
sed -i.bak "s/{{PRODUCT_NAME}}/${PRODUCT_NAME}/g" "${CONF_DIR}/${DOMAIN}.conf"
rm -f "${CONF_DIR}/${DOMAIN}.conf.bak"
echo "✅ 产品站点 ${PRODUCT_NAME} 配置完成"
echo ""
echo "后续步骤:"
echo " 1. 添加 DNS A 记录: ${DOMAIN} -> 服务器IP"
echo " 2. 申请 SSL 证书: ./scripts/ssl-product-site.sh ${DOMAIN}"
echo " 3. 重载 Nginx: docker exec novalon-nginx-secure nginx -s reload"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""对比两个字体文件"""
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables import _h_m_t_x, _g_a_s_p
original_hmtx = _h_m_t_x.table__h_m_t_x.decompile
def patched_hmtx(self, data, ttFont):
try: return original_hmtx(self, data, ttFont)
except: self.metrics = {}
_h_m_t_x.table__h_m_t_x.decompile = patched_hmtx
original_gasp = _g_a_s_p.table__g_a_s_p.decompile
def patched_gasp(self, data, ttFont):
try: return original_gasp(self, data, ttFont)
except: self.gaspRanges = {}
_g_a_s_p.table__g_a_s_p.decompile = patched_gasp
print('=== public/fonts/AoyagiReisho.ttf ===')
f1 = TTFont('public/fonts/AoyagiReisho.ttf')
cmap1 = f1.getBestCmap()
print('U+9060 遠:', 0x9060 in cmap1)
print('U+8fdc 远:', 0x8fdc in cmap1)
print('字形数:', len(f1.getGlyphOrder()))
print('GSUB:', 'GSUB' in f1)
f1.close()
print()
print('=== src/app/fonts/AoyagiReisho.ttf ===')
f2 = TTFont('src/app/fonts/AoyagiReisho.ttf')
cmap2 = f2.getBestCmap()
print('U+9060 遠:', 0x9060 in cmap2)
print('U+8fdc 远:', 0x8fdc in cmap2)
print('字形数:', len(f2.getGlyphOrder()))
print('GSUB:', 'GSUB' in f2)
f2.close()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""将青柳隷書字体中的文字转换为 SVG 路径"""
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables import _h_m_t_x, _g_a_s_p
import os
# 修补表解析
original_hmtx = _h_m_t_x.table__h_m_t_x.decompile
def patched_hmtx(self, data, ttFont):
try: return original_hmtx(self, data, ttFont)
except: self.metrics = {}
_h_m_t_x.table__h_m_t_x.decompile = patched_hmtx
original_gasp = _g_a_s_p.table__g_a_s_p.decompile
def patched_gasp(self, data, ttFont):
try: return original_gasp(self, data, ttFont)
except: self.gaspRanges = {}
_g_a_s_p.table__g_a_s_p.decompile = patched_gasp
def get_glyph_path(font, char):
"""获取字符的 SVG 路径"""
cmap = font.getBestCmap()
codepoint = ord(char)
if codepoint not in cmap:
print(f"警告: 字符 '{char}' (U+{codepoint:04X}) 不在字体中")
return None, None
glyph_name = cmap[codepoint]
# 获取 glyf 表
glyf_table = font['glyf']
glyph = glyf_table[glyph_name]
# 获取度量
hmtx = font['hmtx']
advance_width, lsb = hmtx[glyph_name]
# 获取边界框
if hasattr(glyph, 'xMin') and glyph.xMin is not None:
bbox = (glyph.xMin, glyph.yMin, glyph.xMax, glyph.yMax)
else:
bbox = (0, 0, advance_width, 1000)
# 获取字形轮廓
try:
coords, endPts, flags = glyph.getCoordinates(glyf_table)
except:
print(f" 无法获取轮廓: {glyph_name}")
return None, None
# 构建 SVG 路径
path_parts = []
start_idx = 0
for end_pt in endPts:
contour_coords = coords[start_idx:end_pt + 1]
contour_flags = flags[start_idx:end_pt + 1]
if len(contour_coords) > 0:
path_parts.append(f"M {contour_coords[0][0]:.2f} {-contour_coords[0][1]:.2f}")
for i in range(1, len(contour_coords)):
x, y = contour_coords[i]
path_parts.append(f"L {x:.2f} {-y:.2f}")
path_parts.append("Z")
start_idx = end_pt + 1
return " ".join(path_parts), {'advance': advance_width, 'lsb': lsb, 'bbox': bbox}
# 加载字体
font_path = 'public/fonts/AoyagiReisho.ttf'
font = TTFont(font_path)
print("=" * 60)
print("青柳隷書 字形路径提取")
print("=" * 60)
chars = ['', '', '', '']
glyphs_data = []
for char in chars:
print(f"\n字符: {char} (U+{ord(char):04X})")
path, metrics = get_glyph_path(font, char)
if path and metrics:
print(f" Advance: {metrics['advance']}, LSB: {metrics['lsb']}")
print(f" BBox: {metrics['bbox']}")
print(f" Path length: {len(path)} chars")
glyphs_data.append({
'char': char,
'path': path,
'metrics': metrics
})
font.close()
# 生成 SVG
print("\n" + "=" * 60)
print("生成 SVG 文件...")
print("=" * 60)
# 计算总宽度
total_width = sum(g['metrics']['advance'] for g in glyphs_data)
scale = 48 / 1000 # 缩放因子
svg_paths = []
x_offset = 0
for g in glyphs_data:
m = g['metrics']
# 计算字符居中偏移
char_width = m['advance'] * scale
path = g['path']
# 缩放路径
scaled_path = path
for coord in [('M', 'L')]:
pass # 路径已经是正确的格式
svg_paths.append(f''' <!-- {g['char']} -->
<g transform="translate({x_offset:.2f}, 0) scale({scale})">
<path d="{path}" fill="currentColor"/>
</g>''')
x_offset += char_width
print(f"\n总宽度: {total_width * scale:.2f}px")
print("\nSVG 路径组:")
print("\n".join(svg_paths[:2]))
print("...")
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成包含繁体''的字体子集"""
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables import _h_m_t_x, _g_a_s_p
from fontTools.subset import Subsetter, Options
# 修补表解析以跳过损坏的数据
original_hmtx = _h_m_t_x.table__h_m_t_x.decompile
def patched_hmtx(self, data, ttFont):
try:
return original_hmtx(self, data, ttFont)
except:
self.metrics = {}
_h_m_t_x.table__h_m_t_x.decompile = patched_hmtx
original_gasp = _g_a_s_p.table__g_a_s_p.decompile
def patched_gasp(self, data, ttFont):
try:
return original_gasp(self, data, ttFont)
except:
self.gaspRanges = {}
_g_a_s_p.table__g_a_s_p.decompile = patched_gasp
# 加载字体
font = TTFont('src/app/fonts/AoyagiReisho.ttf')
# 删除损坏的表
for t in ['vmtx', 'gasp', 'VORG', 'mort', 'morx']:
if t in font:
del font[t]
print(f'Deleted table: {t}')
# 创建子集器
subsetter = Subsetter()
options = Options()
options.drop_tables = ['gasp', 'vmtx', 'VORG', 'mort', 'morx', 'GSUB', 'GPOS', 'GDEF']
subsetter.options = options
# 目标字符: 睿(0x777f), 新(0x65b0), 致(0x81f4), 遠(0x9060), 空格(0x20)
unicodes = [0x20, 0x777f, 0x65b0, 0x81f4, 0x9060]
print(f'Target Unicode: {[hex(u) for u in unicodes]}')
subsetter.populate(unicodes=unicodes)
# 执行子集化
try:
subsetter.subset(font)
except Exception as e:
print(f'Warning during subsetting: {e}')
# 保存
output_path = 'src/app/fonts/AoyagiReisho-subset.ttf'
font.save(output_path)
font.close()
print(f'Saved to: {output_path}')
# 验证
verify_font = TTFont(output_path)
cmap = verify_font.getBestCmap()
chars = [chr(k) for k in sorted(cmap.keys())]
codes = [hex(k) for k in sorted(cmap.keys())]
print(f'Subset characters: {chars}')
print(f'Unicode codes: {codes}')
print(f'Contains U+9060 (遠): {0x9060 in cmap}')
verify_font.close()
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""将青柳隷書字体中的文字转换为 SVG 路径并生成 logo"""
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables import _h_m_t_x, _g_a_s_p
import os
# 修补表解析
original_hmtx = _h_m_t_x.table__h_m_t_x.decompile
def patched_hmtx(self, data, ttFont):
try: return original_hmtx(self, data, ttFont)
except: self.metrics = {}
_h_m_t_x.table__h_m_t_x.decompile = patched_hmtx
original_gasp = _g_a_s_p.table__g_a_s_p.decompile
def patched_gasp(self, data, ttFont):
try: return original_gasp(self, data, ttFont)
except: self.gaspRanges = {}
_g_a_s_p.table__g_a_s_p.decompile = patched_gasp
def get_glyph_path(font, char):
"""获取字符的 SVG 路径"""
cmap = font.getBestCmap()
codepoint = ord(char)
if codepoint not in cmap:
return None, None
glyph_name = cmap[codepoint]
glyf_table = font['glyf']
glyph = glyf_table[glyph_name]
hmtx = font['hmtx']
advance_width, lsb = hmtx[glyph_name]
try:
coords, endPts, flags = glyph.getCoordinates(glyf_table)
except:
return None, None
path_parts = []
start_idx = 0
for end_pt in endPts:
contour_coords = coords[start_idx:end_pt + 1]
if len(contour_coords) > 0:
path_parts.append(f"M {contour_coords[0][0]:.2f} {-contour_coords[0][1]:.2f}")
for i in range(1, len(contour_coords)):
x, y = contour_coords[i]
path_parts.append(f"L {x:.2f} {-y:.2f}")
path_parts.append("Z")
start_idx = end_pt + 1
return " ".join(path_parts), {'advance': advance_width, 'lsb': lsb}
# 加载字体
font = TTFont('public/fonts/AoyagiReisho.ttf')
chars = ['', '', '', '']
glyphs_data = []
for char in chars:
path, metrics = get_glyph_path(font, char)
if path and metrics:
glyphs_data.append({'char': char, 'path': path, 'metrics': metrics})
font.close()
# 生成主标题 SVG 路径
scale = 48 / 1000
total_width = sum(g['metrics']['advance'] for g in glyphs_data) * scale
svg_title_paths = []
x_offset = 0
for g in glyphs_data:
svg_title_paths.append(f''' <g transform="translate({x_offset:.2f}, 0) scale({scale})">
<path d="{g['path']}" fill="currentColor"/>
</g>''')
x_offset += g['metrics']['advance'] * scale
# 生成印章内文字 (较小尺寸)
scale_seal = 26 / 1000
# 睿新
svg_seal_line1 = []
x_offset = 0
for char in ['', '']:
g = next((x for x in glyphs_data if x['char'] == char), None)
if g:
svg_seal_line1.append(f''' <g transform="translate({x_offset:.2f}, 0) scale({scale_seal})">
<path d="{g['path']}" fill="white"/>
</g>''')
x_offset += g['metrics']['advance'] * scale_seal
# 致遠
svg_seal_line2 = []
x_offset = 0
for char in ['', '']:
g = next((x for x in glyphs_data if x['char'] == char), None)
if g:
svg_seal_line2.append(f''' <g transform="translate({x_offset:.2f}, 0) scale({scale_seal})">
<path d="{g['path']}" fill="white"/>
</g>''')
x_offset += g['metrics']['advance'] * scale_seal
# 计算印章文字居中偏移
line1_width = sum(g['metrics']['advance'] for g in glyphs_data if g['char'] in ['', '']) * scale_seal
line2_width = sum(g['metrics']['advance'] for g in glyphs_data if g['char'] in ['', '']) * scale_seal
seal_center = 43 # 印章中心 x 坐标
line1_x = seal_center - line1_width / 2
line2_x = seal_center - line2_width / 2
# 生成完整 SVG
svg_content = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 120" width="480" height="120">
<defs>
<!-- 印章纹理滤镜 -->
<filter id="sealTexture" x="0%" y="0%" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.1" numOctaves="3" result="noise"/>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2" xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs>
<!-- 红色印章 - 手绘不规则风格 -->
<g transform="translate(12, 12)">
<!-- 印章外框 - 不规则手绘路径 -->
<path d="M8,2
C25,-2 45,-2 72,3
C82,5 85,12 84,25
C83,40 85,55 84,70
C83,82 78,88 65,89
C45,91 25,90 10,88
C2,86 -2,78 1,65
C3,50 2,35 1,20
C0,10 3,4 8,2 Z"
fill="#C41E3A"/>
<!-- 印章内框 - 手绘风格 -->
<path d="M14,10
C28,8 55,8 72,12
C78,14 79,20 78,30
C77,45 78,60 77,72
C76,80 72,84 62,85
C45,86 28,85 16,83
C10,82 8,76 9,65
C10,50 9,35 8,22
C7,15 10,11 14,10 Z"
fill="none" stroke="#fff" stroke-width="1.5" opacity="0.5"/>
<!-- 睿新 - 书法字体路径 -->
<g transform="translate({line1_x:.2f}, 38)">
{chr(10).join(svg_seal_line1)}
</g>
<!-- 致遠 - 书法字体路径 -->
<g transform="translate({line2_x:.2f}, 70)">
{chr(10).join(svg_seal_line2)}
</g>
</g>
<!-- 公司名称 -->
<g transform="translate(110, 60)">
<!-- 睿新致遠 - 书法字体路径 -->
<g transform="translate(0, 0)">
{chr(10).join(svg_title_paths)}
</g>
<!-- NOVALON - 英文字体 -->
<text x="24" y="42" font-family="Arial, sans-serif" font-size="14.5" font-weight="500" fill="currentColor" letter-spacing="10.5">NOVALON</text>
</g>
</svg>'''
# 写入文件
with open('public/logo.svg', 'w', encoding='utf-8') as f:
f.write(svg_content)
print("✅ 已生成 public/logo.svg")
# 生成白色版本 (logo-white.svg)
svg_white = svg_content.replace('fill="#C41E3A"', 'fill="currentColor"')
with open('public/logo-white.svg', 'w', encoding='utf-8') as f:
f.write(svg_white)
print("✅ 已生成 public/logo-white.svg")
print(f"\n标题总宽度: {total_width:.2f}px")
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
DOMAIN=$1
if [ -z "$DOMAIN" ]; then
echo "用法: $0 <subdomain>.novalon.cn"
echo "示例: $0 product-a.novalon.cn"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NGINX_DIR="$(dirname "$SCRIPT_DIR")"
CERTBOT_DIR="/home/novalon/docker-app/certbot"
SSL_DIR="${NGINX_DIR}/ssl"
mkdir -p "${SSL_DIR}/${DOMAIN}"
echo "正在为 ${DOMAIN} 申请 SSL 证书..."
echo ""
docker run --rm \
-v "${CERTBOT_DIR}:/var/www/certbot" \
-v "/etc/letsencrypt:/etc/letsencrypt" \
certbot/certbot certonly \
--webroot \
--webroot-path /var/www/certbot \
-d "${DOMAIN}" \
--email admin@novalon.cn \
--agree-tos \
--no-eff-email
if [ $? -eq 0 ]; then
echo ""
echo "证书申请成功,正在复制到 Nginx SSL 目录..."
cp "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" "${SSL_DIR}/${DOMAIN}/"
cp "/etc/letsencrypt/live/${DOMAIN}/privkey.pem" "${SSL_DIR}/${DOMAIN}/"
echo ""
echo "${DOMAIN} 证书申请完成"
echo ""
echo "后续步骤:"
echo " 1. 验证配置: docker exec novalon-nginx-secure nginx -t"
echo " 2. 重载 Nginx: docker exec novalon-nginx-secure nginx -s reload"
echo " 3. 验证访问: curl -I https://${DOMAIN}"
else
echo ""
echo "❌ 证书申请失败"
echo ""
echo "请检查:"
echo " 1. DNS 解析是否正确: ${DOMAIN} -> 服务器IP"
echo " 2. Nginx 配置是否正确加载"
echo " 3. certbot 目录权限是否正确"
exit 1
fi
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""验证字体子集与原始字体的字形一致性"""
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables import _h_m_t_x, _g_a_s_p
import os
# 修补表解析
original_hmtx = _h_m_t_x.table__h_m_t_x.decompile
def patched_hmtx(self, data, ttFont):
try: return original_hmtx(self, data, ttFont)
except: self.metrics = {}
_h_m_t_x.table__h_m_t_x.decompile = patched_hmtx
original_gasp = _g_a_s_p.table__g_a_s_p.decompile
def patched_gasp(self, data, ttFont):
try: return original_gasp(self, data, ttFont)
except: self.gaspRanges = {}
_g_a_s_p.table__g_a_s_p.decompile = patched_gasp
base = 'src/app/fonts'
# 加载字体
original = TTFont(f'{base}/AoyagiReisho.ttf')
subset = TTFont(f'{base}/AoyagiReisho-subset.ttf')
print("=" * 50)
print("字体对比验证")
print("=" * 50)
# 文件大小
orig_size = os.path.getsize(f'{base}/AoyagiReisho.ttf')
sub_size = os.path.getsize(f'{base}/AoyagiReisho-subset.ttf')
print(f"\n原始字体大小: {orig_size / 1024:.1f} KB ({orig_size} bytes)")
print(f"子集字体大小: {sub_size / 1024:.1f} KB ({sub_size} bytes)")
# CMAP 对比
orig_cmap = original.getBestCmap()
sub_cmap = subset.getBestCmap()
target_chars = [0x20, 0x777f, 0x65b0, 0x81f4, 0x9060]
char_names = {0x20: '空格', 0x777f: '', 0x65b0: '', 0x81f4: '', 0x9060: ''}
print("\n字符映射对比:")
for code in target_chars:
name = char_names[code]
orig_glyph = orig_cmap.get(code, 'MISSING')
sub_glyph = sub_cmap.get(code, 'MISSING')
match = "" if orig_glyph == sub_glyph else ""
print(f" U+{code:04X} ({name}): 原始={orig_glyph}, 子集={sub_glyph} {match}")
# 字形数量
print(f"\n字形数量:")
print(f" 原始: {len(original.getGlyphOrder())}")
print(f" 子集: {len(subset.getGlyphOrder())}")
# 表对比
print("\n字体表:")
orig_tables = set(original.keys())
sub_tables = set(subset.keys())
print(f" 原始表: {sorted(orig_tables)}")
print(f" 子集表: {sorted(sub_tables)}")
original.close()
subset.close()
print("\n" + "=" * 50)
View File
+4 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, jest, beforeAll } from '@jest/globals';
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
@@ -35,11 +35,13 @@ jest.mock('framer-motion', () => ({
}));
jest.mock('next/link', () => {
return ({ children, href, ...props }: any) => (
const MockLink = ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
jest.mock('lucide-react', () => ({
@@ -11,28 +11,13 @@ jest.mock('next/navigation', () => ({
}));
jest.mock('next/link', () => {
return ({ children, href }: { children: React.ReactNode; href: string }) => {
const MockLink = ({ children, href }: { children: React.ReactNode; href: string }) => {
return <a href={href}>{children}</a>;
};
MockLink.displayName = 'MockLink';
return MockLink;
});
const mockProduct = {
id: 'test-product',
title: '测试产品',
category: '企业软件',
description: '这是测试产品描述',
overview: '这是测试产品概述',
features: ['功能1', '功能2'],
benefits: ['优势1', '优势2'],
process: ['步骤1', '步骤2'],
specs: ['规格1', '规格2'],
pricing: {
base: '¥10,000/年',
standard: '¥30,000/年',
enterprise: '定制',
},
};
jest.mock('@/lib/constants', () => ({
PRODUCTS: [
{
Binary file not shown.
+18 -18
View File
@@ -203,6 +203,24 @@
outline: none;
}
/* 马善政行书体 - 用于红色关键词高亮 */
.font-calligraphy {
font-family: var(--font-ma-shan-zheng), 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif !important;
font-weight: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* 青柳隷書 - 仅用于品牌标题"睿新致遠" */
.font-brand {
font-family: var(--font-aoyagi-reisho), 'Aoyagi Reisho', 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif !important;
font-weight: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
::selection {
background-color: var(--color-text-primary);
color: var(--color-bg-primary);
@@ -320,15 +338,6 @@
background-clip: text;
}
/* 马善政行书体 - 用于红色关键词高亮 */
.font-calligraphy {
font-family: var(--font-ma-shan-zheng), 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif !important;
font-weight: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* 发光效果 */
.bg-glow-red {
background: radial-gradient(circle at center, var(--color-accent-red-glow) 0%, transparent 70%);
@@ -369,15 +378,6 @@
}
}
/* 青柳隷書 - 仅用于品牌标题"睿新致遠" */
@utility font-brand {
font-family: var(--font-aoyagi-reisho), 'Aoyagi Reisho', 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif !important;
font-weight: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
@keyframes slideIn {
from {
opacity: 0;
+2
View File
@@ -8,6 +8,7 @@ import { GoogleAnalytics } from "@/components/analytics/GoogleAnalytics";
import { CookieConsent } from "@/components/analytics/CookieConsent";
import { PerformanceTracker } from "@/components/analytics/PerformanceTracker";
import { OutboundLinkTracker } from "@/components/analytics/OutboundLinkTracker";
import { ScrollDepthTracker } from "@/components/analytics/ScrollDepthTracker";
import { OrganizationSchema, WebsiteSchema } from "@/components/seo/structured-data";
import { MobileTabBar } from "@/components/layout/mobile-tab-bar";
import { ErrorBoundary } from "@/components/ui/error-boundary";
@@ -141,6 +142,7 @@ export default function RootLayout({
<GoogleAnalytics />
<PerformanceTracker />
<OutboundLinkTracker />
<ScrollDepthTracker />
<ThemeProvider>
<ErrorBoundary>
{children}
+83 -1
View File
@@ -138,13 +138,95 @@ export default function PrivacyPolicyPage() {
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">Cookie </h2>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.1 Cookie 使</h3>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
使 Cookie Cookie
</p>
<div className="overflow-x-auto mb-6">
<table className="min-w-full border border-gray-200 rounded-lg">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b">Cookie </th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"> Cookie</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"></td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"></td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"></td>
</tr>
<tr>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"> Cookie</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]">使</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]">14</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"></td>
</tr>
<tr>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"> Cookie</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]">广使</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]">-</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"></td>
</tr>
</tbody>
</table>
</div>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.2 Google Analytics 使</h3>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
使 Google Analytics 4 Google LLC 使访使
</p>
<p className="text-[#5C5C5C] leading-relaxed mb-2">
<strong></strong>
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-1 mb-4">
<li>访</li>
<li></li>
<li>/IP </li>
<li>访访</li>
</ul>
<p className="text-[#5C5C5C] leading-relaxed mb-2">
<strong></strong>
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-1 mb-4">
<li>IP </li>
<li> 14 </li>
<li>广</li>
<li> Google </li>
<li> Google 广</li>
</ul>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.3 </h3>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2 mb-4">
<li>访 Cookie</li>
<li>&ldquo;Cookie &rdquo;</li>
<li> Cookie</li>
</ul>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.4 </h3>
<p className="text-[#5C5C5C] leading-relaxed">
</p>
<ul className="list-none text-[#5C5C5C] space-y-1 mt-2">
<li>privacy@novalon.cn</li>
<li>驿12</li>
</ul>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
使
</p>
<ul className="list-none text-[#5C5C5C] space-y-2">
<li></li>
<li>contact@novalon.cn</li>
<li>privacy@novalon.cn</li>
<li>驿12</li>
</ul>
</section>
+211 -52
View File
@@ -1,48 +1,91 @@
'use client';
import { useState, useEffect } from 'react';
import { updateConsent, trackButtonClick } from '@/lib/analytics';
import { useState, useEffect, useCallback } from 'react';
import {
updateConsentDetailed,
trackButtonClick,
CookiePreferences,
getStoredPreferences,
storePreferences,
getDefaultPreferences,
} from '@/lib/analytics';
import { motion, AnimatePresence } from 'framer-motion';
const CONSENT_KEY = 'ga_consent';
const LEGACY_CONSENT_KEY = 'ga_consent';
export function CookieConsent() {
const [showConsent, setShowConsent] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [preferences, setPreferences] = useState<CookiePreferences>(getDefaultPreferences());
useEffect(() => {
const consent = localStorage.getItem(CONSENT_KEY);
if (!consent) {
const timer = setTimeout(() => {
setShowConsent(true);
}, 2000);
return () => clearTimeout(timer);
} else if (consent === 'granted') {
updateConsent(true);
const stored = getStoredPreferences();
if (stored) {
updateConsentDetailed(stored);
} else {
const legacyConsent = localStorage.getItem(LEGACY_CONSENT_KEY);
if (legacyConsent) {
const migratedPrefs: CookiePreferences = {
necessary: true,
analytics: legacyConsent === 'granted',
marketing: false,
timestamp: Date.now(),
};
storePreferences(migratedPrefs);
updateConsentDetailed(migratedPrefs);
localStorage.removeItem(LEGACY_CONSENT_KEY);
} else {
const timer = setTimeout(() => {
setShowConsent(true);
}, 2000);
return () => clearTimeout(timer);
}
}
return undefined;
}, []);
const handleAccept = () => {
const handleSavePreferences = useCallback((prefs: CookiePreferences) => {
setIsAnimating(true);
localStorage.setItem(CONSENT_KEY, 'granted');
updateConsent(true);
trackButtonClick('accept_cookies', 'consent_banner');
const finalPrefs = { ...prefs, timestamp: Date.now() };
storePreferences(finalPrefs);
updateConsentDetailed(finalPrefs);
trackButtonClick('save_cookie_preferences', 'consent_banner');
setTimeout(() => {
setShowConsent(false);
setShowSettings(false);
setIsAnimating(false);
}, 300);
}, []);
const handleAcceptAll = () => {
const allAccepted: CookiePreferences = {
necessary: true,
analytics: true,
marketing: false,
timestamp: Date.now(),
};
handleSavePreferences(allAccepted);
trackButtonClick('accept_all_cookies', 'consent_banner');
};
const handleDecline = () => {
setIsAnimating(true);
localStorage.setItem(CONSENT_KEY, 'denied');
updateConsent(false);
trackButtonClick('decline_cookies', 'consent_banner');
setTimeout(() => {
setShowConsent(false);
setIsAnimating(false);
}, 300);
const handleRejectAll = () => {
const allRejected: CookiePreferences = {
necessary: true,
analytics: false,
marketing: false,
timestamp: Date.now(),
};
handleSavePreferences(allRejected);
trackButtonClick('reject_all_cookies', 'consent_banner');
};
const handleTogglePreference = (key: 'analytics' | 'marketing') => {
setPreferences((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleSaveCustom = () => {
handleSavePreferences(preferences);
};
return (
@@ -56,40 +99,156 @@ export function CookieConsent() {
className="fixed bottom-16 md:bottom-0 left-0 right-0 z-[9998] bg-white border-t border-gray-200 shadow-lg"
>
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex-1">
<p className="text-sm text-gray-700">
使 Cookie
使{' '}
<a
href="/privacy"
className="text-[#C41E3A] hover:text-[#A01830] underline font-medium"
{!showSettings ? (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex-1">
<p className="text-sm text-gray-700">
使 Cookie
使{' '}
<a
href="/privacy"
className="text-[#C41E3A] hover:text-[#A01830] underline font-medium"
>
</a>
</p>
</div>
<div className="flex flex-wrap gap-3 shrink-0">
<button
onClick={() => setShowSettings(true)}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
</a>
</p>
</button>
<button
onClick={handleRejectAll}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleAcceptAll}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-white bg-[#C41E3A] rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
>
</button>
</div>
</div>
<div className="flex gap-3 shrink-0">
<button
onClick={handleDecline}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleAccept}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-white bg-[#C41E3A] rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
>
</button>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-[#1C1C1C]">Cookie </h3>
<button
onClick={() => setShowSettings(false)}
className="text-gray-500 hover:text-gray-700"
aria-label="关闭设置"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
<input
type="checkbox"
checked
disabled
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-not-allowed"
aria-label="必要 Cookie"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-[#1C1C1C]"> Cookie</span>
<span className="text-xs px-2 py-0.5 bg-gray-200 text-gray-600 rounded"></span>
</div>
<p className="text-sm text-[#5C5C5C] mt-1">
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
<input
type="checkbox"
checked={preferences.analytics}
onChange={() => handleTogglePreference('analytics')}
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-pointer"
aria-label="分析 Cookie"
/>
<div className="flex-1">
<span className="font-medium text-[#1C1C1C]"> Cookie</span>
<p className="text-sm text-[#5C5C5C] mt-1">
访使
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg opacity-50">
<input
type="checkbox"
checked={preferences.marketing}
onChange={() => handleTogglePreference('marketing')}
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-pointer"
aria-label="营销 Cookie"
/>
<div className="flex-1">
<span className="font-medium text-[#1C1C1C]"> Cookie</span>
<p className="text-sm text-[#5C5C5C] mt-1">
广使
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setShowSettings(false)}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleSaveCustom}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-white bg-[#C41E3A] rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
);
}
export function CookieSettingsButton() {
const [isVisible] = useState(() => {
if (typeof window === 'undefined') {return false;}
return !!getStoredPreferences();
});
if (!isVisible) {return null;}
return (
<button
onClick={() => {
const event = new CustomEvent('open-cookie-settings');
window.dispatchEvent(event);
}}
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-lg shadow-sm hover:bg-gray-50 transition-colors"
aria-label="Cookie 设置"
>
Cookie
</button>
);
}
+1 -1
View File
@@ -48,7 +48,7 @@ function GoogleAnalyticsContent() {
gtag('config', '${GA_MEASUREMENT_ID}', {
send_page_view: false,
anonymize_ip: true,
allow_google_signals: true,
allow_google_signals: false,
allow_ad_personalization_signals: false,
cookie_flags: 'SameSite=None;Secure'
});
@@ -0,0 +1,42 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { usePathname } from 'next/navigation';
import { trackScrollDepth } from '@/lib/analytics';
const MILESTONES = [25, 50, 75, 100] as const;
export function ScrollDepthTracker() {
const trackedRef = useRef<Set<number>>(new Set());
const pathname = usePathname();
const handleScroll = useCallback(() => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
if (docHeight <= 0) {
return;
}
const scrollPercent = Math.round((scrollTop / docHeight) * 100);
MILESTONES.forEach((milestone) => {
if (scrollPercent >= milestone && !trackedRef.current.has(milestone)) {
trackedRef.current.add(milestone);
trackScrollDepth(milestone);
}
});
}, []);
useEffect(() => {
trackedRef.current = new Set();
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [pathname, handleScroll]);
return null;
}
+10 -3
View File
@@ -5,16 +5,23 @@ import { MobileTabBar } from './mobile-tab-bar';
jest.mock('next/navigation', () => ({
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
}));
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
div: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => (
<div {...props}>{children}</div>
),
},
}));
jest.mock('next/link', () => {
return ({ children, href }: any) => <a href={href}>{children}</a>;
const MockLink = ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
describe('MobileTabBar', () => {
@@ -87,7 +94,7 @@ describe('MobileTabBar', () => {
it('should have correct href for contact', () => {
render(<MobileTabBar />);
const contactLink = screen.getByText('联系').closest('a');
expect(contactLink).toHaveAttribute('href', '/#contact');
expect(contactLink).toHaveAttribute('href', '/contact');
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ErrorBoundary } from './error-boundary';
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { Input } from './input';
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import {
Skeleton,
-2
View File
@@ -34,8 +34,6 @@ jest.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const { RippleButton } = jest.requireActual('./ripple-button');
describe('RippleButton', () => {
beforeEach(() => {
jest.clearAllMocks();
-1
View File
@@ -9,7 +9,6 @@ import {
SheetFooter,
SheetTitle,
SheetDescription,
SheetClose,
} from './sheet';
describe('Sheet', () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { Textarea } from './textarea';
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { renderHook, act } from '@testing-library/react';
import { renderHook } from '@testing-library/react';
import { useFocusTrap } from './use-focus-trap';
describe('useFocusTrap', () => {
+48
View File
@@ -149,6 +149,13 @@ export const trackProductView = (productId: string, productName: string) => {
}
};
export interface CookiePreferences {
necessary: boolean;
analytics: boolean;
marketing: boolean;
timestamp: number;
}
export const updateConsent = (granted: boolean) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('consent', 'update', {
@@ -157,3 +164,44 @@ export const updateConsent = (granted: boolean) => {
});
}
};
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 const getStoredPreferences = (): CookiePreferences | null => {
if (typeof window === 'undefined') {
return null;
}
try {
const stored = localStorage.getItem('cookie_preferences');
if (stored) {
return JSON.parse(stored) as CookiePreferences;
}
} catch {
return null;
}
return null;
};
export const storePreferences = (preferences: CookiePreferences) => {
if (typeof window !== 'undefined') {
localStorage.setItem('cookie_preferences', JSON.stringify(preferences));
}
};
export const getDefaultPreferences = (): CookiePreferences => ({
necessary: true,
analytics: false,
marketing: false,
timestamp: Date.now(),
});
+4 -4
View File
@@ -77,7 +77,7 @@ describe('Animation Variants', () => {
it('should have correct transition configuration', async () => {
const { inkVariants } = await import('./animations');
const transition = inkVariants.visible.transition as any;
const transition = (inkVariants.visible as any)?.transition;
expect(transition.duration).toBe(0.8);
expect(transition.ease).toEqual([0.16, 1, 0.3, 1]);
});
@@ -102,7 +102,7 @@ describe('Animation Variants', () => {
it('should use spring animation', async () => {
const { sealStampVariants } = await import('./animations');
const transition = sealStampVariants.visible.transition as any;
const transition = (sealStampVariants.visible as any)?.transition;
expect(transition.type).toBe('spring');
expect(transition.stiffness).toBe(300);
expect(transition.damping).toBe(20);
@@ -144,7 +144,7 @@ describe('Animation Variants', () => {
describe('staggerContainerVariants', () => {
it('should have staggerChildren configured', async () => {
const { staggerContainerVariants } = await import('./animations');
const transition = staggerContainerVariants.visible.transition as any;
const transition = (staggerContainerVariants.visible as any)?.transition;
expect(transition.staggerChildren).toBe(0.1);
expect(transition.delayChildren).toBe(0.1);
});
@@ -412,7 +412,7 @@ describe('Animation Components', () => {
const { GlitchText } = await import('./animations');
render(<GlitchText text="Test" className="glitch-class" />);
const testElements = screen.getAllByText('Test');
const container = testElements[0].closest('.glitch-class');
const container = testElements[0]?.closest('.glitch-class');
expect(container).toBeInTheDocument();
});
});
+1 -1
View File
@@ -12,5 +12,5 @@ export const NAVIGATION: NavigationItem[] = [
{ id: 'cases', label: '成功案例', href: '/' },
{ id: 'about', label: '关于我们', href: '/' },
{ id: 'news', label: '新闻动态', href: '/' },
{ id: 'contact', label: '联系我们', href: '/contact' },
{ id: 'contact', label: '联系', href: '/contact' },
];