refactor: 完成静态网站转换,移除所有 CMS 和动态功能

- 删除数据库相关代码 (src/db/)
- 删除 API 路由 (src/app/api/)
- 删除认证相关代码 (src/lib/auth/, src/providers/)
- 删除监控和安全中间件 (src/lib/security/, src/lib/monitoring/)
- 删除 hooks (use-news, use-products, use-services)
- 更新组件为静态数据源
- 添加 nginx 静态配置和部署脚本
- 添加 static-link 组件
This commit is contained in:
张翔
2026-04-21 07:53:56 +08:00
parent cd1d6aa28a
commit 6403489954
197 changed files with 654 additions and 24762 deletions
+1 -10
View File
@@ -1,11 +1,2 @@
DATABASE_URL=postgresql://user:password@localhost:5432/novalon NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
NEXTAUTH_SECRET=your-secret-key-here
NEXTAUTH_URL=https://novalon.cn
RESEND_API_KEY=your-resend-api-key-here
OPS_ALERT_EMAIL=ops@novalon.cn
CDN_DOMAIN=https://cdn.novalon.cn CDN_DOMAIN=https://cdn.novalon.cn
COS_SECRET_ID=your-tencent-cloud-secret-id
COS_SECRET_KEY=your-tencent-cloud-secret-key
COS_BUCKET=novalon-cdn-1250000000
COS_REGION=ap-chengdu
+3
View File
@@ -289,3 +289,6 @@ findings.md
# Visual regression snapshots should be committed to version control # Visual regression snapshots should be committed to version control
# These are in: e2e/src/tests/visual/**/*-snapshots/ # These are in: e2e/src/tests/visual/**/*-snapshots/
# Git will track them because they are not in test-results/ or allure-results/ # Git will track them because they are not in test-results/ or allure-results/
# AGENTS
AGENTS.md
-401
View File
@@ -1,401 +0,0 @@
# ============================================
# Novalon Website - 全自动CI/CD工作流
# ============================================
# 发布策略:release分支发布 + main分支归档
#
# 分支角色:
# - feature分支:开发新功能
# - release分支:生产环境代码,合并后自动部署
# - main分支:稳定代码归档,只读
#
# 流水线阶段:
# 1. 代码质量检查 (lint, type-check, security)
# 2. 单元测试和集成测试
# 3. E2E测试 (分层测试)
# 4. 构建Docker镜像
# 5. 部署到生产环境 (release分支)
# 6. 归档到main分支
# 7. 通知和监控
# ============================================
# 全局环境变量
variables:
- &node_image node:20-alpine
- &docker_image docker:24-cli
# ============================================
# 阶段1: 代码质量检查
# ============================================
steps:
# 1.1 Lint检查
lint:
image: *node_image
environment:
NODE_ENV: development
commands:
- npm ci
- npm run lint
when:
event:
- push
- pull_request
# 1.2 类型检查
type-check:
image: *node_image
environment:
NODE_ENV: development
commands:
- npm ci
- npm run type-check
when:
event:
- push
- pull_request
# 1.3 安全漏洞扫描
security-scan:
image: *node_image
environment:
NODE_ENV: development
commands:
- npm ci
- npm audit --audit-level=moderate
when:
event:
- push
- pull_request
failure: ignore
# ============================================
# 阶段2: 单元测试和集成测试
# ============================================
unit-tests:
image: *node_image
environment:
NODE_ENV: test
CI: true
commands:
- npm ci
- npm run test:coverage:check
when:
event:
- push
- pull_request
# ============================================
# 阶段3: E2E测试 (分层测试)
# ============================================
# 3.1 Smoke测试 (PR快速验证)
e2e-smoke:
image: mcr.microsoft.com/playwright:v1.48.0-jammy
environment:
NODE_ENV: test
CI: true
commands:
- npm ci
- cd e2e && npm ci
- npx playwright install chromium --with-deps
- npm run test:smoke
when:
event:
- pull_request
# 3.2 标准测试 (release分支)
e2e-standard:
image: mcr.microsoft.com/playwright:v1.48.0-jammy
environment:
NODE_ENV: test
CI: true
commands:
- npm ci
- cd e2e && npm ci
- npx playwright install chromium --with-deps
- npm run test:tier:standard
when:
event:
- push
branch:
- release
- release/**
# 3.3 深度测试 (release分支)
e2e-deep:
image: mcr.microsoft.com/playwright:v1.48.0-jammy
environment:
NODE_ENV: test
CI: true
commands:
- npm ci
- cd e2e && npm ci
- npx playwright install chromium firefox webkit --with-deps
- npm run test:tier:deep
when:
event:
- push
branch:
- release
- release/**
# 3.4 性能测试 (release分支)
e2e-performance:
image: mcr.microsoft.com/playwright:v1.48.0-jammy
environment:
NODE_ENV: test
CI: true
commands:
- npm ci
- cd e2e && npm ci
- npx playwright install chromium --with-deps
- npm run test:performance
when:
event:
- push
branch:
- release
- release/**
# 3.5 可访问性测试 (release分支)
e2e-accessibility:
image: mcr.microsoft.com/playwright:v1.48.0-jammy
environment:
NODE_ENV: test
CI: true
commands:
- npm ci
- cd e2e && npm ci
- npx playwright install chromium --with-deps
- npx playwright test --grep @accessibility
when:
event:
- push
branch:
- release
- release/**
# 3.6 视觉回归测试 (release分支)
e2e-visual:
image: mcr.microsoft.com/playwright:v1.48.0-jammy
environment:
NODE_ENV: test
CI: true
commands:
- npm ci
- cd e2e && npm ci
- npx playwright install chromium --with-deps
- npx playwright test --grep @visual
when:
event:
- push
branch:
- release
- release/**
# ============================================
# 阶段4: 构建Docker镜像 (release分支)
# ============================================
build-image:
image: *docker_image
environment:
DOCKER_HOST: tcp://docker:2375
REGISTRY_PASSWORD:
from_secret: registry_password
commands:
- echo "Building Docker image..."
- docker build -t registry.f.novalon.cn/novalon-website:${CI_COMMIT_SHA} .
- docker tag registry.f.novalon.cn/novalon-website:${CI_COMMIT_SHA} registry.f.novalon.cn/novalon-website:latest
- docker tag registry.f.novalon.cn/novalon-website:${CI_COMMIT_SHA} registry.f.novalon.cn/novalon-website:release-${CI_COMMIT_SHA:0:7}
- echo "Pushing to registry..."
- echo "$REGISTRY_PASSWORD" | docker login -u novalon-admin --password-stdin registry.f.novalon.cn
- docker push registry.f.novalon.cn/novalon-website:${CI_COMMIT_SHA}
- docker push registry.f.novalon.cn/novalon-website:latest
- docker push registry.f.novalon.cn/novalon-website:release-${CI_COMMIT_SHA:0:7}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
when:
- event: push
branch:
- release
- release/**
# ============================================
# 阶段5: 部署到生产环境 (release分支)
# ============================================
deploy-production:
image: alpine:latest
environment:
DEPLOY_ENV: production
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
REGISTRY_PASSWORD:
from_secret: registry_password
commands:
- echo "Deploying to production environment..."
- apk add --no-cache openssh-client curl
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H 139.155.109.62 >> ~/.ssh/known_hosts
# 前置检查
- echo "Pre-deployment checks..."
- ssh root@139.155.109.62 "echo 'Server connection OK'"
- ssh root@139.155.109.62 "df -h | grep -E '/$|/home'"
- ssh root@139.155.109.62 "docker ps | grep novalon-website || echo 'No existing container'"
# 部署
- |
ssh root@139.155.109.62 << EOF
set -e # 任何命令失败立即退出
cd /home/novalon/docker-app/novalon-website
echo "=== Step 1: Login to Registry ==="
if ! echo "${REGISTRY_PASSWORD}" | docker login -u novalon-admin --password-stdin registry.f.novalon.cn; then
echo "❌ Registry login failed!"
exit 1
fi
echo "=== Step 2: Backup current version ==="
BACKUP_TIME=\$(date +%Y%m%d_%H%M%S)
docker tag registry.f.novalon.cn/novalon-website:latest registry.f.novalon.cn/novalon-website:backup-\${BACKUP_TIME} 2>/dev/null || echo "No existing image to backup"
echo "=== Step 3: Pull new image ==="
if ! docker-compose pull novalon-website; then
echo "❌ Image pull failed!"
exit 1
fi
echo "=== Step 4: Rolling update ==="
docker-compose up -d --no-deps novalon-website
echo "=== Step 5: Wait for service startup ==="
sleep 10
echo "=== Step 6: Database migration ==="
if ! docker-compose exec -T novalon-website npm run db:migrate; then
echo "❌ Database migration failed, rolling back..."
docker tag registry.f.novalon.cn/novalon-website:backup-\${BACKUP_TIME} registry.f.novalon.cn/novalon-website:latest 2>/dev/null || true
docker-compose pull novalon-website
docker-compose up -d --no-deps novalon-website
exit 1
fi
echo "=== Step 7: Health check ==="
for i in {1..30}; do
if curl -f https://novalon.cn/api/health; then
echo "✅ Health check passed!"
echo "=== Step 8: Cleanup old images ==="
docker image prune -f
docker images registry.f.novalon.cn/novalon-website --format "{{.ID}} {{.CreatedAt}}" | tail -n +4 | awk '{print \$1}' | xargs -r docker rmi -f || true
exit 0
fi
echo "Waiting for service to be ready... (\$i/30)"
sleep 2
done
echo "❌ Health check failed, rolling back..."
docker tag registry.f.novalon.cn/novalon-website:backup-\${BACKUP_TIME} registry.f.novalon.cn/novalon-website:latest 2>/dev/null || true
docker-compose pull novalon-website
docker-compose up -d --no-deps novalon-website
sleep 10
# 验证回滚
if curl -f https://novalon.cn/api/health; then
echo "✅ Rollback succeeded, but deployment failed"
else
echo "❌ Rollback also failed!"
fi
exit 1
EOF
- echo "✅ Production deployment completed!"
when:
event:
- push
branch:
- release
- release/**
# ============================================
# 阶段6: 归档到main分支 (release分支)
# ============================================
archive-to-main:
image: alpine:latest
environment:
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
commands:
- echo "Archiving to main branch..."
- apk add --no-cache git openssh-client
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H git.f.novalon.cn >> ~/.ssh/known_hosts
- |
set -e
git config --global user.email "ci@novalon.cn"
git config --global user.name "Woodpecker CI"
# 使用SSH而不是HTTPS+Token
git remote set-url origin git@git.f.novalon.cn:novalon/novalon-website.git
# 拉取最新代码
git fetch origin
git checkout main
git pull origin main
# 合并release分支
git merge release --no-ff -m "chore: 归档release ${CI_COMMIT_SHA:0:7}"
# 创建版本标签
VERSION_TAG="v$(date +%Y.%m.%d)-${CI_COMMIT_SHA:0:7}"
git tag -a "$VERSION_TAG" -m "Release $(date +%Y-%m-%d)"
# 推送到远程(带重试)
for i in {1..3}; do
if git push origin main && git push origin --tags; then
echo "✅ Archive succeeded! Version: $VERSION_TAG"
exit 0
fi
echo "Retry $i/3..."
sleep 5
done
echo "⚠️ Archive failed, but deployment succeeded"
echo "Manual archive may be needed"
exit 0 # 不阻止部署成功
when:
event:
- push
branch:
- release
- release/**
status:
- success
# ============================================
# 服务配置
# ============================================
services:
docker:
image: docker:24-dind
privileged: true
environment:
DOCKER_TLS_CERTDIR: ""
# ============================================
# 工作区配置
# ============================================
workspace:
base: /woodpecker
path: src
# ============================================
# 克隆配置
# ============================================
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
partial: false
+6 -29
View File
@@ -1,44 +1,21 @@
FROM node:20-alpine AS base FROM node:20-alpine AS builder
ARG CDN_DOMAIN
ENV CDN_DOMAIN=${CDN_DOMAIN}
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci && npm cache clean --force RUN npm ci && npm cache clean --force
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN npm run build
FROM base AS runner FROM nginx:alpine
WORKDIR /app
ENV NODE_ENV=production COPY --from=builder /app/dist /usr/share/nginx/html
ENV NEXT_TELEMETRY_DISABLED=1 COPY nginx-static.conf /etc/nginx/nginx.conf
RUN addgroup --system --gid 1001 nodejs && \ EXPOSE 80
adduser --system --uid 1001 nextjs
COPY --from=builder /app/dist/standalone ./ CMD ["nginx", "-g", "daemon off;"]
COPY --from=builder /app/dist/static ./dist/static
COPY --from=builder /app/public ./public
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+34 -520
View File
@@ -4,7 +4,7 @@
## 项目概述 ## 项目概述
本项目是四川睿新致远科技有限公司的企业官网,采用 Next.js 16 + React 19 + TypeScript 技术栈构建,提供现代化的企业展示、产品服务介绍、案例展示、新闻动态和在线咨询等功能。 本项目是四川睿新致远科技有限公司的企业官网,采用 Next.js 16 + React 19 + TypeScript 技术栈构建的纯静态网站,提供现代化的企业展示、产品服务介绍、案例展示、新闻动态等功能。
### 核心功能 ### 核心功能
@@ -13,10 +13,8 @@
- **产品展示** - 产品列表和详情页面 - **产品展示** - 产品列表和详情页面
- **案例展示** - 成功案例列表和详情 - **案例展示** - 成功案例列表和详情
- **新闻动态** - 公司新闻、产品发布、合作动态、行业资讯 - **新闻动态** - 公司新闻、产品发布、合作动态、行业资讯
- **在线咨询** - 联系表单、公司信息展示
- **响应式设计** - 完美适配桌面端、平板和移动设备 - **响应式设计** - 完美适配桌面端、平板和移动设备
- **SEO 优化** - 结构化数据、元信息优化 - **SEO 优化** - 结构化数据、元信息优化
- **CMS管理后台** - 内容管理、用户管理、配置中心、审计日志
## 技术栈 ## 技术栈
@@ -29,14 +27,9 @@
| 组件库 | shadcn/ui (Radix UI) | - | | 组件库 | shadcn/ui (Radix UI) | - |
| 动画 | Framer Motion | 12.x | | 动画 | Framer Motion | 12.x |
| 图标 | Lucide React | 0.563.0 | | 图标 | Lucide React | 0.563.0 |
| 邮件服务 | Resend | 6.9.2 |
| 数据验证 | Zod | 4.3.6 | | 数据验证 | Zod | 4.3.6 |
| 图表 | @antv/g2 | 5.4.8 | | 图表 | @antv/g2 | 5.4.8 |
| 3D 效果 | Three.js | 0.183.1 | | 3D 效果 | Three.js | 0.183.1 |
| 数据库 | SQLite | - |
| ORM | Drizzle ORM | - |
| 认证 | NextAuth.js | 5.x beta |
| 富文本编辑 | Tiptap | - |
## 快速开始 ## 快速开始
@@ -51,37 +44,6 @@
npm install npm install
``` ```
### 环境变量配置
复制环境变量示例文件:
```bash
cp .env.example .env.local
```
配置必要的环境变量:
```env
# 邮件服务
RESEND_API_KEY=your_resend_api_key
COMPANY_EMAIL=contact@novalon.cn
# 数据库
DATABASE_URL=./data/novalon.db
# NextAuth.js
NEXTAUTH_SECRET=your_nextauth_secret
NEXTAUTH_URL=http://localhost:3000
# 文件上传
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760
# 管理员账号(首次运行时创建)
ADMIN_EMAIL=contact@novalon.cn
ADMIN_PASSWORD=your_secure_password
```
### 开发模式 ### 开发模式
```bash ```bash
@@ -98,10 +60,10 @@ npm run build
输出目录: `dist/` 输出目录: `dist/`
### 启动生产服务器 ### 预览生产版本
```bash ```bash
npm start npm run preview
``` ```
## 项目结构 ## 项目结构
@@ -119,22 +81,8 @@ novalon-website/
│ │ │ ├── products/ # 产品服务 │ │ │ ├── products/ # 产品服务
│ │ │ ├── services/ # 核心业务 │ │ │ ├── services/ # 核心业务
│ │ │ └── solutions/ # 解决方案 │ │ │ └── solutions/ # 解决方案
│ │ ├── admin/ # 管理后台 │ │ ├── privacy/ # 隐私政策
│ │ │ ├── page.tsx # 仪表盘 │ │ ├── terms/ # 服务条款
│ │ │ ├── login/ # 登录页面
│ │ │ ├── content/ # 内容管理
│ │ │ ├── users/ # 用户管理
│ │ │ ├── settings/ # 配置中心
│ │ │ └── logs/ # 审计日志
│ │ ├── api/ # API 路由
│ │ │ ├── auth/ # 认证 API
│ │ │ ├── contact/ # 联系表单 API
│ │ │ └── admin/ # 管理 API
│ │ │ ├── content/ # 内容管理
│ │ │ ├── users/ # 用户管理
│ │ │ ├── config/ # 配置管理
│ │ │ ├── upload/ # 文件上传
│ │ │ └── logs/ # 审计日志
│ │ ├── layout.tsx # 根布局 │ │ ├── layout.tsx # 根布局
│ │ ├── error.tsx # 错误页面 │ │ ├── error.tsx # 错误页面
│ │ └── not-found.tsx # 404 页面 │ │ └── not-found.tsx # 404 页面
@@ -144,76 +92,20 @@ novalon-website/
│ │ ├── sections/ # 页面区块组件 │ │ ├── sections/ # 页面区块组件
│ │ ├── effects/ # 视觉效果组件 │ │ ├── effects/ # 视觉效果组件
│ │ ├── seo/ # SEO 组件 │ │ ├── seo/ # SEO 组件
│ │ ── analytics/ # 分析组件 │ │ ── analytics/ # 分析组件
│ │ └── admin/ # 管理后台组件
│ ├── lib/ # 工具函数
│ │ ├── api/ # API 服务
│ │ ├── auth/ # 认证相关
│ │ ├── db.ts # 数据库连接
│ │ ├── audit.ts # 审计日志
│ │ └── upload.ts # 文件上传
│ ├── db/ # 数据库相关
│ │ ├── schema.ts # 数据库 Schema
│ │ ├── seed.ts # 种子数据
│ │ └── migrations/ # 迁移文件
│ ├── hooks/ # 自定义 Hooks │ ├── hooks/ # 自定义 Hooks
│ └── contexts/ # React Context │ └── contexts/ # React Context
├── e2e/ # E2E 测试(统一测试框架) ├── e2e/ # E2E 测试
│ ├── src/ ├── tests/ # 测试文件
│ ├── tests/ # 测试用例 │ ├── performance/ # 性能测试
├── smoke/ # 冒烟测试 └── styles/ # 样式测试
│ │ │ ├── regression/ # 回归测试
│ │ │ ├── api/ # API 测试
│ │ │ ├── accessibility/ # 可访问性测试
│ │ │ ├── performance/ # 性能测试
│ │ │ ├── security/ # 安全测试
│ │ │ └── visual/ # 视觉回归测试
│ │ ├── pages/ # Page Object
│ │ ├── fixtures/ # 测试 Fixtures
│ │ └── config/ # 测试配置
│ ├── playwright.config.ts
│ └── MIGRATION.md # 测试框架迁移说明
├── docs/ # 项目文档 ├── docs/ # 项目文档
│ ├── architecture/ # 架构文档
│ ├── development/ # 开发文档
│ ├── deployment/ # 部署文档
│ ├── testing/ # 测试文档
│ ├── api/ # API 文档
│ ├── guides/ # 使用指南
│ ├── STRUCTURE_PLAN.md # 目录结构规划
│ └── OPTIMIZATION_REPORT.md # 优化报告
├── scripts/ # 脚本文件 ├── scripts/ # 脚本文件
│ ├── deployment/ # 部署脚本
│ ├── monitoring/ # 监控脚本
│ ├── testing/ # 测试脚本
│ ├── maintenance/ # 维护脚本
│ └── utils/ # 工具脚本
├── config/ # 配置文件 ├── config/ # 配置文件
│ ├── ci/ # CI/CD 配置
│ ├── lint/ # 代码检查配置
│ └── test/ # 测试配置
├── reports/ # 测试报告
│ ├── e2e/ # E2E 测试报告
│ ├── performance/ # 性能测试报告
│ └── coverage/ # 代码覆盖率报告
├── public/ # 静态资源 ├── public/ # 静态资源
├── uploads/ # 上传文件存储
├── data/ # SQLite 数据库文件
└── dist/ # 构建输出 └── dist/ # 构建输出
``` ```
### 项目优化说明
本项目已于 2026-03-24 完成全面的工程化与规范化优化,包括:
1. **测试体系整合** - 统一为 Playwright TypeScript 测试框架
2. **目录结构规范化** - 建立清晰的目录结构,符合 Next.js 最佳实践
3. **配置文件优化** - 合并重复配置,统一配置管理
4. **文档体系完善** - 建立完整的文档体系和导航
5. **代码质量提升** - 修复所有类型错误,确保构建成功
详细信息请查看 [优化报告](docs/OPTIMIZATION_REPORT.md)
## 页面路由 ## 页面路由
| 路由 | 描述 | | 路由 | 描述 |
@@ -231,13 +123,6 @@ novalon-website/
| `/contact` | 联系我们 | | `/contact` | 联系我们 |
| `/privacy` | 隐私政策 | | `/privacy` | 隐私政策 |
| `/terms` | 服务条款 | | `/terms` | 服务条款 |
| `/admin` | 管理后台仪表盘 |
| `/admin/login` | 管理员登录 |
| `/admin/content` | 内容管理 |
| `/admin/content/[id]` | 内容编辑 |
| `/admin/users` | 用户管理 |
| `/admin/settings` | 配置中心 |
| `/admin/logs` | 审计日志 |
## NPM 脚本 ## NPM 脚本
@@ -247,14 +132,11 @@ novalon-website/
| `npm run build` | 构建生产版本 | | `npm run build` | 构建生产版本 |
| `npm start` | 启动生产服务器 | | `npm start` | 启动生产服务器 |
| `npm run lint` | 运行 ESLint 检查 | | `npm run lint` | 运行 ESLint 检查 |
| `npm run type-check` | TypeScript 类型检查 |
| `npm run test` | 运行 E2E 测试 | | `npm run test` | 运行 E2E 测试 |
| `npm run test:smoke` | 运行冒烟测试 | | `npm run test:unit` | 运行单元测试 |
| `npm run check:contrast` | 检查颜色对比度 | | `npm run test:coverage` | 运行测试覆盖率 |
| `npm run check:headings` | 检查标题层级 | | `npm run lighthouse` | 运行 Lighthouse 性能测试 |
| `npm run db:generate` | 生成数据库迁移文件 |
| `npm run db:migrate` | 执行数据库迁移 |
| `npm run db:seed` | 填充数据库种子数据 |
| `npm run db:studio` | 启动 Drizzle Studio |
## 代码质量门禁 ## 代码质量门禁
@@ -264,18 +146,12 @@ novalon-website/
- **commitlint**: 提交信息规范 - **commitlint**: 提交信息规范
- **Jest**: 代码覆盖率检查 - **Jest**: 代码覆盖率检查
详细信息请查看 [质量门禁文档](docs/development/quality-gates.md)。
### 提交规范 ### 提交规范
使用 Conventional Commits 规范: 使用 Conventional Commits 规范:
``` ```
<type>(<scope>): <subject> <type>(<scope>): <subject>
<body>
<footer>
``` ```
**提交类型**: **提交类型**:
@@ -290,415 +166,53 @@ novalon-website/
## 测试 ## 测试
项目使用 Playwright 进行 E2E 测试,测试框架位于 `e2e/` 目录 项目使用 Playwright 进行 E2E 测试,Jest 进行单元测试。
### 测试类型
- **冒烟测试** - 基础功能验证
- **回归测试** - 功能完整性验证
- **API测试** - 后端API接口测试
- **性能测试** - Core Web Vitals
- **响应式测试** - 多设备适配
- **可访问性测试** - WCAG 合规
- **安全测试** - XSS、CSRF 防护
- **视觉回归测试** - UI 一致性
### 运行测试 ### 运行测试
```bash ```bash
cd e2e # E2E 测试
npm install
npm run test npm run test
# 单元测试
npm run test:unit
# 测试覆盖率
npm run test:coverage
``` ```
## 管理后台 ## 部署
### 功能模块 ### 静态部署
#### 内容管理 项目构建后生成纯静态文件,可部署到任何静态托管服务:
- 支持新闻、产品、服务、案例四种内容类型
- 富文本编辑器(支持图片上传)
- 内容版本管理
- 草稿/发布/归档状态管理
#### 用户管理
- 用户创建、编辑、删除
- 角色权限控制(管理员、编辑、查看者)
- 密码加密存储
#### 配置中心
- 网站基本信息配置
- SEO配置
- 联系信息配置
- 分类管理
#### 审计日志
- 操作记录追踪
- 按操作类型、资源类型筛选
- 分页查询
### 权限说明
| 角色 | 内容管理 | 用户管理 | 配置管理 | 审计日志 |
|------|---------|---------|---------|---------|
| admin | 全部权限 | 全部权限 | 全部权限 | 查看权限 |
| editor | 创建、编辑、发布 | 无权限 | 查看权限 | 查看权限 |
| viewer | 查看权限 | 无权限 | 查看权限 | 查看权限 |
### API 接口
#### 认证接口
- `POST /api/auth/signin` - 登录
- `POST /api/auth/signout` - 登出
- `GET /api/auth/session` - 获取会话信息
#### 内容管理接口
- `GET /api/admin/content` - 获取内容列表
- `POST /api/admin/content` - 创建内容
- `GET /api/admin/content/[id]` - 获取内容详情
- `PUT /api/admin/content/[id]` - 更新内容
- `DELETE /api/admin/content/[id]` - 删除内容
#### 用户管理接口
- `GET /api/admin/users` - 获取用户列表
- `POST /api/admin/users` - 创建用户
- `GET /api/admin/users/[id]` - 获取用户详情
- `PUT /api/admin/users/[id]` - 更新用户
- `DELETE /api/admin/users/[id]` - 删除用户
#### 配置管理接口
- `GET /api/admin/config` - 获取配置列表
- `POST /api/admin/config` - 更新配置
#### 文件上传接口
- `POST /api/admin/upload` - 上传文件
- `DELETE /api/admin/upload` - 删除文件
#### 审计日志接口
- `GET /api/admin/logs` - 获取审计日志列表
## CI/CD
项目使用 Woodpecker CI 进行持续集成,配置文件为 `.woodpecker/` 目录。
CI 流水线包括:
- **CI 工作流** (`.woodpecker/ci.yml`) - 代码检查、测试、构建
- **部署工作流** (`.woodpecker/deploy.yml`) - 生产环境部署
- **质量门禁** (`.woodpecker/quality-gate.yml`) - 代码质量检查
### CI 触发条件
- 分支:`main``develop`
- 事件:`push``pull_request`
### 质量门禁标准
- ESLint 检查通过
- TypeScript 类型检查通过
- 单元测试覆盖率 ≥ 70%
- E2E 测试通过率 ≥ 95%
## 监控和告警
### Sentry 错误监控
项目集成了 Sentry 错误监控,用于追踪生产环境中的错误。
**配置环境变量:**
```env
NEXT_PUBLIC_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
```
**监控内容:**
- JavaScript 错误
- API 错误
- 性能追踪
- 用户会话回放
### 健康检查
健康检查 API`GET /api/health`
**返回信息:**
- 应用状态
- 运行时间
- 内存使用
- 数据库连接状态
- 请求统计
### 性能监控
项目内置性能监控工具,记录关键指标:
- 响应时间(平均值、P50、P95、P99)
- 请求计数
- 内存使用率
**查看性能数据:**
```bash
curl http://localhost:3000/api/health
```
## 备份和恢复
### 备份
使用备份脚本定期备份数据:
```bash
./scripts/backup.sh
```
**备份内容包括:**
- SQLite 数据库文件
- 上传文件
- 环境配置
**备份文件位置:** `./backups/backup_YYYYMMDD_HHMMSS.tar.gz`
**自动清理:** 保留最近 7 天的备份
### 恢复
使用恢复脚本从备份中恢复数据:
```bash
./scripts/restore.sh <backup_file.tar.gz>
```
**注意事项:**
- 恢复操作会覆盖当前数据
- 恢复后需要重启应用
- 建议在恢复前先备份当前数据
## 性能测试
项目使用 k6 进行性能测试。
### 负载测试
模拟正常用户访问模式,测试系统在预期负载下的表现。
```bash
npm run test:performance
```
**测试场景:**
- 逐步增加用户数(100 → 200)
- 访问主要页面
- 提交联系表单
**性能指标:**
- P95 响应时间 < 500ms
- P99 响应时间 < 1000ms
- 错误率 < 1%
### 压力测试
测试系统在极端负载下的表现和极限。
```bash
npm run test:stress
```
**测试场景:**
- 快速增加用户数(50 → 300
- 持续高负载
- 快速下降
**性能指标:**
- P95 响应时间 < 1000ms
- P99 响应时间 < 2000ms
- 错误率 < 5%
**测试报告:** `tests/performance/load-test-summary.json`
## 安全测试
项目使用 k6 进行安全测试。
### SQL 注入测试
测试系统对 SQL 注入攻击的防护能力。
```bash
npm run test:sql-injection
```
**测试内容:**
- 常见 SQL 注入 payload
- UNION 查询注入
- 盲注攻击
- 时间注入
**防护措施:**
- 使用参数化查询(Drizzle ORM
- 输入验证和过滤
- 错误信息脱敏
### XSS 防护测试
测试系统对跨站脚本攻击的防护能力。
```bash
npm run test:xss
```
**测试内容:**
- Script 标签注入
- 事件处理器注入
- JavaScript 伪协议
- 外部资源引用
**防护措施:**
- 输入转义(DOMPurify
- CSP 策略
- HTTPOnly Cookie
### 完整安全测试
运行所有安全测试:
```bash
npm run test:security
```
## Docker 部署
项目提供 Docker 部署方案。
### 构建镜像
```bash
docker build -t novalon-website .
```
### 使用 Docker Compose
```bash
cp .env.production.example .env.production
docker-compose -f docker-compose.prod.yml up -d
```
### 健康检查
Docker 容器配置了健康检查:
```yaml
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
## 生产环境配置
### 环境变量
生产环境需要配置以下变量:
```env
# 数据库
DATABASE_URL=file:./data/prod.db
# NextAuth
NEXTAUTH_URL=https://novalon.cn
NEXTAUTH_SECRET=your-production-secret-here
# 管理员
ADMIN_EMAIL=admin@novalon.cn
ADMIN_PASSWORD=your-secure-password
# Sentry
NEXT_PUBLIC_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
# 邮件服务
RESEND_API_KEY=your_resend_api_key
COMPANY_EMAIL=contact@novalon.cn
# 文件上传
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760
# 站点 URL
NEXT_PUBLIC_SITE_URL=https://novalon.cn
```
### 部署流程
1. **准备环境**
```bash
cp .env.production.example .env.production
# 编辑 .env.production 配置生产环境变量
```
2. **构建应用**
```bash ```bash
npm run build npm run build
``` ```
3. **初始化数据库** 构建产物位于 `dist/` 目录,可直接部署到:
```bash - Nginx
npm run db:push - CDN
npm run db:seed - Vercel
``` - Netlify
- GitHub Pages
4. **启动服务** ### Docker 部署
```bash
npm start
# 或使用 Docker
docker-compose -f docker-compose.prod.yml up -d
```
5. **验证部署**
```bash
curl http://localhost:3000/api/health
```
6. **配置监控**
- 在 Sentry 创建项目并配置 DSN
- 配置告警规则
7. **设置定时备份**
```bash
# 添加到 crontab
0 2 * * * /path/to/scripts/backup.sh
```
8. **配置CDN加速** (可选)
为静态资源配置CDN加速,提升网站加载速度:
```bash ```bash
# 配置CDN环境变量 docker build -t novalon-website .
export CDN_DOMAIN=https://cdn.novalon.cn docker run -p 3000:3000 novalon-website
export COS_SECRET_ID=your-tencent-cloud-secret-id
export COS_SECRET_KEY=your-tencent-cloud-secret-key
export COS_BUCKET=novalon-cdn-1250000000
export COS_REGION=ap-chengdu
# 上传静态资源到COS
npm run deploy:cdn
# 刷新CDN缓存
npm run deploy:cdn:refresh
``` ```
详细配置步骤请参考 [CDN配置文档](./docs/CDN_CONFIGURATION.md)
## 文档 ## 文档
详细文档位于 `docs/` 目录: 详细文档位于 `docs/` 目录:
- [架构文档](docs/architecture.md) - 系统架构设计 - [架构文档](docs/architecture.md) - 系统架构设计
- [组件文档](docs/components.md) - 组件使用指南 - [组件文档](docs/components.md) - 组件使用指南
- [API 文档](docs/api.md) - API 接口说明
- [测试文档](docs/testing.md) - 测试策略和指南 - [测试文档](docs/testing.md) - 测试策略和指南
- [部署文档](docs/deployment.md) - 部署流程说明 - [部署文档](docs/deployment.md) - 部署流程说明
- [CMS文档](docs/cms.md) - CMS系统使用指南
## 许可证 ## 许可证
-28
View File
@@ -1,28 +0,0 @@
when:
branch: [main, develop]
event: [push, pull_request]
steps:
lint:
image: node:18-alpine
commands:
- npm ci
- npm run lint
- npm run type-check
test:
image: node:18-alpine
commands:
- npm ci
- npm run db:push
- npm run test:unit -- --coverage --coverageReporters=text --coverageReporters=lcov
- npx playwright install --with-deps
- npm run test:e2e
build:
image: node:18-alpine
commands:
- npm ci
- npm run build
when:
status: [success]
-12
View File
@@ -1,12 +0,0 @@
when:
branch: [main]
event: [push]
steps:
deploy:
image: node:18-alpine
commands:
- npm ci
- npm run build
- echo "Deploying to production..."
secrets: [deploy_key]
-71
View File
@@ -1,71 +0,0 @@
when:
event: [pull_request]
branch: [main, develop]
steps:
install-dependencies:
image: node:18-alpine
commands:
- npm ci
lint:
image: node:18-alpine
commands:
- echo "=== Running ESLint ==="
- npm run lint
- echo "✅ ESLint check passed"
type-check:
image: node:18-alpine
commands:
- echo "=== Running TypeScript type check ==="
- npm run type-check
- echo "✅ TypeScript type check passed"
unit-tests:
image: node:18-alpine
commands:
- echo "=== Running unit tests with coverage ==="
- npm run test:unit -- --coverage --coverageReporters=json
- |
COVERAGE=$(cat coverage/coverage-summary.json | grep -o '"lines":{"pct":[0-9.]*' | grep -o '[0-9.]*$')
echo "Current coverage: $COVERAGE%"
if [ $(echo "$COVERAGE < 42" | bc -l) -eq 1 ]; then
echo "❌ Coverage $COVERAGE% is below threshold 42%"
exit 1
fi
echo "✅ Coverage $COVERAGE% meets threshold 42%"
e2e-tests:
image: node:18-alpine
commands:
- echo "=== Running E2E tests ==="
- npx playwright install --with-deps
- npm run test:e2e
- echo "✅ E2E tests passed"
security-check:
image: node:18-alpine
commands:
- echo "=== Running security audit ==="
- npm audit --audit-level=moderate
- echo "✅ Security audit passed"
performance-check:
image: node:18-alpine
commands:
- echo "=== Running performance checks ==="
- npm run audit:performance
- echo "✅ Performance audit passed"
quality-summary:
image: node:18-alpine
commands:
- echo "=== Quality Gate Summary ==="
- echo "✅ All quality checks passed"
- echo " - ESLint: PASSED"
- echo " - TypeScript: PASSED"
- echo " - Unit Tests: PASSED (Coverage ≥ 42%)"
- echo " - E2E Tests: PASSED"
- echo " - Security: PASSED"
- echo " - Performance: PASSED"
-50
View File
@@ -1,50 +0,0 @@
when:
event:
- push
- pull_request
pipeline:
test-tier-fast:
image: mcr.microsoft.com/playwright:v1.42.0-jammy
environment:
TEST_TIER: fast
CI: true
commands:
- cd e2e
- npm ci
- npx playwright install --with-deps
- npm run test:tier:fast
when:
branch:
- main
- develop
- feat-dynamic
test-tier-standard:
image: mcr.microsoft.com/playwright:v1.42.0-jammy
environment:
TEST_TIER: standard
CI: true
commands:
- cd e2e
- npm ci
- npx playwright install --with-deps
- npm run test:tier:standard
when:
branch:
- main
- develop
test-tier-deep:
image: mcr.microsoft.com/playwright:v1.42.0-jammy
environment:
TEST_TIER: deep
CI: true
commands:
- cd e2e
- npm ci
- npx playwright install --with-deps
- npm run test:tier:deep
when:
branch:
- main
-102
View File
@@ -1,102 +0,0 @@
when:
event:
- push
- pull_request
- tag
pipeline:
setup:
image: node:20-alpine
commands:
- node -v
- npm -v
- npm ci
- cd e2e && npm ci
test-tier-fast:
image: mcr.microsoft.com/playwright:v1.42.0-jammy
environment:
TEST_TIER: fast
CI: true
commands:
- cd e2e
- npx playwright install --with-deps
- npm run test:tier:fast
depends_on:
- setup
test-tier-standard:
image: mcr.microsoft.com/playwright:v1.42.0-jammy
environment:
TEST_TIER: standard
CI: true
commands:
- cd e2e
- npx playwright install --with-deps
- npm run test:tier:standard
depends_on:
- test-tier-fast
when:
status:
- success
test-tier-deep:
image: mcr.microsoft.com/playwright:v1.42.0-jammy
environment:
TEST_TIER: deep
CI: true
commands:
- cd e2e
- npx playwright install --with-deps
- npm run test:tier:deep
depends_on:
- test-tier-standard
when:
status:
- success
generate-report:
image: node:20-alpine
commands:
- cd e2e
- node scripts/generate-report.js
depends_on:
- test-tier-fast
- test-tier-standard
- test-tier-deep
upload-artifacts:
image: plugins/s3
settings:
bucket: test-reports
source: e2e/test-results/**
target: /${CI_REPO}/${CI_BUILD_NUMBER}/
path_style: true
depends_on:
- generate-report
when:
status:
- success
- failure
notify:
image: plugins/webhook
settings:
urls:
from_secret: webhook_url
content_type: application/json
template: |
{
"repo": "{{ repo.name }}",
"build": "{{ build.number }}",
"status": "{{ build.status }}",
"message": "{{ build.message }}",
"author": "{{ commit.author }}",
"link": "{{ build.link }}"
}
depends_on:
- upload-artifacts
when:
status:
- success
- failure
-125
View File
@@ -1,125 +0,0 @@
version: '3.8'
services:
# Nginx 负载均衡器
nginx:
image: nginx:alpine
container_name: novalon-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- app1
- app2
- app3
restart: unless-stopped
networks:
- novalon-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
# 应用实例 1
app1:
build:
context: .
dockerfile: Dockerfile
container_name: novalon-app-1
environment:
- NODE_ENV=production
- PORT=3001
env_file:
- .env.production
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
networks:
- novalon-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
# 应用实例 2
app2:
build:
context: .
dockerfile: Dockerfile
container_name: novalon-app-2
environment:
- NODE_ENV=production
- PORT=3002
env_file:
- .env.production
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
networks:
- novalon-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
# 应用实例 3
app3:
build:
context: .
dockerfile: Dockerfile
container_name: novalon-app-3
environment:
- NODE_ENV=production
- PORT=3003
env_file:
- .env.production
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
networks:
- novalon-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3003/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
networks:
novalon-network:
driver: bridge
-30
View File
@@ -1,30 +0,0 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: novalon-website
ports:
- "3000:3000"
environment:
- NODE_ENV=production
env_file:
- .env.production
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- novalon-network
networks:
novalon-network:
driver: bridge
+2 -29
View File
@@ -5,36 +5,9 @@ services:
image: novalon-website:1.0.0 image: novalon-website:1.0.0
container_name: novalon-website container_name: novalon-website
restart: unless-stopped restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=${DATABASE_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=${NEXTAUTH_URL}
- RESEND_API_KEY=${RESEND_API_KEY}
- OPS_ALERT_EMAIL=${OPS_ALERT_EMAIL:-ops@novalon.cn}
volumes:
- ./novalon-website/logs:/app/logs
networks:
- novalon-network
nginx:
image: nginx:alpine
container_name: novalon-nginx
restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
volumes: volumes:
- ./novalon-nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx-static.conf:/etc/nginx/nginx.conf:ro
- ./novalon-nginx/ssl:/etc/nginx/ssl:ro - ./ssl:/etc/nginx/ssl:ro
- ./novalon-nginx/logs:/var/log/nginx
- ./certbot:/var/www/certbot
networks:
- novalon-network
depends_on:
- novalon-website
networks:
novalon-network:
driver: bridge
-111
View File
@@ -1,111 +0,0 @@
# 管理员账号信息
## 默认管理员凭据
### 登录信息
- **邮箱**: `admin@novalon.cn`
- **密码**: `admin123456`
- **登录地址**: `http://localhost:3000/admin/login`
### 权限说明
- 管理员账号拥有完整的后台管理权限
- 可以管理内容、用户、配置和日志
- 可以修改其他用户信息(包括密码)
## 修改管理员密码
### 方法1:通过后台管理界面
1. 使用管理员账号登录后台
2. 进入"用户管理"页面
3. 找到管理员账号
4. 点击"编辑"按钮
5. 输入新密码并保存
### 方法2:通过API
```bash
# 使用管理员token修改密码
curl -X PUT http://localhost:3000/api/admin/users/[user-id] \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"password": "new_password_here"
}'
```
### 方法3:重新运行seed脚本
```bash
npm run db:seed
```
## 创建新管理员账号
### 通过API创建
```bash
curl -X POST http://localhost:3000/api/admin/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"email": "new_admin@example.com",
"password": "secure_password",
"name": "新管理员",
"isAdmin": true
}'
```
## 安全建议
### ⚠️ 重要提示
1. **立即修改默认密码** - 生产环境必须修改默认密码
2. **使用强密码** - 至少12位,包含大小写字母、数字和特殊字符
3. **定期更换密码** - 建议每3个月更换一次
4. **启用双因素认证** - 如果支持的话
5. **限制登录尝试** - 防止暴力破解
### 密码安全策略
- 最小长度:8个字符
- 必须包含:大小写字母、数字
- 建议包含:特殊字符
- 禁止使用:常见密码、个人信息
## 常见问题
### Q: 忘记管理员密码怎么办?
A: 重新运行seed脚本会重置为默认密码,但这会删除所有现有用户数据。
### Q: 如何创建多个管理员账号?
A: 通过后台管理界面或API创建新用户,并将 `isAdmin` 设置为 `true`
### Q: 登录时提示"认证配置错误"?
A: 检查NextAuth配置,确保环境变量正确设置。
### Q: 如何禁用某个管理员账号?
A: 通过API或数据库将该用户的 `isAdmin` 设置为 `false`
## 相关文件
- **Seed脚本**: `src/db/seed.ts`
- **用户Schema**: `src/db/schema.ts`
- **认证配置**: `src/lib/auth.ts`
- **登录页面**: `src/app/admin/login/page.tsx`
## 技术细节
### 密码加密
- 使用 `bcryptjs` 进行密码哈希
- 盐值轮数:10
- 算法:bcrypt
### 会话管理
- 使用NextAuth.js进行会话管理
- 会话存储:数据库
- 会话有效期:可配置
### 权限检查
- 基于 `isAdmin` 字段
- 通过 `checkIsAdmin()` 函数验证
- 支持细粒度权限控制
---
**最后更新**: 2026-03-13
**版本**: 1.0.0
-357
View File
@@ -1,357 +0,0 @@
# 🚀 CI/CD流水线快速设置指南
## 📋 前置条件
- ✅ Gitea已部署并配置 (https://git.f.novalon.cn)
- ✅ Woodpecker CI已部署并配置 (https://ci.f.novalon.cn)
- ✅ Docker Registry已部署并配置 (https://registry.f.novalon.cn)
- ✅ 服务器已配置SSH免密登录
## 🔧 快速配置步骤
### 步骤1: 配置Woodpecker CI密钥
#### 方式A: 使用自动化脚本 (推荐)
```bash
# 1. 上传脚本到服务器
scp scripts/setup-woodpecker-secrets.sh root@139.155.109.62:/home/novalon/scripts/
# 2. SSH到服务器
ssh root@139.155.109.62
# 3. 运行配置脚本
chmod +x /home/novalon/scripts/setup-woodpecker-secrets.sh
/home/novalon/scripts/setup-woodpecker-secrets.sh
```
#### 方式B: 手动配置
```bash
# 1. SSH到服务器
ssh root@139.155.109.62
# 2. 设置SSH私钥
woodpecker-cli secret add \
--repository novalon/novalon-website \
--name ssh_private_key \
--value @- <<< "$(cat ~/.ssh/id_rsa)"
# 3. 设置Webhook URL (可选)
woodpecker-cli secret add \
--repository novalon/novalon-website \
--name webhook_url \
--value @- <<< "YOUR_WEBHOOK_URL"
```
### 步骤2: 在Gitea中创建仓库
```bash
# 1. 访问 https://git.f.novalon.cn
# 2. 使用管理员账户登录
# 用户名: novalon-admin
# 密码: Novalon@Admin2026
# 3. 创建新仓库: novalon/novalon-website
# 4. 添加远程仓库
git remote add origin https://git.f.novalon.cn/novalon/novalon-website.git
```
### 步骤3: 在Woodpecker CI中激活仓库
```bash
# 1. 访问 https://ci.f.novalon.cn
# 2. 使用Gitea账户登录 (自动SSO)
# 3. 点击"Add Repository"
# 4. 选择 novalon/novalon-website 仓库
# 5. 点击"Activate"
```
### 步骤4: 配置服务器部署目录
```bash
# SSH到服务器
ssh root@139.155.109.62
# 创建部署目录
mkdir -p /home/novalon/docker-app/novalon-website
cd /home/novalon/docker-app/novalon-website
# 创建docker-compose.yml
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
novalon-website:
image: registry.f.novalon.cn/novalon-website:latest
container_name: novalon-website
restart: always
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=file:/app/data/local.db
volumes:
- ./data:/app/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- novalon-network
networks:
novalon-network:
external: true
EOF
# 创建数据目录
mkdir -p data
```
### 步骤5: 提交代码并触发CI/CD
```bash
# 在本地项目目录
cd /Users/zhangxiang/Codes/Gitee/home-page/novalon-website
# 添加所有文件
git add .
# 提交代码
git commit -m "feat: 配置全自动CI/CD工作流
- 添加完整的CI/CD流水线配置
- 配置代码质量检查(lint, type-check, security)
- 配置分层测试策略(fast, standard, deep)
- 配置Docker镜像构建和推送
- 配置自动部署到staging和production环境
- 配置健康检查和自动回滚
- 配置成功/失败通知
- 添加健康检查API端点
- 创建CI/CD配置文档"
# 推送到develop分支
git push -u origin develop
# 或者推送到main分支
git push -u origin main
```
## 📊 验证CI/CD流水线
### 1. 查看构建状态
```bash
# 访问Woodpecker CI
https://ci.f.novalon.cn/novalon/website
# 查看构建日志
# 每个步骤都有详细的日志输出
```
### 2. 验证部署
#### Staging环境 (develop分支)
```bash
# 检查容器状态
ssh root@139.155.109.62
docker ps | grep novalon-website
# 查看容器日志
docker logs novalon-website -f
# 健康检查
curl http://localhost:3000/api/health
```
#### Production环境 (main分支)
```bash
# 检查容器状态
ssh root@139.155.109.62
docker ps | grep novalon-website
# 查看容器日志
docker logs novalon-website -f
# 健康检查
curl https://novalon.cn/api/health
```
### 3. 验证通知
如果配置了Webhook,您应该会收到通知:
- ✅ 成功通知:绿色,包含构建信息
- ❌ 失败通知:红色,包含错误信息和构建链接
## 🔄 日常使用流程
### 开发新功能
```bash
# 1. 创建功能分支
git checkout -b feature/new-feature
# 2. 开发并提交
git add .
git commit -m "feat: 添加新功能"
# 3. 推送到远程
git push origin feature/new-feature
# 4. 在Gitea创建Pull Request
# 访问: https://git.f.novalon.cn/novalon/novalon-website/pulls
# 5. CI自动运行测试
# - Lint检查
# - 类型检查
# - 单元测试
# - Smoke测试
# 6. 代码审查通过后合并到develop
# - 自动触发完整测试
# - 自动构建Docker镜像
# - 自动部署到Staging环境
# 7. 测试通过后合并到main
# - 自动触发完整测试
# - 自动构建Docker镜像
# - 自动部署到Production环境
```
### 紧急修复
```bash
# 1. 创建hotfix分支
git checkout -b hotfix/critical-fix main
# 2. 修复并提交
git add .
git commit -m "fix: 修复关键问题"
# 3. 推送并创建PR
git push origin hotfix/critical-fix
# 4. 快速审查并合并到main
# - 自动部署到Production
# - 自动回滚机制保障
```
## 🛠️ 故障排查
### 构建失败
```bash
# 1. 查看Woodpecker CI日志
https://ci.f.novalon.cn/novalon/novalon-website
# 2. 常见原因
# - 依赖安装失败
# - TypeScript类型错误
# - 测试失败
# - Docker构建失败
# 3. 本地重现
npm ci
npm run lint
npm run type-check
npm run test:coverage:check
npm run build
```
### 部署失败
```bash
# 1. SSH到服务器
ssh root@139.155.109.62
# 2. 检查容器状态
docker ps -a | grep novalon-website
# 3. 查看容器日志
docker logs novalon-website
# 4. 检查健康状态
curl http://localhost:3000/api/health
# 5. 手动回滚
docker images | grep novalon-website
docker tag novalon-website:backup-<commit-sha> novalon-website:latest
cd /home/novalon/docker-app/novalon-website
docker-compose up -d --no-deps novalon-website
```
### 测试失败
```bash
# 1. 本地运行测试
npm run test:smoke # Smoke测试
npm run test:tier:standard # 标准测试
npm run test:tier:deep # 深度测试
# 2. 查看测试报告
npm run test:allure:open
# 3. 调试特定测试
npx playwright test --debug
```
## 📈 性能优化建议
### 1. 加速构建
```yaml
# 在.woodpecker.yml中添加缓存
cache:
- name: npm-cache
paths:
- node_modules
- e2e/node_modules
```
### 2. 并行执行
```yaml
# Woodpecker CI自动并行执行独立步骤
# 无需额外配置
```
### 3. 增量构建
```yaml
# 利用Docker层缓存
# 在Dockerfile中优化层顺序
```
## 🔐 安全最佳实践
### 1. 密钥管理
- ✅ 所有密钥存储在Woodpecker CI中
- ✅ 不在代码中硬编码
- ✅ 定期轮换密钥
### 2. 访问控制
- ✅ main分支受保护
- ✅ PR需要代码审查
- ✅ 部署需要审批
### 3. 安全扫描
- ✅ npm audit自动扫描
- ✅ 定期更新依赖
- ✅ 修复高危漏洞
## 📞 获取帮助
如有问题,请:
1. 查看 [CI/CD配置文档](./CICD_GUIDE.md)
2. 检查Woodpecker CI日志
3. 联系运维团队: ops@novalon.cn
---
**最后更新**: 2026-03-27
**版本**: 1.0.0
-164
View File
@@ -1,164 +0,0 @@
# 联系方式配置说明
## 📋 联系方式总结
### 实际联系方式(对外)
| 联系类型 | 邮箱 | 用途 |
|----------|------|------|
| **运维告警** | ops@novalon.cn | 监控告警、系统故障通知 |
| **业务咨询** | contact@novalon.cn | 用户联系、业务咨询、表单提交 |
### 系统内部配置(不对)
| 配置项 | 邮箱 | 用途 |
|--------|------|------|
| **管理员账号** | contact@novalon.cn | CMS后台登录、系统管理 |
| **公司邮箱** | contact@novalon.cn | 接收联系表单邮件 |
| **Resend API** | re_72PzbVrr_DiwTnB1ZDT7TyqCsgLoAfKfU | 邮件发送服务 |
## 📧 配置文件更新
### 1. 生产环境配置
文件: `.env.production`
```env
# 管理员账号(CMS后台登录)
ADMIN_EMAIL=contact@novalon.cn
# 公司邮箱(接收联系表单邮件)
COMPANY_EMAIL=contact@novalon.cn
# Resend API(邮件发送服务)
RESEND_API_KEY=re_72PzbVrr_DiwTnB1ZDT7TyqCsgLoAfKfU
```
### 2. 测试配置更新
文件: `e2e/global-setup.ts`
```typescript
// 测试登录账号
await page.locator('#email').fill('contact@novalon.cn');
```
### 3. 文档更新
所有文档已更新,移除了不存在的"技术支持"联系方式。
## 📊 监控和告警配置
### Sentry 错误监控
- **告警邮箱**: ops@novalon.cn
- **告警类型**: Critical Errors
- **响应时间**: 立即
### UptimeRobot 可用性监控
- **告警邮箱**: ops@novalon.cn
- **告警类型**: Down, Up, SSL Expiry
- **监控频率**: 5分钟
### Google Analytics 访问统计
- **测量 ID**: G-LGTLCR15KM
- **追踪类型**: 用户行为、页面浏览、事件追踪
## 📝 业务流程
### 用户联系流程
1. **用户访问联系页面**
- 填写联系表单
- 提交表单
2. **系统处理**
- 表单提交到 `/api/contact`
- 使用 Resend API 发送邮件
- 邮件发送到: contact@novalon.cn
3. **管理员处理**
- 管理员登录: contact@novalon.cn
- 查看收到的邮件
- 回复用户咨询
### 系统监控流程
1. **错误发生**
- Sentry 捕获错误
- 发送告警到: ops@novalon.cn
2. **网站故障**
- UptimeRobot 检测到故障
- 发送告警到: ops@novalon.cn
3. **运维响应**
- 运维团队收到告警
- 检查系统状态
- 修复问题
- 通知相关人员
## 🔐 安全考虑
### 账号分离
- **管理员账号**: contact@novalon.cn(仅用于系统管理)
- **运维告警**: ops@novalon.cn(用于系统监控)
- **业务咨询**: contact@novalon.cn(用于用户联系)
### 权限控制
- 管理员账号仅限内部使用
- 不对外公开管理员登录信息
- 定期更换密码
## 📞 联系方式使用指南
### 对于用户
- **业务咨询**: contact@novalon.cn
- 通过网站联系表单提交
- 邮件会在 24 小时内回复
### 对于运维团队
- **系统告警**: ops@novalon.cn
- 监控系统自动发送告警
- 需要立即响应和处理
### 对于管理员
- **系统登录**: contact@novalon.cn
- 访问 CMS 管理后台
- 管理网站内容和用户
## ✅ 配置检查清单
- [x] 生产环境配置更新
- [x] 测试配置更新
- [x] 文档联系方式统一
- [x] 监控告警配置
- [x] 邮件服务配置
- [x] 账号权限分离
## 📚 相关文档
- [轻量级监控配置](LIGHTWEIGHT_MONITORING.md)
- [生产部署指南](PRODUCTION_DEPLOYMENT_LIGHTWEIGHT.md)
- [Google Analytics 集成](GOOGLE_ANALYTICS_SETUP.md)
- [项目 README](../README.md)
## 🎯 总结
现在所有联系方式已经统一配置完成:
1. **对外联系**: contact@novalon.cn
- 用户联系表单
- 业务咨询
- 管理员登录
2. **运维告警**: ops@novalon.cn
- Sentry 错误告警
- UptimeRobot 可用性告警
- 系统故障通知
3. **邮件服务**: Resend API
- API Key: re_72PzbVrr_DiwTnB1ZDT7TyqCsgLoAfKfU
- 发件人: alertmanager@novalon.cn / contact@novalon.cn
- SMTP: smtp.resend.com:587
所有配置文件和文档都已经更新完成,联系方式现在统一且准确!
-512
View File
@@ -1,512 +0,0 @@
# API版本控制指南
## 概述
API版本控制是API设计的重要部分,它允许我们在不破坏现有客户端的情况下演进API。本项目采用URL路径版本控制策略。
## 版本控制策略
### URL路径版本控制
使用URL路径中的版本号来区分不同版本的API:
```
/api/v1/endpoint # 版本1
/api/v2/endpoint # 版本2
```
**优点**
- ✅ 清晰明了,易于理解
- ✅ 便于缓存和路由
- ✅ 支持多版本并存
- ✅ 客户端易于使用
**缺点**
- ❌ URL较长
- ❌ 需要维护多个版本
### 版本命名规则
- **主版本号**`v1`, `v2`, `v3`...
- **格式**`/api/v{major}/`
- **示例**
- `/api/v1/content`
- `/api/v1/admin/users`
## 目录结构
### 当前结构(向后兼容)
```
src/app/api/
├── admin/
│ ├── config/
│ ├── content/
│ ├── upload/
│ └── users/
├── auth/
├── config/
├── content/
├── docs/
└── health/
```
### 版本化结构(推荐)
```
src/app/api/
├── v1/ # 版本1 API
│ ├── admin/
│ │ ├── config/
│ │ ├── content/
│ │ ├── upload/
│ │ └── users/
│ ├── auth/
│ ├── config/
│ ├── content/
│ └── health/
├── admin/ # 向后兼容(重定向到v1)
├── auth/
├── config/
├── content/
├── docs/ # OpenAPI文档(无版本)
└── health/
```
## 实施步骤
### 步骤1:创建版本化API
#### 创建v1目录
```bash
mkdir -p src/app/api/v1
```
#### 迁移现有API
将现有API复制到v1目录:
```bash
# 复制admin API
cp -r src/app/api/admin src/app/api/v1/
# 复制其他API
cp -r src/app/api/auth src/app/api/v1/
cp -r src/app/api/config src/app/api/v1/
cp -r src/app/api/content src/app/api/v1/
cp -r src/app/api/health src/app/api/v1/
```
### 步骤2:更新API路由
#### 更新v1 API路由
在v1版本的API中,更新路由路径:
```typescript
// src/app/api/v1/admin/content/route.ts
/**
* @openapi
* /api/v1/admin/content:
* get:
* tags:
* - Admin
* - Content
* summary: 获取内容列表 (v1)
* description: 管理员获取内容列表,支持分页、筛选和搜索
* operationId: getAdminContentV1
* ...
*/
export async function GET(request: NextRequest) {
// 实现代码
}
```
### 步骤3:创建向后兼容层
#### 创建重定向中间件
```typescript
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 如果访问旧API路径,重定向到v1版本
const legacyApiPaths = [
'/api/admin',
'/api/auth',
'/api/config',
'/api/content',
'/api/health',
];
const isLegacyApi = legacyApiPaths.some(path =>
pathname.startsWith(path) && !pathname.includes('/v1/')
);
if (isLegacyApi) {
const url = request.nextUrl.clone();
url.pathname = pathname.replace('/api/', '/api/v1/');
// 返回重定向响应(可选:也可以内部重写)
// return NextResponse.redirect(url);
// 或者内部重写(URL不变,但使用新路径)
return NextResponse.rewrite(url);
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};
```
### 步骤4:更新客户端代码
#### 更新API客户端
```typescript
// src/lib/api-client.ts
const API_VERSION = 'v1';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '';
export class ApiClient {
private baseUrl: string;
constructor(version: string = API_VERSION) {
this.baseUrl = `${API_BASE_URL}/api/${version}`;
}
async get(endpoint: string, options?: RequestInit) {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
method: 'GET',
});
return response.json();
}
async post(endpoint: string, data: any, options?: RequestInit) {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
body: JSON.stringify(data),
});
return response.json();
}
}
// 使用示例
const apiClient = new ApiClient('v1');
const content = await apiClient.get('/admin/content');
```
## 版本生命周期
### 版本状态
| 状态 | 描述 | 持续时间 |
|------|------|----------|
| **Current** | 当前推荐版本 | 无限期 |
| **Supported** | 仍受支持,但不推荐新功能 | 6-12个月 |
| **Deprecated** | 即将废弃,计划移除 | 3-6个月 |
| **Sunset** | 已移除,不再可用 | - |
### 版本废弃流程
1. **公告**:提前6个月通知废弃计划
2. **警告**:在响应头中添加`Deprecation``Sunset`
3. **迁移期**:提供迁移指南和工具
4. **移除**:在预定日期移除旧版本
#### 添加废弃头
```typescript
// src/app/api/v1/admin/content/route.ts
export async function GET(request: NextRequest) {
const response = NextResponse.json(data);
// 添加废弃警告
response.headers.set('Deprecation', 'true');
response.headers.set('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
response.headers.set('Link', '</api/v2/admin/content>; rel="successor-version"');
return response;
}
```
## 版本间差异处理
### 向后兼容的变更
以下变更不需要增加主版本号:
- ✅ 添加新的可选参数
- ✅ 添加新的响应字段
- ✅ 添加新的端点
- ✅ 修复bug
### 需要新版本的变更
以下变更需要增加主版本号:
- ❌ 移除或重命名端点
- ❌ 移除或重命名请求/响应字段
- ❌ 修改必填参数
- ❌ 修改认证方式
- ❌ 修改错误响应格式
## 多版本并存示例
### 场景:修改内容API响应格式
#### v1版本(旧)
```typescript
// src/app/api/v1/admin/content/route.ts
/**
* @openapi
* /api/v1/admin/content:
* get:
* responses:
* 200:
* content:
* application/json:
* schema:
* type: object
* properties:
* items:
* type: array
* pagination:
* type: object
*/
export async function GET(request: NextRequest) {
const items = await db.select().from(content);
return NextResponse.json({
items,
pagination: { page: 1, limit: 20, total: items.length },
});
}
```
#### v2版本(新)
```typescript
// src/app/api/v2/admin/content/route.ts
/**
* @openapi
* /api/v2/admin/content:
* get:
* responses:
* 200:
* content:
* application/json:
* schema:
* type: object
* properties:
* data:
* type: array
* meta:
* type: object
*/
export async function GET(request: NextRequest) {
const items = await db.select().from(content);
return NextResponse.json({
data: items, // 改名:items -> data
meta: { // 改名:pagination -> meta
page: 1,
limit: 20,
total: items.length,
hasNext: items.length === 20,
},
});
}
```
## 测试策略
### 版本兼容性测试
```typescript
// src/app/api/__tests__/version-compatibility.test.ts
import { describe, it, expect } from '@jest/globals';
describe('API Version Compatibility', () => {
it('should return same data structure in v1 and v2', async () => {
const v1Response = await fetch('/api/v1/admin/content');
const v2Response = await fetch('/api/v2/admin/content');
const v1Data = await v1Response.json();
const v2Data = await v2Response.json();
// 验证数据一致性
expect(v1Data.items.length).toBe(v2Data.data.length);
expect(v1Data.pagination.total).toBe(v2Data.meta.total);
});
it('should redirect legacy API to v1', async () => {
const response = await fetch('/api/admin/content');
expect(response.url).toContain('/api/v1/admin/content');
});
});
```
## 文档更新
### 更新OpenAPI文档
```typescript
// src/app/api/docs/route.ts
const options = {
definition: {
openapi: '3.0.0',
info: {
title: '睿新致远 API',
version: '1.0.0',
description: `
## API版本
当前支持以下版本:
- **v1** (Current): 当前推荐版本
- **v2** (Beta): 测试版本,包含新功能
### 版本状态
| 版本 | 状态 | 发布日期 | 废弃日期 |
|------|------|----------|----------|
| v1 | Current | 2024-01-01 | - |
| v2 | Beta | 2024-06-01 | - |
`,
},
servers: [
{
url: '/api/v1',
description: 'API v1 (Current)',
},
{
url: '/api/v2',
description: 'API v2 (Beta)',
},
],
},
};
```
## 最佳实践
### ✅ 推荐做法
1. **提前规划版本策略**
- 在API设计初期就考虑版本控制
- 为未来变更预留空间
2. **保持向后兼容**
- 尽可能保持旧版本可用
- 提供充足的迁移时间
3. **清晰的文档**
- 明确标注版本差异
- 提供迁移指南
4. **版本废弃通知**
- 提前通知用户
- 使用HTTP头传递废弃信息
### ❌ 避免的做法
1. **不要频繁变更主版本**
- 主版本变更应该谨慎
- 考虑向后兼容的替代方案
2. **不要突然移除旧版本**
- 给用户足够的迁移时间
- 提供迁移工具和文档
3. **不要忽略版本测试**
- 确保多版本并存时功能正常
- 测试版本兼容性
## 监控和分析
### 版本使用统计
```typescript
// src/lib/api-analytics.ts
export async function trackApiVersion(request: NextRequest) {
const { pathname } = request.nextUrl;
const version = pathname.match(/\/api\/v(\d+)\//)?.[1] || 'legacy';
// 发送到分析服务
await analytics.track('api_request', {
version,
endpoint: pathname,
method: request.method,
timestamp: new Date().toISOString(),
});
}
```
### 版本使用报告
定期生成版本使用报告:
```markdown
## API版本使用报告(2024年6月)
### 请求分布
| 版本 | 请求数 | 占比 | 趋势 |
|------|--------|------|------|
| v1 | 150,000 | 75% | ↓ |
| v2 | 50,000 | 25% | ↑ |
### 废弃版本使用
| 版本 | 请求数 | 废弃日期 | 建议 |
|------|--------|----------|------|
| legacy | 1,000 | 2024-12-31 | 尽快迁移到v1 |
```
## 参考资源
- [API版本控制最佳实践](https://www.postman.com/api-platform/api-versioning/)
- [REST API版本控制](https://restfulapi.net/versioning/)
- [语义化版本控制](https://semver.org/)
- [HTTP废弃头规范](https://datatracker.ietf.org/doc/html/rfc8594)
## 总结
API版本控制已集成到项目中,提供了:
**清晰的版本管理**
**向后兼容支持**
**平滑的版本迁移**
**版本使用监控**
**完善的文档支持**
通过合理的版本控制策略,可以:
- 保护现有客户端
- 安全地演进API
- 提供良好的开发者体验
- 维护API的长期健康
-382
View File
@@ -1,382 +0,0 @@
# API 文档
## API 概述
项目使用 Next.js API Routes 实现服务端接口,主要用于处理联系表单提交等后端逻辑。
## 基础信息
- **基础 URL**: `/api`
- **内容类型**: `application/json`
- **字符编码**: `UTF-8`
## 接口列表
### 1. 联系表单 API
#### 提交联系表单
**接口地址**
```
POST /api/contact
```
**请求头**
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| Content-Type | string | 是 | application/json |
**请求参数**
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| name | string | 是 | 联系人姓名 |
| email | string | 是 | 联系人邮箱 |
| phone | string | 否 | 联系人电话 |
| subject | string | 是 | 咨询主题 |
| message | string | 是 | 咨询内容 |
| website | string | 否 | 蜜罐字段(用于反垃圾) |
| submitTime | string | 否 | 表单提交时间戳 |
| mathHash | string | 否 | 数学验证码哈希 |
| mathTimestamp | string | 否 | 数学验证码时间戳 |
| mathAnswer | number | 否 | 数学验证码答案 |
**请求示例**
```json
{
"name": "张三",
"email": "zhangsan@example.com",
"phone": "13800138000",
"subject": "产品咨询",
"message": "我想了解贵公司的软件开发服务。",
"submitTime": "1709827200000",
"mathHash": "MTAwLTE3MDk4MjcxMDAwMDA=",
"mathTimestamp": "1709827100000",
"mathAnswer": 100
}
```
**响应参数**
| 参数 | 类型 | 描述 |
|------|------|------|
| success | boolean | 请求是否成功 |
| message | string | 成功消息(成功时) |
| error | string | 错误消息(失败时) |
**成功响应**
```json
{
"success": true,
"message": "消息已发送,我们会尽快与您联系!"
}
```
**错误响应**
```json
{
"success": false,
"error": "请填写必填字段"
}
```
**错误码说明**
| HTTP 状态码 | 错误信息 | 描述 |
|-------------|----------|------|
| 200 | - | 蜜罐字段被填充(静默拒绝) |
| 400 | 请填写必填字段 | 缺少必填字段 |
| 400 | 请输入有效的邮箱地址 | 邮箱格式不正确 |
| 400 | 提交过快,请稍后再试 | 提交时间间隔过短 |
| 400 | 验证码错误,请重新计算 | 数学验证码错误 |
| 500 | 发送失败,请稍后重试 | 邮件发送失败 |
## 安全机制
### 1. 蜜罐字段 (Honeypot)
通过隐藏字段 `website` 检测机器人提交:
```tsx
// 前端隐藏字段
<input name="website" className="hidden" tabIndex={-1} autoComplete="off" />
// 后端检测
if (website) {
// 检测到机器人,静默返回成功
return NextResponse.json({ success: true });
}
```
### 2. 提交时间验证
验证表单提交时间间隔,防止快速自动提交:
```tsx
if (submitTime) {
const timeDiff = Date.now() - parseInt(submitTime);
if (timeDiff < 2000) {
// 提交过快,拒绝请求
return NextResponse.json({ success: false, error: '提交过快' });
}
}
```
### 3. 数学验证码
使用数学运算验证码防止机器人:
```tsx
// 前端生成验证码
const num1 = Math.floor(Math.random() * 10) + 1;
const num2 = Math.floor(Math.random() * 10) + 1;
const answer = num1 + num2;
const timestamp = Date.now();
const hash = btoa(`${answer}-${timestamp}`);
// 后端验证
const expectedHash = btoa(`${mathAnswer}-${mathTimestamp}`);
if (expectedHash !== mathHash) {
return NextResponse.json({ success: false, error: '验证码错误' });
}
```
### 4. 邮箱验证
验证邮箱格式:
```tsx
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json({ success: false, error: '请输入有效的邮箱地址' });
}
```
### 5. XSS 防护
使用 DOMPurify 清理用户输入:
```tsx
import DOMPurify from 'dompurify';
const sanitizedName = DOMPurify.sanitize(name);
```
## 邮件服务
### Resend 配置
项目使用 Resend 服务发送邮件:
```env
RESEND_API_KEY=re_xxxxx
COMPANY_EMAIL=contact@novalon.cn
```
### 邮件模板
邮件使用 HTML 模板,包含:
- 公司品牌头部
- 表单数据展示
- 时间戳信息
- 响应式设计
**邮件模板结构**
```html
<html>
<head>
<style>
/* 响应式样式 */
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- 公司品牌 -->
</div>
<div class="content">
<!-- 表单数据 -->
</div>
<div class="footer">
<!-- 页脚信息 -->
</div>
</div>
</body>
</html>
```
## Server Actions
### 联系表单 Server Action
位于 `src/app/(marketing)/contact/actions.ts`
```tsx
'use server';
export async function submitContactForm(formData: FormData) {
// 服务端表单处理逻辑
}
```
**优势:**
- 无需创建 API 路由
- 自动 CSRF 保护
- 类型安全
- 更简洁的错误处理
## 环境变量
### 必需配置
```env
# Resend API 密钥
RESEND_API_KEY=re_xxxxx
# 公司邮箱
COMPANY_EMAIL=contact@novalon.cn
```
### 可选配置
```env
# 环境
NODE_ENV=production
# 站点 URL
NEXT_PUBLIC_SITE_URL=https://www.novalon.cn
```
## 请求限制
### 速率限制
建议在生产环境配置速率限制:
```typescript
// 示例:使用 Upstash Redis
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'),
});
const { success } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json({ error: '请求过于频繁' }, { status: 429 });
}
```
## CORS 配置
API 路由默认不允许跨域请求。如需配置 CORS:
```typescript
export async function POST(request: NextRequest) {
const response = NextResponse.json({ success: true });
response.headers.set('Access-Control-Allow-Origin', 'https://www.novalon.cn');
response.headers.set('Access-Control-Allow-Methods', 'POST');
return response;
}
```
## 错误处理
### 统一错误响应格式
```typescript
interface ApiResponse {
success: boolean;
message?: string;
error?: string;
data?: unknown;
}
```
### 错误日志
```typescript
try {
await resend.emails.send({ ... });
} catch (error) {
console.error('邮件发送失败:', error);
return NextResponse.json(
{ success: false, error: '发送失败,请稍后重试' },
{ status: 500 }
);
}
```
## 测试接口
### 使用 cURL
```bash
curl -X POST http://localhost:3000/api/contact \
-H "Content-Type: application/json" \
-d '{
"name": "测试用户",
"email": "test@example.com",
"subject": "测试主题",
"message": "测试消息内容"
}'
```
### 使用 Playwright
```typescript
// e2e/src/tests/api/contact.spec.ts
test('提交联系表单', async ({ request }) => {
const response = await request.post('/api/contact', {
data: {
name: '测试用户',
email: 'test@example.com',
subject: '测试主题',
message: '测试消息内容',
},
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data.success).toBe(true);
});
```
## API 版本控制
当前项目 API 不包含版本号。如需版本控制,建议:
```
/api/v1/contact
/api/v2/contact
```
## 监控与日志
### 推荐集成
- **Sentry** - 错误监控
- **LogRocket** - 会话回放
- **Vercel Analytics** - 性能监控
### 日志格式
```typescript
console.log({
timestamp: new Date().toISOString(),
level: 'info',
message: '联系表单提交',
data: { email, subject },
});
```
-104
View File
@@ -1,104 +0,0 @@
# CI/CD中的质量门禁
## 概述
质量门禁不仅在本地的Git hooks中运行,也在CI/CD流水线中执行,确保所有合并到主分支的代码都符合质量标准。
## Woodpecker CI配置
### 质量检查步骤
`.woodpecker.yml` 中添加质量检查步骤:
```yaml
pipeline:
quality-check:
image: node:18-alpine
environment:
NODE_ENV: test
commands:
- npm ci
- npm run lint
- npm run type-check
- npm run test:coverage
when:
event:
- push
- pull_request
coverage-report:
image: node:18-alpine
environment:
NODE_ENV: test
commands:
- npm ci
- npm run test:coverage
# 上传覆盖率报告到Codecov或其他服务
secrets: [codecov_token]
when:
event:
- pull_request
```
### 质量门禁规则
CI/CD中的质量门禁规则:
1. **代码检查**: ESLint必须通过,无错误
2. **类型检查**: TypeScript编译必须成功
3. **测试通过**: 所有测试必须通过
4. **覆盖率达标**: 代码覆盖率必须≥70%
### 失败处理
如果质量检查失败:
1. CI/CD流水线失败
2. 阻止合并到主分支
3. 发送通知给开发者
4. 显示详细的错误信息
## 本地开发 vs CI/CD
### 本地开发
- **优点**: 快速反馈,立即发现问题
- **缺点**: 可能被绕过(--no-verify
### CI/CD
- **优点**: 强制执行,无法绕过
- **缺点**: 反馈延迟,需要等待CI运行
### 最佳实践
1. **本地优先**: 在本地运行质量检查,确保通过后再推送
2. **CI兜底**: CI/CD作为最后一道防线,确保质量
3. **快速反馈**: CI/CD配置为快速失败,尽早发现问题
## 持续改进
### 监控质量指标
定期监控以下指标:
- 代码覆盖率趋势
- ESLint错误数量
- TypeScript错误数量
- 测试失败率
- CI/CD通过率
### 优化质量门禁
根据监控数据优化质量门禁:
1. 调整覆盖率阈值
2. 添加新的质量检查
3. 优化检查性能
4. 改进错误提示
## 参考资料
- [Woodpecker CI文档](https://woodpecker-ci.org/)
- [Codecov文档](https://docs.codecov.com/)
- [GitHub Actions文档](https://docs.github.com/en/actions)
-466
View File
@@ -1,466 +0,0 @@
# OpenAPI文档使用指南
## 概述
OpenAPI(原名Swagger)是一个用于描述、生成、消费和可视化RESTful Web服务的规范。本项目已集成OpenAPI文档,提供交互式API文档界面。
## 访问文档
### 开发环境
启动开发服务器后,访问:
```
http://localhost:3000/api-docs
```
### 生产环境
部署后访问:
```
https://your-domain.com/api-docs
```
## 文档结构
### API端点
| 端点 | 方法 | 描述 | 认证 |
|------|------|------|------|
| `/api/health` | GET | 健康检查 | 无 |
| `/api/admin/content` | GET | 获取内容列表 | 需要管理员权限 |
| `/api/admin/content` | POST | 创建新内容 | 需要管理员权限 |
### 数据模型
#### Content(内容)
```typescript
interface Content {
id: number;
type: 'news' | 'case' | 'product' | 'service';
title: string;
content: string;
status: 'draft' | 'published' | 'archived';
createdAt: Date;
updatedAt: Date;
}
```
#### User(用户)
```typescript
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
}
```
#### Config(配置)
```typescript
interface Config {
key: string;
value: string;
description: string;
}
```
## 使用Swagger UI
### 浏览API
1. 访问 `/api-docs`
2. 点击任意API端点展开详情
3. 查看请求参数、响应格式和示例
### 测试API
#### 无需认证的API
1. 点击"Try it out"按钮
2. 填写必要参数
3. 点击"Execute"执行请求
4. 查看响应结果
示例:健康检查API
```bash
curl -X GET "http://localhost:3000/api/health" -H "accept: application/json"
```
#### 需要认证的API
1. 先登录获取访问令牌
2. 点击页面右上角的"Authorize"按钮
3. 输入Bearer令牌:`Bearer your-access-token`
4. 点击"Authorize"确认
5. 现在可以测试需要认证的API
示例:获取内容列表
```bash
curl -X GET "http://localhost:3000/api/admin/content?page=1&limit=20" \
-H "accept: application/json" \
-H "Authorization: Bearer your-access-token"
```
## 为API添加文档
### 步骤1:添加JSDoc注释
在API路由文件中添加JSDoc注释:
```typescript
/**
* @openapi
* /api/your-endpoint:
* get:
* tags:
* - YourTag
* summary: 简短描述
* description: 详细描述
* operationId: getYourData
* parameters:
* - name: id
* in: path
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: 成功响应
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
*/
export async function GET(request: NextRequest) {
// API实现
}
```
### 步骤2:定义请求体
对于POST/PUT请求:
```typescript
/**
* @openapi
* /api/your-endpoint:
* post:
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* - email
* properties:
* name:
* type: string
* email:
* type: string
* format: email
*/
```
### 步骤3:引用共享Schema
使用`$ref`引用共享数据模型:
```typescript
/**
* @openapi
* /api/admin/content:
* get:
* responses:
* 200:
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Content'
*/
```
## OpenAPI规范文件
### 获取规范文件
访问以下端点获取原始OpenAPI规范:
```
GET /api/docs
```
### 使用规范文件
1. **导入到Postman**
- 打开Postman
- 点击"Import"
- 选择"Link"
- 输入:`http://localhost:3000/api/docs`
- 点击"Import"
2. **生成客户端代码**
- 使用OpenAPI Generator
- 支持多种语言:TypeScript, Python, Java等
3. **API测试**
- 导入到测试工具
- 自动生成测试用例
## 最佳实践
### ✅ 推荐做法
1. **完整的描述**
- 提供清晰的summary和description
- 说明参数的作用和限制
- 提供示例值
2. **准确的类型定义**
- 使用正确的数据类型
- 标注必填字段
- 定义枚举值
3. **完整的响应定义**
- 定义所有可能的响应状态码
- 提供错误响应格式
- 包含示例数据
4. **合理的标签分组**
- 按功能模块分组
- 使用一致的命名
- 避免过多标签
### ❌ 避免的做法
1. **不要省略错误响应**
```typescript
// ❌ 不好
responses:
* 200:
* description: 成功
// ✅ 好
responses:
* 200:
* description: 成功
* 400:
* description: 参数错误
* 401:
* description: 未授权
* 500:
* description: 服务器错误
```
2. **不要使用模糊的描述**
```typescript
// ❌ 不好
summary: 获取数据
// ✅ 好
summary: 获取内容列表
description: 管理员获取内容列表,支持分页、筛选和搜索
```
3. **不要忽略认证要求**
```typescript
// ✅ 始终标注认证要求
security:
* - bearerAuth: []
```
## 高级功能
### 添加示例
```typescript
/**
* @openapi
* /api/admin/content:
* post:
* requestBody:
* content:
* application/json:
* examples:
* newsExample:
* summary: 新闻示例
* value:
* type: news
* title: 新闻标题
* content: 新闻内容
*/
```
### 添加标签描述
在`/api/docs/route.ts`中:
```typescript
tags: [
{
name: 'Content',
description: '内容管理相关接口',
},
{
name: 'Admin',
description: '管理员相关接口',
},
],
```
### 添加服务器配置
```typescript
servers: [
{
url: 'http://localhost:3000',
description: '开发服务器',
},
{
url: 'https://api.novalon.cn',
description: '生产服务器',
},
],
```
## CI/CD集成
### 验证OpenAPI规范
```bash
# 安装验证工具
npm install -g @redocly/cli
# 验证规范
redocly lint http://localhost:3000/api/docs
```
### 生成文档
```bash
# 安装Redoc
npm install -g redoc
# 生成静态HTML文档
redocly build-docs http://localhost:3000/api/docs -o api-docs.html
```
### GitHub Actions示例
```yaml
name: API Documentation
on:
push:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Start server
run: npm run dev &
env:
CI: true
- name: Wait for server
run: npx wait-on http://localhost:3000/api/docs
- name: Validate OpenAPI spec
run: npx @redocly/cli lint http://localhost:3000/api/docs
```
## 故障排查
### 问题1:文档页面无法加载
**症状**:访问`/api-docs`显示加载中或空白页
**解决方案**
```bash
# 检查API端点是否正常
curl http://localhost:3000/api/docs
# 检查浏览器控制台错误
# 打开开发者工具查看Network和Console标签
```
### 问题2API不显示在文档中
**症状**:某些API端点未出现在文档中
**解决方案**
```typescript
// 检查JSDoc注释格式
// 确保使用 @openapi 标签
/**
* @openapi // ← 必须是这个标签
* /api/your-endpoint:
* get:
*/
// 检查apis路径配置
apis: [
'./src/app/api/**/route.ts', // ← 确保路径正确
],
```
### 问题3:认证失败
**症状**:使用Authorize按钮后仍然无法访问需要认证的API
**解决方案**
```bash
# 确保令牌格式正确
Bearer your-access-token # ← 注意Bearer前缀
# 检查令牌是否有效
curl -H "Authorization: Bearer your-token" http://localhost:3000/api/admin/content
```
## 参考资源
- [OpenAPI规范](https://swagger.io/specification/)
- [Swagger UI文档](https://swagger.io/tools/swagger-ui/)
- [swagger-jsdoc文档](https://github.com/surnet/swagger-jsdoc)
- [OpenAPI Generator](https://openapi-generator.tech/)
- [Redoc文档](https://redocly.com/docs/redoc/)
## 总结
OpenAPI文档已完全集成到项目中,提供了:
**交互式API文档**
**自动生成规范**
**在线测试功能**
**认证支持**
**多格式导出**
通过合理使用OpenAPI文档,可以:
- 提升API可用性
- 减少沟通成本
- 自动化API测试
- 生成客户端SDK
-450
View File
@@ -1,450 +0,0 @@
# 分层测试最佳实践
## 概述
本文档提供分层测试系统的最佳实践,帮助团队构建高效、可靠的测试体系。
## 核心原则
### 1. 质量左移
在需求分析和设计阶段就考虑测试策略,而不是在开发完成后才补充测试。
**实践:**
- 在需求文档中明确测试要求
- 在设计评审中讨论可测试性
- 开发过程中同步编写测试
### 2. 测试金字塔
遵循测试金字塔原则,保持合理的测试比例:
```
/\
/ \ E2E测试 (10%)
/____\
/ \ 集成测试 (30%)
/________\
/ \ 单元测试 (60%)
/____________\
```
**实践:**
- 单元测试:快速、独立、覆盖核心逻辑
- 集成测试:验证组件间交互
- E2E测试:验证关键用户流程
### 3. 快速反馈
确保测试能够快速提供反馈,帮助开发人员快速定位问题。
**实践:**
- 快速层测试在5分钟内完成
- 标准层测试在30分钟内完成
- 深度层测试可以接受较长执行时间
## 测试分层策略
### 快速层设计
**目标:** 在5分钟内验证核心功能
**包含内容:**
1. **冒烟测试** (Smoke Tests)
- 验证应用能够正常启动
- 验证关键页面能够加载
- 验证核心API能够响应
2. **API测试**
- 验证API端点的正确性
- 验证数据格式和结构
- 验证错误处理
3. **基础功能测试**
- 验证用户登录/登出
- 验证基本CRUD操作
- 验证权限控制
**最佳实践:**
- 每个测试文件不超过3个测试用例
- 每个测试用例执行时间不超过10秒
- 使用mock数据替代真实数据库
**示例:**
```typescript
test.describe('用户认证快速测试 @smoke @critical', () => {
test('应该能够成功登录', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'admin@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-btn"]');
await expect(page).toHaveURL('/dashboard');
});
test('应该能够成功登出', async ({ page }) => {
await page.goto('/dashboard');
await page.click('[data-testid="logout-btn"]');
await expect(page).toHaveURL('/login');
});
});
```
### 标准层设计
**目标:** 在30分钟内验证大部分功能
**包含内容:**
1. **功能测试** (Functional Tests)
- 验证完整的用户流程
- 验证表单验证
- 验证业务规则
2. **响应式测试** (Responsive Tests)
- 验证不同屏幕尺寸下的布局
- 验证移动端和桌面端的交互
- 验证触摸和鼠标事件
3. **管理后台测试** (Admin Tests)
- 验证内容管理功能
- 验证用户管理功能
- 验证系统配置
**最佳实践:**
- 每个测试文件包含5-10个测试用例
- 每个测试用例执行时间不超过30秒
- 使用Page Object Model模式
**示例:**
```typescript
test.describe('新闻管理功能测试 @admin @regression', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin/news');
});
test('应该能够创建新闻', async ({ page }) => {
await page.click('[data-testid="create-news-btn"]');
await page.fill('[data-testid="news-title"]', '测试新闻');
await page.fill('[data-testid="news-content"]', '新闻内容');
await page.click('[data-testid="save-btn"]');
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});
test('应该能够编辑新闻', async ({ page }) => {
await page.click('[data-testid="edit-news-1"]');
await page.fill('[data-testid="news-title"]', '更新后的标题');
await page.click('[data-testid="save-btn"]');
await expect(page.locator('[data-testid="news-title"]')).toHaveValue('更新后的标题');
});
test('应该能够删除新闻', async ({ page }) => {
await page.click('[data-testid="delete-news-1"]');
await page.click('[data-testid="confirm-btn"]');
await expect(page.locator('[data-testid="news-1"]')).not.toBeVisible();
});
});
```
### 深度层设计
**目标:** 在发布前进行全面验证
**包含内容:**
1. **视觉回归测试** (Visual Regression Tests)
- 验证UI与设计稿一致
- 验证样式和布局
- 验证跨浏览器一致性
2. **性能测试** (Performance Tests)
- 验证页面加载时间
- 验证API响应时间
- 验证资源加载优化
3. **完整回归测试** (Full Regression Tests)
- 验证所有已知功能
- 验证边界情况
- 验证错误处理
**最佳实践:**
- 使用截图对比工具
- 使用性能监控工具
- 在夜间或周末执行
**示例:**
```typescript
test.describe('首页视觉回归测试 @visual @regression', () => {
test('桌面端首页应该与基准一致', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-desktop.png');
});
test('移动端首页应该与基准一致', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-mobile.png');
});
});
```
## 测试标记策略
### 标记分类
#### 优先级标记
- `@critical` - 关键测试,必须通过
- `@high` - 高优先级测试
- `@medium` - 中等优先级测试
- `@low` - 低优先级测试
#### 类型标记
- `@smoke` - 冒烟测试
- `@regression` - 回归测试
- `@functional` - 功能测试
- `@api` - API测试
- `@visual` - 视觉测试
- `@performance` - 性能测试
#### 平台标记
- `@desktop` - 桌面端测试
- `@mobile` - 移动端测试
- `@tablet` - 平板端测试
#### 功能标记
- `@auth` - 认证相关测试
- `@admin` - 管理后台测试
- `@content` - 内容管理测试
- `@user` - 用户功能测试
### 标记使用规则
1. **每个测试套件至少有一个标记**
2. **关键测试必须标记为 `@critical`**
3. **冒烟测试必须标记为 `@smoke`**
4. **回归测试必须标记为 `@regression`**
## 性能优化
### 减少测试执行时间
#### 1. 并行执行
```typescript
// playwright.config.tiered.ts
{
fullyParallel: true,
workers: '75%',
}
```
#### 2. 减少等待时间
```typescript
// 不推荐
await page.waitForTimeout(5000);
// 推荐
await page.waitForSelector('[data-testid="result"]', { timeout: 5000 });
```
#### 3. 使用快速选择器
```typescript
// 不推荐
await page.click('div > div > button');
// 推荐
await page.click('[data-testid="submit-btn"]');
```
#### 4. 复用浏览器上下文
```typescript
test.describe('用户管理测试', () => {
test.use({ storageState: '.auth/admin.json' });
test('应该能够创建用户', async ({ page }) => {
// 测试逻辑
});
});
```
### 优化测试数据
#### 1. 使用固定数据
```typescript
const testUser = {
email: 'test@example.com',
password: 'password123',
};
test('应该能够登录', async ({ page }) => {
await page.fill('[data-testid="email"]', testUser.email);
await page.fill('[data-testid="password"]', testUser.password);
});
```
#### 2. 使用测试数据库
```typescript
test.beforeEach(async () => {
await db.reset();
await db.seed(testData);
});
```
#### 3. 清理测试数据
```typescript
test.afterEach(async () => {
await db.cleanup();
});
```
## 可维护性
### Page Object Model
使用Page Object Model模式提高测试的可维护性:
```typescript
// pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
async login(email: string, password: string) {
await this.page.fill('[data-testid="email"]', email);
await this.page.fill('[data-testid="password"]', password);
await this.page.click('[data-testid="login-btn"]');
}
async expectLoggedIn() {
await expect(this.page).toHaveURL('/dashboard');
}
}
// tests/login.spec.ts
test('应该能够登录', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('admin@example.com', 'password123');
await loginPage.expectLoggedIn();
});
```
### 测试数据管理
使用专门的测试数据管理器:
```typescript
// utils/test-data.ts
export const TestData = {
users: {
admin: {
email: 'admin@example.com',
password: 'password123',
role: 'admin',
},
user: {
email: 'user@example.com',
password: 'password123',
role: 'user',
},
},
news: {
valid: {
title: '测试新闻',
content: '新闻内容',
},
invalid: {
title: '',
content: '',
},
},
};
```
### 配置管理
使用环境变量管理测试配置:
```typescript
// config/environments.ts
export const getEnvironment = () => {
const env = process.env.NODE_ENV || 'development';
return {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
timeout: parseInt(process.env.TEST_TIMEOUT || '30000'),
retries: parseInt(process.env.TEST_RETRIES || '2'),
headless: process.env.HEADLESS !== 'false',
};
};
```
## 持续改进
### 定期审查
每月进行一次测试审查:
1. 检查测试覆盖率
2. 识别慢速测试
3. 评估测试有效性
4. 清理无用测试
### 性能监控
持续监控测试性能:
1. 记录测试执行时间
2. 识别性能趋势
3. 优化慢速测试
4. 调整测试分层
### 反馈收集
收集测试反馈:
1. 开发人员反馈
2. 测试失败分析
3. 用户反馈
4. 生产问题追踪
## 常见问题
### Q: 如何确定测试应该放在哪一层?
A: 根据测试的执行时间和重要性:
- 执行时间<30秒且是关键功能 → 快速层
- 执行时间<60秒 → 标准层
- 执行时间>60秒或需要完整回归 → 深度层
### Q: 测试失败时如何处理?
A: 按照以下优先级处理:
1. 快速层测试失败 → 立即修复
2. 标准层测试失败 → 在合并PR前修复
3. 深度层测试失败 → 在发布前修复
### Q: 如何减少测试执行时间?
A: 采用以下策略:
1. 并行执行测试
2. 减少不必要的等待
3. 优化选择器
4. 拆分大测试
5. 使用mock数据
### Q: 如何提高测试稳定性?
A: 遵循以下原则:
1. 使用稳定的等待策略
2. 避免硬编码的等待时间
3. 使用data-testid选择器
4. 清理测试数据
5. 增加重试次数
## 参考资源
- [Playwright最佳实践](https://playwright.dev/docs/best-practices)
- [测试金字塔](https://martinfowler.com/articles/practical-test-pyramid.html)
- [Page Object Model](https://playwright.dev/docs/pom)
- [测试驱动开发](https://martinfowler.com/bliki/TestDrivenDevelopment.html)
-295
View File
@@ -1,295 +0,0 @@
# 分层测试快速入门指南
## 什么是分层测试?
分层测试是一种测试策略,将测试按照执行时间和重要性分为三个层级:
- **快速层**:5分钟内完成,验证核心功能
- **标准层**:30分钟内完成,验证大部分功能
- **深度层**:可接受较长执行时间,进行全面验证
## 快速开始
### 1. 本地运行测试
#### 运行快速层测试(推荐日常开发使用)
```bash
npm run test:tier:fast
```
#### 运行标准层测试
```bash
npm run test:tier:standard
```
#### 运行深度层测试
```bash
npm run test:tier:deep
```
#### 运行所有层级测试
```bash
npm run test:tier:all
```
### 2. 编写分层测试
#### 快速层测试示例
```typescript
test.describe('API快速测试 @smoke @critical', () => {
test('应该能够获取内容列表', async ({ request }) => {
const response = await request.get('/api/admin/content');
expect(response.status()).toBe(200);
});
});
```
#### 标准层测试示例
```typescript
test.describe('管理后台功能测试 @admin @regression', () => {
test('应该能够创建新闻', async ({ page }) => {
await page.goto('/admin/news');
await page.click('[data-testid="create-news-btn"]');
await page.fill('[data-testid="news-title"]', '测试新闻');
await page.click('[data-testid="save-btn"]');
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});
});
```
#### 深度层测试示例
```typescript
test.describe('首页视觉回归测试 @visual @regression', () => {
test('桌面端首页应该与基准一致', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-desktop.png');
});
});
```
### 3. 使用测试标记
为测试添加标记以便分类和管理:
```typescript
test.describe('测试套件 @smoke @critical', () => {
test('测试用例 @api @regression', async ({ page }) => {
// 测试逻辑
});
});
```
**常用标记:**
- `@smoke` - 冒烟测试
- `@critical` - 关键测试
- `@regression` - 回归测试
- `@visual` - 视觉测试
- `@api` - API测试
- `@mobile` - 移动端测试
## CI/CD集成
项目已配置Woodpecker CI,自动执行分层测试:
### 分支策略
- **main分支**:执行所有层级测试
- **develop分支**:执行快速层和标准层测试
- **其他分支**:仅执行快速层测试
### 工作流程
1. 提交代码到分支
2. Woodpecker CI自动触发
3. 依次执行快速层、标准层、深度层测试
4. 前一层失败则停止后续执行
5. 生成测试报告并上传
6. 发送通知
## 性能优化
### 识别慢速测试
运行性能优化工具:
```bash
cd e2e && node test-optimizer-simple-test.js
```
工具会生成优化报告,包含:
- 慢速测试列表
- 优化建议
- 潜在时间节省
### 优化建议
1. **减少等待时间**
```typescript
// 不推荐
await page.waitForTimeout(5000);
// 推荐
await page.waitForSelector('[data-testid="result"]', { timeout: 5000 });
```
2. **使用data-testid选择器**
```typescript
// 不推荐
await page.click('div > div > button');
// 推荐
await page.click('[data-testid="submit-btn"]');
```
3. **拆分大测试**
```typescript
// 不推荐:单个大测试
test('完整的用户注册流程', async ({ page }) => {
// 100+ 行代码
});
// 推荐:拆分为多个小测试
test.describe('用户注册流程', () => {
test('应该能够填写注册表单', async ({ page }) => {
// 20 行代码
});
test('应该能够提交注册', async ({ page }) => {
// 20 行代码
});
});
```
## 监控和告警
### 测试执行历史
系统自动记录测试执行历史,存储在 `e2e/test-history.json`。
### 告警规则
系统会根据以下规则触发告警:
1. 测试通过率低于80% (Critical)
2. 测试通过率低于90% (High)
3. 测试执行时间超过30分钟 (Medium)
4. 失败测试数量超过10个 (High)
5. 深度层测试存在失败 (Critical)
### 查看告警
告警信息会输出到控制台,并保存在 `test-results/alerts.json`。
## 常见问题
### Q: 测试超时怎么办?
A: 检查以下几点:
1. 是否有不必要的等待时间
2. 选择器是否正确
3. 网络请求是否正常
4. 是否需要增加超时时间
### Q: 测试不稳定怎么办?
A: 采用以下策略:
1. 增加重试次数
2. 使用更稳定的等待策略
3. 检查是否有竞态条件
4. 使用data-testid选择器
### Q: 如何确定测试应该放在哪一层?
A: 根据执行时间和重要性:
- 执行时间<30秒且是关键功能 → 快速层
- 执行时间<60秒 → 标准层
- 执行时间>60秒或需要完整回归 → 深度层
### Q: 如何减少测试执行时间?
A: 采用以下策略:
1. 并行执行测试
2. 减少不必要的等待
3. 优化选择器
4. 拆分大测试
5. 使用mock数据
## 进阶使用
### 自定义测试层级
编辑 `e2e/src/config/test-tiers.ts`
```typescript
export const TEST_TIERS: Record<string, TestTierConfig> = {
fast: {
name: '快速层',
description: '冒烟测试、API测试、基础功能验证',
testMatch: /.*\.smoke\.spec\.ts$|.*\.api\.spec\.ts$/,
timeout: 30000,
retries: 1,
workers: process.env.CI ? 6 : '75%',
fullyParallel: true,
failFast: true,
},
// ... 其他层级
};
```
### 添加自定义告警规则
编辑 `e2e/src/utils/test-monitor.ts`
```typescript
this.alertRules.push({
name: 'custom-alert',
condition: (m) => m.failedTests > 5 && m.tier === 'fast',
severity: 'critical',
message: '快速层测试失败超过5个',
});
```
### 自定义优化规则
编辑 `e2e/src/utils/test-optimizer.ts`
```typescript
this.rules.push({
name: 'custom-rule',
condition: (p) => p.duration > 90000 && p.tier === 'standard',
suggestions: [
'标准层测试不应超过90秒',
'考虑拆分测试或优化执行流程',
],
});
```
## 文档资源
- [测试优化指南](./test-optimization-guide.md) - 详细的优化策略和技巧
- [分层测试最佳实践](./test-tiering-best-practices.md) - 完整的最佳实践指南
- [Playwright文档](https://playwright.dev/) - Playwright官方文档
- [Woodpecker CI文档](https://woodpecker-ci.org/docs/) - Woodpecker CI官方文档
## 获取帮助
如果遇到问题:
1. 查看文档资源
2. 检查测试日志
3. 运行性能优化工具
4. 联系团队成员
## 总结
分层测试系统通过以下方式提高测试效率:
1. **快速反馈**:快速层测试在5分钟内完成
2. **合理分配**:根据重要性分配测试资源
3. **持续优化**:通过历史数据持续优化
4. **自动化**CI/CD自动执行和报告
开始使用分层测试,提高测试效率,缩短反馈周期!
-10
View File
@@ -1,10 +0,0 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_URL || 'file:./data.db',
},
} satisfies Config;
-71
View File
@@ -1,71 +0,0 @@
CREATE TABLE `audit_logs` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text,
`action` text NOT NULL,
`resource_type` text NOT NULL,
`resource_id` text,
`details` text,
`ip_address` text,
`user_agent` text,
`timestamp` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `content` (
`id` text PRIMARY KEY NOT NULL,
`type` text NOT NULL,
`title` text NOT NULL,
`slug` text NOT NULL,
`excerpt` text,
`content` text NOT NULL,
`cover_image` text,
`category` text,
`tags` text,
`status` text DEFAULT 'draft' NOT NULL,
`published_at` integer,
`author_id` text NOT NULL,
`sort_order` integer DEFAULT 0,
`metadata` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `content_slug_unique` ON `content` (`slug`);--> statement-breakpoint
CREATE TABLE `content_versions` (
`id` text PRIMARY KEY NOT NULL,
`content_id` text NOT NULL,
`version` integer NOT NULL,
`title` text NOT NULL,
`content` text NOT NULL,
`changes` text,
`changed_by` text NOT NULL,
`changed_at` integer NOT NULL,
FOREIGN KEY (`content_id`) REFERENCES `content`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`changed_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `site_config` (
`id` text PRIMARY KEY NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`category` text NOT NULL,
`description` text,
`updated_at` integer NOT NULL,
`updated_by` text,
FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `site_config_key_unique` ON `site_config` (`key`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password_hash` text,
`name` text NOT NULL,
`role` text DEFAULT 'editor' NOT NULL,
`avatar` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
-17
View File
@@ -1,17 +0,0 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password_hash` text,
`name` text NOT NULL,
`is_admin` integer DEFAULT false NOT NULL,
`avatar` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_users`("id", "email", "password_hash", "name", "is_admin", "avatar", "created_at", "updated_at") SELECT "id", "email", "password_hash", "name", "is_admin", "avatar", "created_at", "updated_at" FROM `users`;--> statement-breakpoint
DROP TABLE `users`;--> statement-breakpoint
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
-500
View File
@@ -1,500 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "98ef90e0-460c-4b25-9197-bf2f4900d3f9",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"audit_logs": {
"name": "audit_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"audit_logs_user_id_users_id_fk": {
"name": "audit_logs_user_id_users_id_fk",
"tableFrom": "audit_logs",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content": {
"name": "content",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cover_image": {
"name": "cover_image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_id": {
"name": "author_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"content_slug_unique": {
"name": "content_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {
"content_author_id_users_id_fk": {
"name": "content_author_id_users_id_fk",
"tableFrom": "content",
"tableTo": "users",
"columnsFrom": [
"author_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content_versions": {
"name": "content_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"content_id": {
"name": "content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"changes": {
"name": "changes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"changed_by": {
"name": "changed_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"changed_at": {
"name": "changed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"content_versions_content_id_content_id_fk": {
"name": "content_versions_content_id_content_id_fk",
"tableFrom": "content_versions",
"tableTo": "content",
"columnsFrom": [
"content_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"content_versions_changed_by_users_id_fk": {
"name": "content_versions_changed_by_users_id_fk",
"tableFrom": "content_versions",
"tableTo": "users",
"columnsFrom": [
"changed_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_by": {
"name": "updated_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"site_config_key_unique": {
"name": "site_config_key_unique",
"columns": [
"key"
],
"isUnique": true
}
},
"foreignKeys": {
"site_config_updated_by_users_id_fk": {
"name": "site_config_updated_by_users_id_fk",
"tableFrom": "site_config",
"tableTo": "users",
"columnsFrom": [
"updated_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'editor'"
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
-502
View File
@@ -1,502 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "923c66d2-b19b-4d84-b88c-dc75d07fefd6",
"prevId": "98ef90e0-460c-4b25-9197-bf2f4900d3f9",
"tables": {
"audit_logs": {
"name": "audit_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"audit_logs_user_id_users_id_fk": {
"name": "audit_logs_user_id_users_id_fk",
"tableFrom": "audit_logs",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content": {
"name": "content",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cover_image": {
"name": "cover_image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_id": {
"name": "author_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"content_slug_unique": {
"name": "content_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {
"content_author_id_users_id_fk": {
"name": "content_author_id_users_id_fk",
"tableFrom": "content",
"tableTo": "users",
"columnsFrom": [
"author_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content_versions": {
"name": "content_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"content_id": {
"name": "content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"changes": {
"name": "changes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"changed_by": {
"name": "changed_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"changed_at": {
"name": "changed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"content_versions_content_id_content_id_fk": {
"name": "content_versions_content_id_content_id_fk",
"tableFrom": "content_versions",
"tableTo": "content",
"columnsFrom": [
"content_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"content_versions_changed_by_users_id_fk": {
"name": "content_versions_changed_by_users_id_fk",
"tableFrom": "content_versions",
"tableTo": "users",
"columnsFrom": [
"changed_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_by": {
"name": "updated_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"site_config_key_unique": {
"name": "site_config_key_unique",
"columns": [
"key"
],
"isUnique": true
}
},
"foreignKeys": {
"site_config_updated_by_users_id_fk": {
"name": "site_config_updated_by_users_id_fk",
"tableFrom": "site_config",
"tableTo": "users",
"columnsFrom": [
"updated_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_admin": {
"name": "is_admin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"users\".\"role\"": "\"users\".\"is_admin\""
}
},
"internal": {
"indexes": {}
}
}
-20
View File
@@ -1,20 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1772974841798,
"tag": "0000_white_justice",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1773202935722,
"tag": "0001_clammy_toro",
"breakpoints": true
}
]
}
-332
View File
@@ -1,332 +0,0 @@
import { test, expect, Page } from '@playwright/test';
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@novalon.cn';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123456';
test.describe('后台与前台页面交互测试', () => {
test('首页展示所有内容类型入口', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
const navLinks = page.locator('nav a, header a[href]');
const count = await navLinks.count();
console.log(`首页导航链接数量: ${count}`);
expect(count).toBeGreaterThan(0);
const linkTexts = await navLinks.allTextContents();
console.log('导航链接:', linkTexts);
});
test('新闻页面内容展示', async ({ page }) => {
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/news/);
const mainContent = page.locator('main, [role="main"]');
await expect(mainContent).toBeVisible();
const heading = page.locator('h1, h2').first();
const hasHeading = await heading.isVisible().catch(() => false);
console.log(`新闻页面标题${hasHeading ? '存在' : '不存在'}`);
});
test('产品页面内容展示', async ({ page }) => {
await page.goto(`${BASE_URL}/products`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/products/);
const mainContent = page.locator('main, [role="main"]');
await expect(mainContent).toBeVisible();
});
test('服务页面内容展示', async ({ page }) => {
await page.goto(`${BASE_URL}/services`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/services/);
const mainContent = page.locator('main, [role="main"]');
await expect(mainContent).toBeVisible();
});
test('案例页面内容展示', async ({ page }) => {
await page.goto(`${BASE_URL}/cases`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/cases/);
const mainContent = page.locator('main, [role="main"]');
await expect(mainContent).toBeVisible();
});
});
test.describe('后台内容管理功能测试', () => {
test.beforeEach(async ({ page }) => {
await page.goto(`${BASE_URL}/admin/login`);
await page.waitForLoadState('networkidle');
const emailInput = page.locator('#email');
const passwordInput = page.locator('#password');
const submitButton = page.locator('button[type="submit"]');
await emailInput.fill(ADMIN_EMAIL);
await passwordInput.fill(ADMIN_PASSWORD);
await submitButton.click();
await page.waitForURL(/\/admin(?!\/login)/, { timeout: 15000 });
});
test('后台仪表盘加载', async ({ page }) => {
await page.goto(`${BASE_URL}/admin`);
await page.waitForLoadState('networkidle');
const heading = page.locator('h1, .text-2xl').first();
await expect(heading).toBeVisible();
console.log('后台仪表盘加载成功');
});
test('后台内容列表页面加载', async ({ page }) => {
await page.goto(`${BASE_URL}/admin/content`);
await page.waitForLoadState('networkidle');
const table = page.locator('table');
await expect(table).toBeVisible();
const rows = page.locator('tbody tr');
const count = await rows.count();
console.log(`后台内容列表数量: ${count}`);
});
test('后台新建内容页面表单完整性', async ({ page }) => {
await page.goto(`${BASE_URL}/admin/content/new`);
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('input[placeholder="请输入标题"]', { timeout: 60000 });
const titleInput = page.locator('input[placeholder="请输入标题"]');
await expect(titleInput).toBeVisible();
const slugInput = page.locator('input[placeholder="url-slug"]');
await expect(slugInput).toBeVisible();
const typeSelect = page.locator('select').first();
await expect(typeSelect).toBeVisible();
const categoryInput = page.locator('input[placeholder="分类名称"]');
const hasCategory = await categoryInput.isVisible().catch(() => false);
console.log(`分类输入框${hasCategory ? '存在' : '不存在'}`);
const publishButton = page.locator('button:has-text("发布")');
await expect(publishButton).toBeVisible();
const saveDraftButton = page.locator('button:has-text("保存草稿"), button:has-text("保存")');
await expect(saveDraftButton).toBeVisible();
});
test('后台内容编辑页面加载', async ({ page }) => {
await page.goto(`${BASE_URL}/admin/content`);
await page.waitForLoadState('networkidle');
const rows = page.locator('tbody tr');
const count = await rows.count();
if (count > 0) {
const firstEditLink = page.locator('tbody tr:first-child a[href*="/admin/content/"]').first();
const hasEditLink = await firstEditLink.isVisible().catch(() => false);
if (hasEditLink) {
await firstEditLink.click();
await page.waitForLoadState('domcontentloaded');
const titleInput = page.locator('input[placeholder="请输入标题"]');
await expect(titleInput).toBeVisible({ timeout: 30000 });
console.log('编辑页面加载成功');
} else {
console.log('没有可编辑的内容');
}
} else {
console.log('内容列表为空');
}
});
test('后台内容分类管理', async ({ page }) => {
await page.goto(`${BASE_URL}/admin/categories`);
await page.waitForLoadState('networkidle');
const heading = page.locator('h1, .text-2xl').first();
const hasHeading = await heading.isVisible().catch(() => false);
console.log(`分类管理页面${hasHeading ? '可访问' : '不存在或无权限'}`);
});
});
test.describe('内容导航和链接测试', () => {
test('导航到不同内容类型页面', async ({ page }) => {
const pages = [
{ url: '/news', name: '新闻' },
{ url: '/products', name: '产品' },
{ url: '/services', name: '服务' },
{ url: '/cases', name: '案例' },
];
for (const p of pages) {
await page.goto(`${BASE_URL}${p.url}`);
await page.waitForLoadState('networkidle');
const url = page.url();
console.log(`${p.name}页面: ${url.includes(p.url) ? '可访问' : '不可访问'}`);
}
});
test('内容详情页访问', async ({ page }) => {
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
const links = page.locator('a[href*="/news/"]');
const count = await links.count();
if (count > 0) {
const firstLink = links.first();
const href = await firstLink.getAttribute('href');
if (href && !href.startsWith('http')) {
await page.goto(`${BASE_URL}${href}`);
await page.waitForLoadState('networkidle');
const mainContent = page.locator('main, article');
const isVisible = await mainContent.isVisible().catch(() => false);
console.log(`详情页加载${isVisible ? '成功' : '失败'}`);
}
} else {
console.log('没有可访问的新闻详情链接');
}
});
});
test.describe('SEO和元数据测试', () => {
test('页面标题验证', async ({ page }) => {
const pages = [
{ url: '/', name: '首页' },
{ url: '/news', name: '新闻' },
{ url: '/products', name: '产品' },
];
for (const p of pages) {
await page.goto(`${BASE_URL}${p.url}`);
await page.waitForLoadState('networkidle');
const title = await page.title();
console.log(`${p.name}标题: ${title}`);
expect(title.length).toBeGreaterThan(0);
}
});
test('Meta描述标签验证', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
const metaDesc = page.locator('meta[name="description"]');
const hasMetaDesc = await metaDesc.count();
console.log(`Meta描述标签${hasMetaDesc > 0 ? '存在' : '不存在'}`);
});
});
test.describe('响应式导航测试', () => {
test('移动端导航菜单', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
const menuButton = page.locator('button[aria-label*="菜单"], button[class*="menu"], button[class*="Menu"]');
const hasMenuButton = await menuButton.isVisible().catch(() => false);
console.log(`移动端菜单按钮${hasMenuButton ? '存在' : '不存在'}`);
if (hasMenuButton) {
await menuButton.click();
await page.waitForSelector('nav, [class*="menu"], [class*="Menu"]', { state: 'visible', timeout: 5000 });
const navMenu = page.locator('nav, [class*="menu"], [class*="Menu"]');
const isVisible = await navMenu.isVisible().catch(() => false);
console.log(`导航菜单${isVisible ? '展开' : '未展开'}`);
}
});
test('桌面端导航显示', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
const navLinks = page.locator('nav a');
const count = await navLinks.count();
console.log(`桌面端导航链接数量: ${count}`);
expect(count).toBeGreaterThan(0);
});
});
test.describe('页面加载性能测试', () => {
test('各页面加载时间', async ({ page }) => {
const pages = [
{ url: '/', name: '首页' },
{ url: '/news', name: '新闻' },
{ url: '/products', name: '产品' },
{ url: '/services', name: '服务' },
{ url: '/cases', name: '案例' },
];
for (const p of pages) {
const startTime = Date.now();
await page.goto(`${BASE_URL}${p.url}`);
await page.waitForLoadState('networkidle');
const loadTime = Date.now() - startTime;
console.log(`${p.name}页面加载时间: ${loadTime}ms`);
expect(loadTime).toBeLessThan(5000);
}
});
});
test.describe('错误处理测试', () => {
test('访问不存在的页面', async ({ page }) => {
await page.goto(`${BASE_URL}/nonexistent-page-12345`);
await page.waitForLoadState('networkidle');
const errorElement = page.locator('[class*="error"], h1:has-text("404"), text=页面不存在');
const hasError = await errorElement.isVisible().catch(() => false);
console.log(`404页面${hasError ? '正确显示' : '未显示'}`);
});
test('后台访问无权限内容', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${BASE_URL}/admin/content/99999`);
await page.waitForLoadState('networkidle');
await page.waitForURL(/\/admin/, { timeout: 5000 });
const url = page.url();
console.log(`访问不存在内容后URL: ${url}`);
await context.close();
});
});
test.describe('国际化支持测试', () => {
test('页面语言属性', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
const htmlLang = await page.locator('html').getAttribute('lang');
console.log(`页面语言: ${htmlLang || '未设置'}`);
});
});
-198
View File
@@ -1,198 +0,0 @@
import { test, expect, Page } from '@playwright/test';
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@novalon.cn';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123456';
test.describe('后台管理发布功能 - 核心测试', () => {
test.beforeEach(async ({ page }) => {
await page.goto(`${BASE_URL}/admin/login`);
await page.waitForLoadState('networkidle');
const emailInput = page.locator('#email');
const passwordInput = page.locator('#password');
const submitButton = page.locator('button[type="submit"]');
await emailInput.fill(ADMIN_EMAIL);
await passwordInput.fill(ADMIN_PASSWORD);
await submitButton.click();
await page.waitForURL(/\/admin(?!\/login)/, { timeout: 15000 });
});
test('管理员登录成功', async ({ page }) => {
expect(page.url()).not.toContain('/admin/login');
await page.goto(`${BASE_URL}/admin/content`);
await page.waitForLoadState('networkidle');
await expect(page.locator('h1, .text-2xl').first()).toContainText('内容管理');
});
test('后台内容列表加载', async ({ page }) => {
await page.goto(`${BASE_URL}/admin/content`);
await page.waitForLoadState('networkidle');
const table = page.locator('table');
await expect(table).toBeVisible();
const rows = page.locator('tbody tr');
const count = await rows.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('新建内容页面加载', async ({ page }) => {
await page.goto(`${BASE_URL}/admin/content/new`);
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('input[placeholder="请输入标题"]', { timeout: 60000 });
await page.waitForSelector('input[placeholder="url-slug"]', { timeout: 60000 });
const heading = page.locator('h1, .text-2xl').first();
await expect(heading).toBeVisible({ timeout: 10000 });
const titleInput = page.locator('input[placeholder="请输入标题"]');
await expect(titleInput).toBeVisible({ timeout: 10000 });
const slugInput = page.locator('input[placeholder="url-slug"]');
await expect(slugInput).toBeVisible({ timeout: 10000 });
});
test('新建内容页面表单元素可见', async ({ page }) => {
await page.goto(`${BASE_URL}/admin/content/new`);
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('input[placeholder="请输入标题"]', { timeout: 60000 });
const typeSelect = page.locator('select').first();
await expect(typeSelect).toBeVisible({ timeout: 10000 });
const categoryInput = page.locator('input[placeholder="分类名称"]');
await expect(categoryInput).toBeVisible({ timeout: 10000 });
const saveButton = page.locator('button:has-text("保存草稿")');
await expect(saveButton).toBeVisible({ timeout: 10000 });
const publishButton = page.locator('button:has-text("发布")');
await expect(publishButton).toBeVisible({ timeout: 10000 });
});
});
test.describe('前端内容展示验证', () => {
test('首页加载正常', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
await expect(page.locator('header')).toBeVisible();
await expect(page.locator('footer')).toBeVisible();
});
test('新闻页面加载', async ({ page }) => {
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/news/);
await expect(page.locator('header')).toBeVisible();
});
test('产品页面加载', async ({ page }) => {
await page.goto(`${BASE_URL}/products`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/products/);
await expect(page.locator('header')).toBeVisible();
});
test('服务页面加载', async ({ page }) => {
await page.goto(`${BASE_URL}/services`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/services/);
await expect(page.locator('header')).toBeVisible();
});
test('案例页面加载', async ({ page }) => {
await page.goto(`${BASE_URL}/cases`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/cases/);
await expect(page.locator('header')).toBeVisible();
});
});
test.describe('权限控制测试', () => {
test('未登录访问后台重定向到登录页', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${BASE_URL}/admin/content`);
await page.waitForURL(/\/admin\/login/, { timeout: 10000 });
expect(page.url()).toContain('/admin/login');
await context.close();
});
test('API无权限访问返回403', async ({ request }) => {
const response = await request.post(`${BASE_URL}/api/admin/content`, {
data: {
type: 'news',
title: '测试',
slug: 'test',
content: 'test',
},
});
expect([401, 403]).toContain(response.status());
});
});
test.describe('性能测试', () => {
test('首页加载性能', async ({ page }) => {
const startTime = Date.now();
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
const loadTime = Date.now() - startTime;
console.log(`首页加载时间: ${loadTime}ms`);
expect(loadTime).toBeLessThan(5000);
});
test('新闻页面加载性能', async ({ page }) => {
const startTime = Date.now();
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
const loadTime = Date.now() - startTime;
console.log(`新闻页面加载时间: ${loadTime}ms`);
expect(loadTime).toBeLessThan(5000);
});
});
test.describe('响应式设计测试', () => {
test('移动端显示', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
await expect(page.locator('header')).toBeVisible();
await expect(page.locator('footer')).toBeVisible();
});
test('平板端显示', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
await expect(page.locator('header')).toBeVisible();
await expect(page.locator('footer')).toBeVisible();
});
test('桌面端显示', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
await expect(page.locator('header')).toBeVisible();
await expect(page.locator('footer')).toBeVisible();
});
});
-507
View File
@@ -1,507 +0,0 @@
import { test, expect, Page } from '@playwright/test';
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@novalon.cn';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123456';
interface ContentData {
type: 'news' | 'product' | 'service' | 'case';
title: string;
slug: string;
excerpt: string;
content: string;
category: string;
tags: string[];
status: 'draft' | 'published' | 'archived';
}
const testContents: ContentData[] = [
{
type: 'news',
title: `测试新闻-${Date.now()}`,
slug: `test-news-${Date.now()}`,
excerpt: '这是一条测试新闻的摘要内容',
content: '<p>这是测试新闻的正文内容</p><p>包含多个段落</p>',
category: '公司新闻',
tags: ['测试', '自动化'],
status: 'published',
},
{
type: 'product',
title: `测试产品-${Date.now()}`,
slug: `test-product-${Date.now()}`,
excerpt: '这是一个测试产品的描述',
content: '<p>测试产品的详细介绍</p>',
category: '软件产品',
tags: ['产品', '测试'],
status: 'published',
},
{
type: 'service',
title: `测试服务-${Date.now()}`,
slug: `test-service-${Date.now()}`,
excerpt: '这是一个测试服务的描述',
content: '<p>测试服务的详细介绍</p>',
category: '软件开发',
tags: ['服务', '测试'],
status: 'published',
},
{
type: 'case',
title: `测试案例-${Date.now()}`,
slug: `test-case-${Date.now()}`,
excerpt: '这是一个测试案例的描述',
content: '<p>测试案例的详细介绍</p>',
category: '企业服务',
tags: ['案例', '测试'],
status: 'published',
},
];
async function loginAsAdmin(page: Page) {
await page.goto(`${BASE_URL}/admin/login`);
await page.waitForLoadState('networkidle');
const emailInput = page.locator('input[name="email"], input[type="email"]');
const passwordInput = page.locator('input[name="password"], input[type="password"]');
const submitButton = page.locator('button[type="submit"]');
await emailInput.fill(ADMIN_EMAIL);
await passwordInput.fill(ADMIN_PASSWORD);
await submitButton.click();
await page.waitForURL(/\/admin(?!\/login)/, { timeout: 10000 });
await page.waitForLoadState('networkidle');
}
async function createContent(page: Page, contentData: ContentData): Promise<string | null> {
await page.goto(`${BASE_URL}/admin/content/new`);
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('input[type="text"]', { state: 'visible', timeout: 10000 });
const titleInput = page.locator('input[type="text"]').first();
await titleInput.fill(contentData.title);
const slugInput = page.locator('input[placeholder="url-slug"]');
await slugInput.fill(contentData.slug);
const excerptTextarea = page.locator('textarea').first();
await excerptTextarea.fill(contentData.excerpt);
const typeSelect = page.locator('select').first();
await typeSelect.selectOption(contentData.type);
const statusSelect = page.locator('select').nth(1);
await statusSelect.selectOption(contentData.status);
const categoryInput = page.locator('input[placeholder="分类名称"]');
await categoryInput.fill(contentData.category);
const publishButton = page.locator('button:has-text("发布")');
await publishButton.click();
await page.waitForResponse(resp =>
resp.url().includes('/api/admin/content') &&
(resp.request().method() === 'POST' || resp.request().method() === 'PUT'),
{ timeout: 15000 }
);
await page.waitForURL(/\/admin\/content\/[a-zA-Z0-9]+/, { timeout: 10000 });
const url = page.url();
const match = url.match(/\/admin\/content\/([a-zA-Z0-9]+)/);
return match ? match[1] : null;
}
async function deleteContent(page: Page, contentId: string) {
await page.goto(`${BASE_URL}/admin/content`);
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('table tbody tr', { state: 'visible', timeout: 10000 });
const contentRow = page.locator(`tr:has-text("${contentId}")`);
if (await contentRow.count() > 0) {
const deleteButton = contentRow.locator('button:has-text("删除")');
await deleteButton.click();
const confirmButton = page.locator('button:has-text("确认"), button:has-text("确定")');
if (await confirmButton.count() > 0) {
await confirmButton.click();
await page.waitForResponse(resp =>
resp.url().includes('/api/admin/content') &&
resp.request().method() === 'DELETE',
{ timeout: 10000 }
);
}
}
}
test.describe('后台管理发布功能测试', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
});
test('TC-001: 创建新闻内容并发布', async ({ page }) => {
const contentData = testContents[0];
const contentId = await createContent(page, contentData);
expect(contentId).not.toBeNull();
await page.goto(`${BASE_URL}/admin/content`);
await page.waitForLoadState('networkidle');
const contentRow = page.locator(`tr:has-text("${contentData.title}")`);
await expect(contentRow).toBeVisible();
const statusBadge = contentRow.locator('td:has-text("已发布")');
await expect(statusBadge).toBeVisible();
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
const newsCard = page.locator(`text="${contentData.title}"`);
await expect(newsCard).toBeVisible();
if (contentId) {
await deleteContent(page, contentId);
}
});
test('TC-002: 创建产品内容并发布', async ({ page }) => {
const contentData = testContents[1];
const contentId = await createContent(page, contentData);
expect(contentId).not.toBeNull();
await page.goto(`${BASE_URL}/products`);
await page.waitForLoadState('networkidle');
const productCard = page.locator(`text="${contentData.title}"`);
await expect(productCard).toBeVisible();
if (contentId) {
await deleteContent(page, contentId);
}
});
test('TC-003: 创建服务内容并发布', async ({ page }) => {
const contentData = testContents[2];
const contentId = await createContent(page, contentData);
expect(contentId).not.toBeNull();
await page.goto(`${BASE_URL}/services`);
await page.waitForLoadState('networkidle');
const serviceCard = page.locator(`text="${contentData.title}"`);
await expect(serviceCard).toBeVisible();
if (contentId) {
await deleteContent(page, contentId);
}
});
test('TC-004: 创建案例内容并发布', async ({ page }) => {
const contentData = testContents[3];
const contentId = await createContent(page, contentData);
expect(contentId).not.toBeNull();
await page.goto(`${BASE_URL}/cases`);
await page.waitForLoadState('networkidle');
const caseCard = page.locator(`text="${contentData.title}"`);
await expect(caseCard).toBeVisible();
if (contentId) {
await deleteContent(page, contentId);
}
});
test('TC-005: 保存为草稿', async ({ page }) => {
const draftContent: ContentData = {
type: 'news',
title: `草稿测试-${Date.now()}`,
slug: `draft-test-${Date.now()}`,
excerpt: '这是草稿测试内容',
content: '<p>草稿内容</p>',
category: '公司新闻',
tags: ['草稿'],
status: 'draft',
};
const contentId = await createContent(page, draftContent);
expect(contentId).not.toBeNull();
await page.goto(`${BASE_URL}/admin/content`);
await page.waitForLoadState('networkidle');
const contentRow = page.locator(`tr:has-text("${draftContent.title}")`);
await expect(contentRow).toBeVisible();
const statusBadge = contentRow.locator('td:has-text("草稿")');
await expect(statusBadge).toBeVisible();
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
const newsCard = page.locator(`text="${draftContent.title}"`);
await expect(newsCard).not.toBeVisible();
if (contentId) {
await deleteContent(page, contentId);
}
});
test('TC-006: 编辑已发布的内容', async ({ page }) => {
const contentData = testContents[0];
const contentId = await createContent(page, contentData);
expect(contentId).not.toBeNull();
await page.goto(`${BASE_URL}/admin/content/${contentId}`);
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('input[type="text"]', { state: 'visible', timeout: 10000 });
const updatedTitle = `${contentData.title}-已修改`;
const titleInput = page.locator('input[type="text"]').first();
await titleInput.fill(updatedTitle);
const saveButton = page.locator('button:has-text("保存草稿")');
await saveButton.click();
await page.waitForResponse(resp =>
resp.url().includes(`/api/admin/content/${contentId}`) &&
resp.request().method() === 'PUT',
{ timeout: 15000 }
);
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
const updatedCard = page.locator(`text="${updatedTitle}"`);
await expect(updatedCard).toBeVisible();
if (contentId) {
await deleteContent(page, contentId);
}
});
test('TC-007: 删除内容', async ({ page }) => {
const contentData = testContents[0];
const contentId = await createContent(page, contentData);
expect(contentId).not.toBeNull();
await deleteContent(page, contentId!);
await page.goto(`${BASE_URL}/admin/content`);
await page.waitForLoadState('networkidle');
const contentRow = page.locator(`tr:has-text("${contentData.title}")`);
await expect(contentRow).not.toBeVisible();
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
const newsCard = page.locator(`text="${contentData.title}"`);
await expect(newsCard).not.toBeVisible();
});
test('TC-008: 归档内容', async ({ page }) => {
const contentData = testContents[0];
const contentId = await createContent(page, contentData);
expect(contentId).not.toBeNull();
await page.goto(`${BASE_URL}/admin/content/${contentId}`);
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('select', { state: 'visible', timeout: 10000 });
const statusSelect = page.locator('select').nth(1);
await statusSelect.selectOption('archived');
const saveButton = page.locator('button:has-text("保存草稿")');
await saveButton.click();
await page.waitForResponse(resp =>
resp.url().includes(`/api/admin/content/${contentId}`) &&
resp.request().method() === 'PUT',
{ timeout: 15000 }
);
await page.goto(`${BASE_URL}/admin/content`);
await page.waitForLoadState('networkidle');
const contentRow = page.locator(`tr:has-text("${contentData.title}")`);
await expect(contentRow).toBeVisible();
const statusBadge = contentRow.locator('td:has-text("已归档")');
await expect(statusBadge).toBeVisible();
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
const newsCard = page.locator(`text="${contentData.title}"`);
await expect(newsCard).not.toBeVisible();
if (contentId) {
await deleteContent(page, contentId);
}
});
test('TC-015: 空内容提交验证', async ({ page }) => {
await page.goto(`${BASE_URL}/admin/content/new`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
const publishButton = page.locator('button:has-text("发布")');
await publishButton.click();
await page.waitForTimeout(1000);
const errorMessage = page.locator('text=/请输入标题|标题不能为空|请输入|必填/');
await expect(errorMessage.first()).toBeVisible();
});
test('TC-018: 未登录用户访问后台', async ({ context }) => {
const newPage = await context.newPage();
await newPage.goto(`${BASE_URL}/admin/content`);
await newPage.waitForLoadState('networkidle');
expect(newPage.url()).toContain('/admin/login');
await newPage.close();
});
});
test.describe('前端内容展示验证', () => {
test('新闻页面加载正常', async ({ page }) => {
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
await expect(page.locator('h1, .page-header')).toContainText('新闻');
const newsCards = page.locator('article, .card, [class*="news-item"]');
const count = await newsCards.count();
expect(count).toBeGreaterThan(0);
});
test('产品页面加载正常', async ({ page }) => {
await page.goto(`${BASE_URL}/products`);
await page.waitForLoadState('networkidle');
await expect(page.locator('h1, .page-header')).toContainText('产品');
const productCards = page.locator('article, .card, [class*="product"]');
const count = await productCards.count();
expect(count).toBeGreaterThan(0);
});
test('服务页面加载正常', async ({ page }) => {
await page.goto(`${BASE_URL}/services`);
await page.waitForLoadState('networkidle');
await expect(page.locator('h1, .page-header')).toContainText('服务');
});
test('案例页面加载正常', async ({ page }) => {
await page.goto(`${BASE_URL}/cases`);
await page.waitForLoadState('networkidle');
await expect(page.locator('h1, .page-header')).toContainText('案例');
});
});
test.describe('性能测试', () => {
test('TC-025: 后台列表加载性能', async ({ page }) => {
await loginAsAdmin(page);
const startTime = Date.now();
await page.goto(`${BASE_URL}/admin/content`);
await page.waitForLoadState('networkidle');
const loadTime = Date.now() - startTime;
console.log(`后台列表加载时间: ${loadTime}ms`);
expect(loadTime).toBeLessThan(3000);
});
test('前端新闻页面加载性能', async ({ page }) => {
const startTime = Date.now();
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
const loadTime = Date.now() - startTime;
console.log(`前端新闻页面加载时间: ${loadTime}ms`);
expect(loadTime).toBeLessThan(3000);
});
});
test.describe('安全测试', () => {
test('TC-031: XSS攻击防护', async ({ page }) => {
await loginAsAdmin(page);
const xssContent: ContentData = {
type: 'news',
title: `XSS测试-${Date.now()}`,
slug: `xss-test-${Date.now()}`,
excerpt: '<script>alert("XSS")</script>测试摘要',
content: '<p><script>alert("XSS")</script>测试内容</p>',
category: '公司新闻',
tags: ['安全测试'],
status: 'published',
};
const contentId = await createContent(page, xssContent);
expect(contentId).not.toBeNull();
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
const xssTriggered = await page.evaluate(() => {
return (window as any).xssTriggered === true;
});
expect(xssTriggered).toBe(false);
if (contentId) {
await deleteContent(page, contentId);
}
});
test('TC-033: API权限验证', async ({ request }) => {
const response = await request.post(`${BASE_URL}/api/admin/content`, {
data: {
type: 'news',
title: '未授权测试',
slug: 'unauthorized-test',
content: '测试内容',
},
});
expect(response.status()).toBe(403);
});
});
test.describe('跨浏览器兼容性测试', () => {
test('响应式设计 - 移动端', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
await expect(page.locator('header')).toBeVisible();
await expect(page.locator('footer')).toBeVisible();
});
test('响应式设计 - 平板端', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto(`${BASE_URL}/news`);
await page.waitForLoadState('networkidle');
await expect(page.locator('header')).toBeVisible();
await expect(page.locator('footer')).toBeVisible();
});
});
-46
View File
@@ -1,46 +0,0 @@
module.exports = {
apps: [
{
name: 'novalon-website-1',
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3001',
instances: 1,
exec_mode: 'fork',
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: 3001
}
},
{
name: 'novalon-website-2',
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3002',
instances: 1,
exec_mode: 'fork',
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: 3002
}
},
{
name: 'novalon-website-3',
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3003',
instances: 1,
exec_mode: 'fork',
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: 3003
}
}
]
};
Binary file not shown.
-68
View File
@@ -1,68 +0,0 @@
APP_NAME = Gitea: Git with a cup of tea
RUN_MODE = prod
WORK_PATH = /data/gitea
[repository]
ROOT = /data/git/repositories
[repository.local]
LOCAL_COPY_PATH = /data/gitea/tmp/local-repo
[repository.upload]
TEMP_PATH = /data/gitea/uploads
[server]
APP_DATA_PATH = /data/gitea
DOMAIN = localhost
SSH_DOMAIN = git.f.novalon.cn
HTTP_PORT = 3000
ROOT_URL = https://git.f.novalon.cn
DISABLE_SSH = false
SSH_PORT = 22
SSH_LISTEN_PORT = 22
LFS_START_SERVER = true
LFS_JWT_SECRET = zaXtgFY-twRUX-ygYDPOkIcPg9SYYDOCZ6gDJEjMJFQ
[database]
PATH = /data/gitea/gitea.db
DB_TYPE = postgres
HOST = postgresql:5432
NAME = forgejo
USER = forgejo
PASSWD = forgejo_novalon_prod_f50f952069d79d00
LOG_SQL = false
[indexer]
ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve
[session]
PROVIDER_CONFIG = /data/gitea/sessions
[picture]
AVATAR_UPLOAD_PATH = /data/gitea/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars
[attachment]
PATH = /data/gitea/attachments
[log]
MODE = console
LEVEL = info
ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = true
SECRET_KEY =
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzQ1OTYzNjl9.6ts7iCdm7v26GwKlLug170fuECEQ7Dymnw7pMTxaOTY
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
[lfs]
PATH = /data/git/lfs
[oauth2]
JWT_SECRET = 955VyK_E5nJvYcNqNLutqm1A4h3E-BfRQiwk3oNS3oo
-22
View File
@@ -1,22 +0,0 @@
version: "3.8"
services:
nginx:
image: nginx:alpine
container_name: novalon-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
- ./logs:/var/log/nginx
- ../certbot:/var/www/certbot
networks:
- novalon-network
networks:
novalon-network:
driver: bridge
external: true
-270
View File
@@ -1,270 +0,0 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+text text/javascript;
upstream novalon_app {
server novalon-website:3000;
}
upstream forgejo_app {
server forgejo:3000;
}
upstream woodpecker_app {
server woodpecker-server:8000;
}
upstream registry_app {
server registry:5000;
}
# ========== novalon.cn 主域名 ==========
server {
listen 80;
server_name novalon.cn www.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 novalon.cn www.novalon.cn;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / {
proxy_pass http://novalon_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_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location /_next/static {
proxy_pass http://novalon_app;
proxy_cache_valid 200 60m;
add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000";
}
location /static {
proxy_pass http://novalon_app;
proxy_cache_valid 200 60m;
add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000";
}
}
# ========== git.f.novalon.cn (Forgejo) - 使用单独证书 ==========
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 HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / {
proxy_pass http://forgejo_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_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
# ========== ci.f.novalon.cn (Woodpecker CI) - 使用单独证书 ==========
server {
listen 80;
server_name ci.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 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 HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / {
proxy_pass http://woodpecker_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_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
# ========== registry.f.novalon.cn (Docker Registry) - 使用单独证书 ==========
server {
listen 80;
server_name registry.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 registry.f.novalon.cn;
ssl_certificate /etc/nginx/ssl/registry.f.novalon.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/registry.f.novalon.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / {
proxy_pass http://registry_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_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_buffering off;
proxy_request_buffering off;
}
location /v2/ {
proxy_pass http://registry_app/v2/;
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;
}
}
}
+100
View File
@@ -0,0 +1,100 @@
# Novalon 静态站点 Nginx 配置
# 用法:替换现有 nginx.conf,然后 nginx -t && nginx -s reload
server {
listen 80;
server_name novalon.cn www.novalon.cn;
return 301 https://www.novalon.cn$request_uri;
}
server {
listen 443 ssl http2;
server_name novalon.cn www.novalon.cn;
# SSL 证书配置
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# 静态文件根目录
root /var/www/novalon;
index index.html;
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml
application/rss+xml
image/svg+xml;
# 安全头
add_header X-DNS-Prefetch-Control "on" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# 静态资源长期缓存(带内容哈希的文件)
location /_next/static/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
# 安全头需要重新添加(location 块会覆盖 server 级别的 add_header
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
# 字体文件缓存
location /fonts/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
try_files $uri =404;
}
# 图片文件缓存
location ~* \.(svg|jpg|jpeg|png|gif|webp|avif|ico)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
try_files $uri =404;
}
# Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files $uri =404;
}
# Next.js 静态导出的页面路由
# /about -> /about.html 或 /about/index.html
location / {
try_files $uri $uri.html $uri/ /404.html;
}
# 自定义 404 页面
error_page 404 /404.html;
# 优化文件传输
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
}
-216
View File
@@ -1,216 +0,0 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+text text/javascript;
upstream novalon_app {
server novalon-website:3000;
}
upstream forgejo_app {
server forgejo:3000;
}
upstream woodpecker_app {
server woodpecker-server:8000;
}
upstream registry_app {
server registry:5000;
}
# ========== novalon.cn 主域名 ==========
server {
listen 80;
server_name novalon.cn www.novalon.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name novalon.cn www.novalon.cn;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / {
proxy_pass http://novalon_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_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location /_next/static {
proxy_pass http://novalon_app;
proxy_cache_valid 200 60m;
add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000";
}
location /static {
proxy_pass http://novalon_app;
proxy_cache_valid 200 60m;
add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000";
}
}
# ========== git.f.novalon.cn (临时HTTP配置用于证书申请) ==========
server {
listen 80;
server_name git.f.novalon.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
proxy_pass http://forgejo_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_cache_bypass $http_upgrade;
}
}
# ========== ci.f.novalon.cn (临时HTTP配置用于证书申请) ==========
server {
listen 80;
server_name ci.f.novalon.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
proxy_pass http://woodpecker_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_cache_bypass $http_upgrade;
}
}
# ========== registry.f.novalon.cn (已有证书,配置HTTPS) ==========
server {
listen 80;
server_name registry.f.novalon.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name registry.f.novalon.cn;
ssl_certificate /etc/nginx/ssl/registry.f.novalon.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/registry.f.novalon.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / {
proxy_pass http://registry_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_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_buffering off;
proxy_request_buffering off;
}
location /v2/ {
proxy_pass http://registry_app/v2/;
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;
}
}
}
-270
View File
@@ -1,270 +0,0 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+text text/javascript;
upstream novalon_app {
server novalon-website:3000;
}
upstream forgejo_app {
server forgejo:3000;
}
upstream woodpecker_app {
server woodpecker-server:8000;
}
upstream registry_app {
server registry:5000;
}
# ========== novalon.cn 主域名 ==========
server {
listen 80;
server_name novalon.cn www.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 novalon.cn www.novalon.cn;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / {
proxy_pass http://novalon_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_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location /_next/static {
proxy_pass http://novalon_app;
proxy_cache_valid 200 60m;
add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000";
}
location /static {
proxy_pass http://novalon_app;
proxy_cache_valid 200 60m;
add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000";
}
}
# ========== git.f.novalon.cn (Forgejo) - 使用通配符证书 ==========
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/wildcard/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/wildcard/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / {
proxy_pass http://forgejo_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_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
# ========== ci.f.novalon.cn (Woodpecker CI) - 使用通配符证书 ==========
server {
listen 80;
server_name ci.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 ci.f.novalon.cn;
ssl_certificate /etc/nginx/ssl/wildcard/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/wildcard/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / {
proxy_pass http://woodpecker_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_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
# ========== registry.f.novalon.cn (Docker Registry) - 使用通配符证书 ==========
server {
listen 80;
server_name registry.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 registry.f.novalon.cn;
ssl_certificate /etc/nginx/ssl/wildcard/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/wildcard/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / {
proxy_pass http://registry_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_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_buffering off;
proxy_request_buffering off;
}
location /v2/ {
proxy_pass http://registry_app/v2/;
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;
}
}
}
-99
View File
@@ -1,99 +0,0 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 20M;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+text text/javascript;
upstream novalon_app {
server novalon-website:3000;
}
server {
listen 80;
server_name novalon.cn www.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 novalon.cn www.novalon.cn;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / {
proxy_pass http://novalon_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_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location /_next/static {
proxy_pass http://novalon_app;
proxy_cache_valid 200 60m;
add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000";
}
location /static {
proxy_pass http://novalon_app;
proxy_cache_valid 200 60m;
add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000";
}
}
}
-21
View File
@@ -1,21 +0,0 @@
version: "3.8"
services:
nginx:
image: nginx:alpine
container_name: novalon-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
- ./logs:/var/log/nginx
networks:
- novalon-network
networks:
novalon-network:
driver: bridge
external: true
-24
View File
@@ -1,24 +0,0 @@
version: "3.8"
services:
novalon-website:
image: novalon-website:1.0.0
container_name: novalon-website
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=${DATABASE_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=${NEXTAUTH_URL}
- RESEND_API_KEY=${RESEND_API_KEY}
- OPS_ALERT_EMAIL=${OPS_ALERT_EMAIL:-ops@novalon.cn}
volumes:
- ./logs:/app/logs
networks:
- novalon-network
networks:
novalon-network:
driver: bridge
external: true
+9
View File
@@ -16,8 +16,16 @@
"coverage:report": "open coverage/lcov-report/index.html", "coverage:report": "open coverage/lcov-report/index.html",
"test:e2e": "cd e2e && npm test", "test:e2e": "cd e2e && npm test",
"test:smoke": "cd e2e && npx playwright test --grep @smoke", "test:smoke": "cd e2e && npx playwright test --grep @smoke",
"test:performance": "k6 run tests/performance/load-test.js",
"test:stress": "k6 run tests/performance/stress-test.js",
"check:contrast": "tsx scripts/utils/check-color-contrast.ts", "check:contrast": "tsx scripts/utils/check-color-contrast.ts",
"check:headings": "tsx scripts/utils/check-heading-hierarchy.ts", "check:headings": "tsx scripts/utils/check-heading-hierarchy.ts",
"audit:performance": "node scripts/performance-audit.js",
"audit:seo": "node scripts/seo-check.js",
"audit:accessibility": "node scripts/accessibility-test.js",
"audit:forms": "node scripts/form-validation.js",
"audit:all": "./scripts/run-all-tests.sh",
"report:generate": "node scripts/generate-test-report.js",
"lighthouse": "lhci autorun", "lighthouse": "lhci autorun",
"lighthouse:collect": "lhci collect", "lighthouse:collect": "lhci collect",
"lighthouse:assert": "lhci assert", "lighthouse:assert": "lhci assert",
@@ -26,6 +34,7 @@
"lighthouse:mobile": "lhci autorun --settings.preset=mobile", "lighthouse:mobile": "lhci autorun --settings.preset=mobile",
"deploy:cdn": "bash scripts/deploy-cdn.sh", "deploy:cdn": "bash scripts/deploy-cdn.sh",
"deploy:cdn:refresh": "bash scripts/refresh-cdn.sh", "deploy:cdn:refresh": "bash scripts/refresh-cdn.sh",
"clean:tests": "bash scripts/maintenance/clean-test-files.sh",
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
-149
View File
@@ -1,149 +0,0 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable no-console */
const { chromium } = require('playwright');
const TARGET_URL = 'https://novalon.cn';
(async () => {
console.log('🚀 开始生产环境测试验收...');
console.log('📍 目标URL:', TARGET_URL);
const browser = await chromium.launch({
headless: false,
slowMo: 100
});
const page = await browser.newPage();
try {
console.log('\n📊 测试1: 页面加载与样式验证');
await page.goto(TARGET_URL, { waitUntil: 'networkidle' });
const title = await page.title();
console.log('✅ 页面标题:', title);
// 检查CSS文件是否正常加载
const cssResources = await page.evaluate(() => {
const stylesheets = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
return stylesheets.map(link => ({
href: link.href,
loaded: link.sheet !== null
}));
});
console.log('📋 CSS文件加载情况:');
cssResources.forEach((css, index) => {
console.log(` ${index + 1}. ${css.loaded ? '✅' : '❌'} ${css.href}`);
});
// 检查是否有CDN引用
const hasCDNReferences = await page.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script[src]'));
return scripts.some(script => script.src.includes('cdn.novalon.cn'));
});
if (hasCDNReferences) {
console.log('❌ 警告: 页面仍引用CDN资源');
} else {
console.log('✅ 页面不引用CDN资源');
}
console.log('\n📊 测试2: 备案信息验证');
const icpText = await page.evaluate(() => {
const footer = document.querySelector('footer');
return footer ? footer.textContent : '';
});
const hasICP = icpText.includes('蜀ICP备2026013658号');
const hasPolice = icpText.includes('川公网安备51010602003285号');
console.log(` ICP备案号: ${hasICP ? '✅ 正确' : '❌ 错误'} (蜀ICP备2026013658号)`);
console.log(` 公安备案号: ${hasPolice ? '✅ 正确' : '❌ 错误'} (川公网安备51010602003285号)`);
console.log('\n📊 测试3: 电话号码移除验证');
const hasPhone = await page.evaluate(() => {
const bodyText = document.body.textContent;
return bodyText.includes('028-88888888') || bodyText.includes('电话');
});
if (hasPhone) {
console.log('❌ 错误: 页面仍显示电话号码');
} else {
console.log('✅ 正确: 页面已移除电话号码');
}
console.log('\n📊 测试4: 页面布局与响应式');
const viewportTests = [
{ width: 1920, height: 1080, name: '桌面端' },
{ width: 768, height: 1024, name: '平板端' },
{ width: 375, height: 667, name: '移动端' }
];
for (const test of viewportTests) {
await page.setViewportSize(test);
await page.screenshot({
path: `./playwright-screenshots/screenshot-${test.name}.png`,
fullPage: true
});
console.log(`${test.name}截图已保存`);
}
console.log('\n📊 测试5: 关键页面导航');
const testPages = [
{ path: '/', name: '首页' },
{ path: '/about', name: '关于我们' },
{ path: '/contact', name: '联系我们' }
];
for (const testPage of testPages) {
await page.goto(`${TARGET_URL}${testPage.path}`, { waitUntil: 'networkidle' });
const pageTitle = await page.title();
console.log(`${testPage.name} (${testPage.path}): ${pageTitle}`);
}
console.log('\n📊 测试6: 网络资源加载');
const resourceErrors = await page.evaluate(() => {
const errors = [];
window.addEventListener('error', (e) => {
errors.push(e.message);
});
return errors.length;
});
if (resourceErrors > 0) {
console.log(`❌ 发现${resourceErrors}个资源加载错误`);
} else {
console.log('✅ 所有资源正常加载');
}
console.log('\n📊 测试7: 备案图标检查');
const hasFilingIcon = await page.evaluate(() => {
const images = Array.from(document.querySelectorAll('img'));
return images.some(img => img.src.includes('备案') || img.alt.includes('备案'));
});
if (hasFilingIcon) {
console.log('✅ 发现备案相关图标');
} else {
console.log('⚠️ 未发现备案图标');
}
console.log('\n🎯 测试总结:');
console.log('✅ 页面加载正常');
console.log('✅ 样式文件正常加载');
console.log('✅ 备案信息正确显示');
console.log('✅ 电话号码已移除');
console.log('✅ 响应式布局正常');
console.log('✅ 关键页面可访问');
console.log('✅ 无CDN引用问题');
console.log('\n📸 截图已保存到 ./playwright-screenshots/ 目录');
console.log('🎉 生产环境测试验收完成!');
} catch (error) {
console.error('❌ 测试过程中出现错误:', error.message);
await page.screenshot({ path: './playwright-screenshots/error-screenshot.png', fullPage: true });
} finally {
await browser.close();
}
})();
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# Novalon 静态站点部署脚本
# 用法: bash scripts/deploy-static.sh [环境]
# 环境参数: production (默认)
set -euo pipefail
ENV="${1:-production}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
BUILD_DIR="$PROJECT_DIR/dist"
DEPLOY_DIR="/var/www/novalon"
BACKUP_DIR="/var/www/novalon-backup"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "========================================="
echo " Novalon 静态站点部署"
echo " 环境: $ENV"
echo " 时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "========================================="
# 步骤 1: 构建
echo ""
echo "[1/5] 构建静态站点..."
cd "$PROJECT_DIR"
npm run build
if [ ! -d "$BUILD_DIR" ]; then
echo "❌ 构建失败:dist 目录不存在"
exit 1
fi
echo "✅ 构建完成,产物大小:$(du -sh "$BUILD_DIR" | cut -f1)"
# 步骤 2: 备份当前版本
echo ""
echo "[2/5] 备份当前版本..."
if [ -d "$DEPLOY_DIR" ]; then
mkdir -p "$BACKUP_DIR"
cp -r "$DEPLOY_DIR" "$BACKUP_DIR/novalon-backup-$TIMESTAMP"
# 只保留最近 3 个备份
ls -t "$BACKUP_DIR"/ | tail -n +4 | xargs -I {} rm -rf "$BACKUP_DIR/{}"
echo "✅ 备份完成: $BACKUP_DIR/novalon-backup-$TIMESTAMP"
else
echo "⚠️ 首次部署,无需备份"
mkdir -p "$DEPLOY_DIR"
fi
# 步骤 3: 部署新版本
echo ""
echo "[3/5] 部署新版本..."
# 清空目标目录(保留 .well-known
if [ -d "$DEPLOY_DIR/.well-known" ]; then
mv "$DEPLOY_DIR/.well-known" /tmp/well-known-backup
fi
rm -rf "$DEPLOY_DIR"/*
cp -r "$BUILD_DIR"/* "$DEPLOY_DIR/"
if [ -d /tmp/well-known-backup ]; then
mv /tmp/well-known-backup "$DEPLOY_DIR/.well-known"
fi
echo "✅ 文件部署完成"
# 步骤 4: 设置权限
echo ""
echo "[4/5] 设置文件权限..."
chmod -R 755 "$DEPLOY_DIR"
echo "✅ 权限设置完成"
# 步骤 5: 重载 Nginx
echo ""
echo "[5/5] 重载 Nginx..."
if nginx -t 2>/dev/null; then
nginx -s reload
echo "✅ Nginx 重载成功"
else
echo "⚠️ Nginx 配置检查失败,跳过重载(请手动检查)"
fi
echo ""
echo "========================================="
echo " ✅ 部署完成!"
echo " 站点地址: https://www.novalon.cn"
echo " 部署目录: $DEPLOY_DIR"
echo "========================================="
-53
View File
@@ -1,53 +0,0 @@
#!/bin/bash
# 备份脚本
# 用法: ./scripts/backup.sh
set -e
BACKUP_DIR="./backups"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="backup_$DATE"
# 创建备份目录
mkdir -p "$BACKUP_DIR/$BACKUP_NAME"
echo "开始备份..."
# 备份数据库
if [ -f "./data/prod.db" ]; then
echo "备份数据库..."
cp ./data/prod.db "$BACKUP_DIR/$BACKUP_NAME/database.db"
else
echo "警告: 数据库文件不存在"
fi
# 备份上传文件
if [ -d "./uploads" ]; then
echo "备份上传文件..."
cp -r ./uploads "$BACKUP_DIR/$BACKUP_NAME/uploads"
else
echo "警告: uploads目录不存在"
fi
# 备份配置
if [ -f ".env.production" ]; then
echo "备份配置..."
cp .env.production "$BACKUP_DIR/$BACKUP_NAME/.env.production"
else
echo "警告: .env.production文件不存在"
fi
# 压缩备份
echo "压缩备份..."
tar -czf "$BACKUP_DIR/$BACKUP_NAME.tar.gz" -C "$BACKUP_DIR" "$BACKUP_NAME"
# 删除临时目录
rm -rf "$BACKUP_DIR/$BACKUP_NAME"
# 保留最近7天的备份
echo "清理旧备份..."
find "$BACKUP_DIR" -name "backup_*.tar.gz" -mtime +7 -delete
echo "备份完成: $BACKUP_DIR/$BACKUP_NAME.tar.gz"
echo "备份大小: $(du -h "$BACKUP_DIR/$BACKUP_NAME.tar.gz" | cut -f1)"
+4 -28
View File
@@ -4,26 +4,16 @@ set -e
echo "🚀 开始部署到生产环境..." echo "🚀 开始部署到生产环境..."
# 加载生产环境变量
export NODE_ENV=production export NODE_ENV=production
# 检查是否已安装依赖
if [ ! -d "node_modules" ]; then if [ ! -d "node_modules" ]; then
echo "📦 安装依赖..." echo "📦 安装依赖..."
npm ci --production=false npm ci --production=false
fi fi
# 运行测试 echo "🔨 构建静态网站..."
echo "🧪 运行测试..."
cd e2e
TEST_ENV=development npx playwright test --reporter=list
cd ..
# 构建生产版本
echo "🔨 构建生产版本..."
npm run build npm run build
# 备份当前版本(如果存在)
if [ -d "dist_backup" ]; then if [ -d "dist_backup" ]; then
rm -rf dist_backup rm -rf dist_backup
fi fi
@@ -32,20 +22,6 @@ if [ -d "dist" ]; then
mv dist dist_backup mv dist dist_backup
fi fi
# 启动生产服务器 echo "✅ 构建完成!"
echo "🌟 启动生产服务器..." echo "📊 静态文件位于 dist/ 目录"
npm start & echo "🌐 可部署到 Nginx、CDN 或任何静态托管服务"
# 等待服务器启动
echo "⏳ 等待服务器启动..."
sleep 10
# 健康检查
echo "🏥 健康检查..."
curl -f http://localhost:3000/api/health || {
echo "❌ 健康检查失败!"
exit 1
}
echo "✅ 部署成功!"
echo "📊 访问 http://localhost:3000"
-69
View File
@@ -1,69 +0,0 @@
#!/bin/bash
# 恢复脚本
# 用法: ./scripts/restore.sh <backup_file.tar.gz>
set -e
if [ -z "$1" ]; then
echo "错误: 请指定备份文件"
echo "用法: ./scripts/restore.sh <backup_file.tar.gz>"
exit 1
fi
BACKUP_FILE="$1"
if [ ! -f "$BACKUP_FILE" ]; then
echo "错误: 备份文件不存在: $BACKUP_FILE"
exit 1
fi
echo "警告: 此操作将覆盖当前数据!"
read -p "确认继续? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo "操作已取消"
exit 0
fi
# 创建临时目录
TEMP_DIR="./temp_restore_$(date +%s)"
mkdir -p "$TEMP_DIR"
echo "解压备份..."
tar -xzf "$BACKUP_FILE" -C "$TEMP_DIR"
# 获取备份目录名
BACKUP_DIR_NAME=$(ls "$TEMP_DIR")
BACKUP_PATH="$TEMP_DIR/$BACKUP_DIR_NAME"
# 恢复数据库
if [ -f "$BACKUP_PATH/database.db" ]; then
echo "恢复数据库..."
cp "$BACKUP_PATH/database.db" ./data/prod.db
else
echo "警告: 备份中没有数据库文件"
fi
# 恢复上传文件
if [ -d "$BACKUP_PATH/uploads" ]; then
echo "恢复上传文件..."
rm -rf ./uploads/*
cp -r "$BACKUP_PATH/uploads"/* ./uploads/ 2>/dev/null || true
else
echo "警告: 备份中没有uploads目录"
fi
# 恢复配置
if [ -f "$BACKUP_PATH/.env.production" ]; then
echo "恢复配置..."
cp "$BACKUP_PATH/.env.production" ./.env.production
else
echo "警告: 备份中没有配置文件"
fi
# 清理临时文件
rm -rf "$TEMP_DIR"
echo "恢复完成!"
echo "请重启应用以使更改生效"
-62
View File
@@ -1,62 +0,0 @@
#!/bin/bash
# Woodpecker CI - 通过API设置仓库为Trusted
# 用途:解决 "Insufficient trust level to use volumes" 和 "Insufficient trust level to use privileged mode" 错误
set -e
echo "=========================================="
echo "Woodpecker CI - 设置仓库为Trusted"
echo "=========================================="
echo ""
# 配置
WOODPECKER_SERVER="https://ci.f.novalon.cn"
REPO_OWNER="novalon"
REPO_NAME="novalon-website"
echo "📋 方法1: 通过Web UI设置(推荐)"
echo "=========================================="
echo ""
echo "步骤1: 访问 ${WOODPECKER_SERVER}"
echo "步骤2: 登录(使用Gitea账号)"
echo "步骤3: 选择仓库 ${REPO_OWNER}/${REPO_NAME}"
echo "步骤4: 点击右上角 Settings"
echo "步骤5: 勾选 Trusted 选项"
echo "步骤6: 点击 Save"
echo ""
echo "📋 方法2: 通过API设置"
echo "=========================================="
echo ""
echo "步骤1: 获取管理员Token"
echo " 1.1 访问 ${WOODPECKER_SERVER}"
echo " 1.2 点击右上角用户头像"
echo " 1.3 选择 Account"
echo " 1.4 复制 Token"
echo ""
echo "步骤2: 执行API请求"
echo " curl -X PATCH \"${WOODPECKER_SERVER}/api/repos/${REPO_OWNER}/${REPO_NAME}\" \\"
echo " -H \"Authorization: Bearer YOUR_TOKEN\" \\"
echo " -H \"Content-Type: application/json\" \\"
echo " -d '{\"trusted\": true}'"
echo ""
echo "📋 方法3: 通过Woodpecker Server配置"
echo "=========================================="
echo ""
echo "如果以上方法不可行,可以在Server端配置:"
echo ""
echo "步骤1: 编辑 /home/novalon/docker-app/novalon-cicd/docker-compose.yml"
echo "步骤2: 在 woodpecker-server 服务中添加:"
echo " environment:"
echo " - WOODPECKER_OPEN=true"
echo " - WOODPECKER_ADMIN=your-admin-username"
echo ""
echo "步骤3: 重启服务:"
echo " cd /home/novalon/docker-app/novalon-cicd"
echo " docker-compose restart woodpecker-server"
echo ""
echo "✅ 推荐使用方法1Web UI设置)"
echo ""
-125
View File
@@ -1,125 +0,0 @@
#!/bin/bash
echo "========================================="
echo "Woodpecker CI密钥配置脚本"
echo "========================================="
echo ""
echo "此脚本将帮助您配置Woodpecker CI所需的密钥"
echo ""
# 检查是否在服务器上
if [ "$HOSTNAME" != "novalon-server" ]; then
echo "⚠️ 请在服务器上运行此脚本"
echo " ssh root@139.155.109.62"
echo " 然后运行: bash /home/novalon/scripts/setup-woodpecker-secrets.sh"
exit 1
fi
# Woodpecker CI CLI命令
WOODPECKER_CLI="woodpecker-cli"
# 检查woodpecker-cli是否安装
if ! command -v $WOODPECKER_CLI &> /dev/null; then
echo "❌ woodpecker-cli未安装"
echo " 请先安装: https://woodpecker-ci.org/docs/cli"
exit 1
fi
echo "步骤1: 配置SSH私钥"
echo "----------------------------------------"
echo "请确保您已经生成了SSH密钥对"
echo "公钥已添加到服务器的authorized_keys中"
echo ""
# 读取SSH私钥
if [ -f ~/.ssh/id_rsa ]; then
echo "✅ 找到SSH私钥: ~/.ssh/id_rsa"
SSH_KEY=$(cat ~/.ssh/id_rsa)
else
echo "❌ 未找到SSH私钥"
echo " 请先生成SSH密钥对: ssh-keygen -t rsa -b 4096"
exit 1
fi
echo ""
echo "步骤2: 配置企业微信通知"
echo "----------------------------------------"
echo "已配置企业微信Webhook URL:"
echo "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=bb7efcdc-c32f-47b7-a437-d76cab9fba74"
echo ""
WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=bb7efcdc-c32f-47b7-a437-d76cab9fba74"
echo "✅ 企业微信通知已配置"
echo ""
echo "步骤3: 配置Docker Registry密码"
echo "----------------------------------------"
echo "请输入Docker Registry密码:"
echo "用于推送到 registry.f.novalon.cn"
read -s -p "密码: " REGISTRY_PASSWORD
echo ""
echo ""
echo "步骤4: 设置Woodpecker CI密钥"
echo "----------------------------------------"
# 设置SSH私钥
echo "设置SSH_PRIVATE_KEY..."
echo "$SSH_KEY" | $WOODPECKER_CLI secret add \
--repository novalon/novalon-website \
--name ssh_private_key \
--value @-
if [ $? -eq 0 ]; then
echo "✅ SSH_PRIVATE_KEY设置成功"
else
echo "❌ SSH_PRIVATE_KEY设置失败"
exit 1
fi
# 设置Registry密码
echo "设置REGISTRY_PASSWORD..."
echo "$REGISTRY_PASSWORD" | $WOODPECKER_CLI secret add \
--repository novalon/novalon-website \
--name registry_password \
--value @-
if [ $? -eq 0 ]; then
echo "✅ REGISTRY_PASSWORD设置成功"
else
echo "❌ REGISTRY_PASSWORD设置失败"
exit 1
fi
# 设置Webhook URL
if [ -n "$WEBHOOK_URL" ]; then
echo "设置WEBHOOK_URL..."
echo "$WEBHOOK_URL" | $WOODPECKER_CLI secret add \
--repository novalon/novalon-website \
--name webhook_url \
--value @-
if [ $? -eq 0 ]; then
echo "✅ WEBHOOK_URL设置成功"
else
echo "❌ WEBHOOK_URL设置失败"
exit 1
fi
fi
echo ""
echo "========================================="
echo "✅ 密钥配置完成!"
echo "========================================="
echo ""
echo "已配置的密钥:"
echo " - SSH_PRIVATE_KEY ✅"
echo " - REGISTRY_PASSWORD ✅"
if [ -n "$WEBHOOK_URL" ]; then
echo " - WEBHOOK_URL ✅"
fi
echo ""
echo "下一步:"
echo " 1. 提交.woodpecker.yml到代码仓库"
echo " 2. 在Woodpecker CI中激活仓库"
echo " 3. 推送代码触发CI/CD流水线"
echo ""
echo "========================================="
-164
View File
@@ -1,164 +0,0 @@
#!/bin/bash
echo "🚀 分层测试系统验证"
echo "=========================="
echo ""
# 颜色定义
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 验证计数器
TOTAL_CHECKS=0
PASSED_CHECKS=0
FAILED_CHECKS=0
# 验证函数
check_file() {
local file=$1
local description=$2
TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
if [ -f "$file" ]; then
echo -e "${GREEN}${NC} $description: $file"
PASSED_CHECKS=$((PASSED_CHECKS + 1))
return 0
else
echo -e "${RED}${NC} $description: $file (文件不存在)"
FAILED_CHECKS=$((FAILED_CHECKS + 1))
return 1
fi
}
check_dir() {
local dir=$1
local description=$2
TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
if [ -d "$dir" ]; then
echo -e "${GREEN}${NC} $description: $dir"
PASSED_CHECKS=$((PASSED_CHECKS + 1))
return 0
else
echo -e "${RED}${NC} $description: $dir (目录不存在)"
FAILED_CHECKS=$((FAILED_CHECKS + 1))
return 1
fi
}
check_script() {
local script=$1
local description=$2
TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
if [ -f "$script" ] && [ -x "$script" ]; then
echo -e "${GREEN}${NC} $description: $script"
PASSED_CHECKS=$((PASSED_CHECKS + 1))
return 0
else
echo -e "${RED}${NC} $description: $script (文件不存在或不可执行)"
FAILED_CHECKS=$((FAILED_CHECKS + 1))
return 1
fi
}
check_npm_script() {
local script_name=$1
local description=$2
TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
if npm run | grep -q "$script_name"; then
echo -e "${GREEN}${NC} $description: npm run $script_name"
PASSED_CHECKS=$((PASSED_CHECKS + 1))
return 0
else
echo -e "${RED}${NC} $description: npm run $script_name (脚本不存在)"
FAILED_CHECKS=$((FAILED_CHECKS + 1))
return 1
fi
}
echo "📁 检查配置文件"
echo "-------------------"
check_file "e2e/src/config/test-tiers.ts" "测试层级配置"
check_file "e2e/src/config/test-tags.ts" "测试标记配置"
check_file "e2e/playwright.config.tiered.ts" "分层测试Playwright配置"
check_file ".woodpecker/test-tiered.yml" "Woodpecker CI配置"
check_file ".woodpecker/test-tiered-simple.yml" "简化版Woodpecker CI配置"
echo ""
echo "🔧 检查工具文件"
echo "-------------------"
check_file "e2e/src/utils/test-history.ts" "测试历史管理器"
check_file "e2e/src/utils/test-scheduler.ts" "智能测试调度器"
check_file "e2e/src/utils/test-reporter.ts" "测试报告生成器"
check_file "e2e/src/utils/test-monitor.ts" "测试监控器"
check_file "e2e/src/utils/test-alert.ts" "测试告警管理器"
check_file "e2e/src/utils/test-optimizer.ts" "测试性能优化器"
echo ""
echo "📝 检查脚本文件"
echo "-------------------"
check_file "e2e/global-setup.ts" "全局设置脚本"
check_file "e2e/global-teardown.ts" "全局清理脚本"
check_file "e2e/scripts/generate-report.js" "CI报告生成脚本"
check_file "scripts/validate-woodpecker-config.js" "Woodpecker配置验证脚本"
echo ""
echo "📚 检查文档文件"
echo "-------------------"
check_file "README-TIERED-TESTING.md" "快速入门指南"
check_file "docs/test-optimization-guide.md" "测试优化指南"
check_file "docs/test-tiering-best-practices.md" "最佳实践文档"
echo ""
echo "🎯 检查NPM脚本"
echo "-------------------"
check_npm_script "test:tier:fast" "快速层测试脚本"
check_npm_script "test:tier:standard" "标准层测试脚本"
check_npm_script "test:tier:deep" "深度层测试脚本"
check_npm_script "test:tier:all" "所有层级测试脚本"
check_npm_script "test:tier:ci" "CI测试脚本"
echo ""
echo "📊 检查测试文件"
echo "-------------------"
check_dir "e2e/src/tests/smoke" "冒烟测试目录"
check_dir "e2e/src/tests/api" "API测试目录"
check_dir "e2e/src/tests/admin" "管理后台测试目录"
echo ""
echo "🔍 验证TypeScript编译"
echo "-------------------"
TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
if cd e2e && npx tsc --noEmit src/config/test-tiers.ts src/config/test-tags.ts src/utils/*.ts 2>/dev/null; then
echo -e "${GREEN}${NC} TypeScript编译通过"
PASSED_CHECKS=$((PASSED_CHECKS + 1))
else
echo -e "${RED}${NC} TypeScript编译失败"
FAILED_CHECKS=$((FAILED_CHECKS + 1))
fi
cd ..
echo ""
echo "📈 生成验证报告"
echo "=========================="
echo ""
echo "总检查项: $TOTAL_CHECKS"
echo -e "${GREEN}通过: $PASSED_CHECKS${NC}"
echo -e "${RED}失败: $FAILED_CHECKS${NC}"
echo ""
if [ $FAILED_CHECKS -eq 0 ]; then
echo -e "${GREEN}🎉 所有验证通过!分层测试系统已就绪。${NC}"
exit 0
else
echo -e "${YELLOW}⚠️ 发现 $FAILED_CHECKS 个问题,请检查并修复。${NC}"
exit 1
fi
@@ -1,59 +0,0 @@
const fs = require('fs');
const path = require('path');
console.log('🔍 验证Woodpecker CI配置...');
const configFiles = [
'.woodpecker/test-tiered.yml',
'.woodpecker/test-tiered-simple.yml',
];
let allValid = true;
for (const configFile of configFiles) {
const filePath = path.join(__dirname, '..', configFile);
if (!fs.existsSync(filePath)) {
console.log(`❌ 配置文件不存在: ${configFile}`);
allValid = false;
continue;
}
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes('when:') && content.includes('pipeline:')) {
console.log(`${configFile} - 配置格式正确`);
} else {
console.log(`${configFile} - 配置格式错误`);
allValid = false;
}
if (content.includes('TEST_TIER')) {
console.log(`${configFile} - 包含分层测试环境变量`);
} else {
console.log(`${configFile} - 缺少分层测试环境变量`);
allValid = false;
}
if (content.includes('depends_on')) {
console.log(`${configFile} - 包含任务依赖配置`);
} else {
console.log(`⚠️ ${configFile} - 未配置任务依赖`);
}
}
const reportScript = path.join(__dirname, '..', 'e2e/scripts/generate-report.js');
if (fs.existsSync(reportScript)) {
console.log(`✅ 测试报告脚本存在`);
} else {
console.log(`❌ 测试报告脚本不存在`);
allValid = false;
}
if (allValid) {
console.log('\n✅ 所有配置验证通过');
process.exit(0);
} else {
console.log('\n❌ 部分配置验证失败');
process.exit(1);
}
-21
View File
@@ -1,21 +0,0 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [
Sentry.replayIntegration({
maskAllText: true,
blockAllMedia: true,
}),
],
environment: process.env.NODE_ENV,
beforeSend(event) {
if (process.env.NODE_ENV === "development") {
return null;
}
return event;
},
});
-13
View File
@@ -1,13 +0,0 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
environment: process.env.NODE_ENV,
beforeSend(event) {
if (process.env.NODE_ENV === "development") {
return null;
}
return event;
},
});
-38
View File
@@ -1,38 +0,0 @@
#!/bin/bash
SSL_DIR="./ssl"
CERTBOT_DIR="/var/www/certbot"
DOMAIN="novalon.cn"
mkdir -p "$SSL_DIR"
mkdir -p "$CERTBOT_DIR"
echo "🔒 开始配置SSL证书..."
if [ ! -f "$SSL_DIR/fullchain.pem" ] || [ ! -f "$SSL_DIR/privkey.pem" ]; then
echo "📝 SSL证书不存在,需要手动配置Let's Encrypt证书"
echo "📋 请按照以下步骤操作:"
echo "1. 在服务器上安装certbot:"
echo " sudo apt-get update"
echo " sudo apt-get install certbot"
echo ""
echo "2. 获取SSL证书:"
echo " sudo certbot certonly --webroot -w $CERTBOT_DIR -d $DOMAIN -d www.$DOMAIN"
echo ""
echo "3. 复制证书文件到SSL目录:"
echo " sudo cp /etc/letsencrypt/live/$DOMAIN/fullchain.pem $SSL_DIR/"
echo " sudo cp /etc/letsencrypt/live/$DOMAIN/privkey.pem $SSL_DIR/"
echo ""
echo "4. 设置证书文件权限:"
echo " sudo chmod 644 $SSL_DIR/fullchain.pem"
echo " sudo chmod 600 $SSL_DIR/privkey.pem"
echo ""
echo "5. 配置自动续期:"
echo " 添加cron任务: 0 0,12 * * * certbot renew --quiet"
else
echo "✅ SSL证书已存在"
echo "📋 证书信息:"
ls -lh "$SSL_DIR"
fi
echo "🎉 SSL证书配置完成!"
+4 -4
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import Link from 'next/link'; import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { BackButton } from '@/components/ui/back-button'; import { BackButton } from '@/components/ui/back-button';
@@ -99,7 +99,7 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
</h2> </h2>
</div> </div>
<div className="prose prose-sm max-w-none"> <div className="prose prose-base max-w-none [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-[#1C1C1C] [&_h3]:mt-8 [&_h3]:mb-4 [&_p]:text-[#5C5C5C] [&_p]:leading-[1.8] [&_p]:mb-4 [&_p]:text-base">
<div dangerouslySetInnerHTML={{ __html: caseItem.content }} /> <div dangerouslySetInnerHTML={{ __html: caseItem.content }} />
</div> </div>
</section> </section>
@@ -237,9 +237,9 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
className="w-full bg-white text-[#C41E3A] hover:bg-white/90" className="w-full bg-white text-[#C41E3A] hover:bg-white/90"
asChild asChild
> >
<Link href="/contact"> <StaticLink href="/contact">
</Link> </StaticLink>
</Button> </Button>
</div> </div>
</div> </div>
+15 -20
View File
@@ -1,30 +1,17 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { contentService } from '@/lib/api/services'; import { CASES } from '@/lib/constants';
import { CaseDetailClient } from './client'; import { CaseDetailClient } from './client';
interface CaseItem {
id: string;
title: string;
excerpt: string;
content: string;
category: string;
slug: string;
date: string;
image?: string;
}
export async function generateStaticParams() { export async function generateStaticParams() {
const cases = await contentService.getCases(100); return CASES.map((caseItem) => ({
return cases.map((caseItem) => ({
id: caseItem.id, id: caseItem.id,
})); }));
} }
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const cases = await contentService.getCases(100); const caseItem = CASES.find((c) => c.id === id);
const caseItem = cases.find((c) => c.id === id);
if (!caseItem) { if (!caseItem) {
return { return {
@@ -34,18 +21,26 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
return { return {
title: `${caseItem.title} - 睿新致远`, title: `${caseItem.title} - 睿新致远`,
description: caseItem.excerpt, description: caseItem.description,
}; };
} }
export default async function CaseDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function CaseDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const cases = await contentService.getCases(100); const caseItem = CASES.find((c) => c.id === id);
const caseItem = cases.find((c) => c.id === id);
if (!caseItem) { if (!caseItem) {
notFound(); notFound();
} }
return <CaseDetailClient caseItem={caseItem as CaseItem} />; return <CaseDetailClient caseItem={{
id: caseItem.id,
title: caseItem.title,
excerpt: caseItem.description,
content: caseItem.content || '',
category: caseItem.industry,
slug: caseItem.id,
date: '2026-01-15',
image: caseItem.image,
}} />;
} }
-265
View File
@@ -1,265 +0,0 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
section: ({ children, className, ...props }: any) => (
<section className={className} {...props}>
{children}
</section>
),
},
AnimatePresence: ({ children }: any) => <>{children}</>,
useInView: () => [null, true],
}));
jest.mock('next/link', () => {
return ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
);
});
jest.mock('lucide-react', () => ({
ArrowRight: () => <span data-testid="arrow-right-icon" />,
ArrowLeft: () => <span data-testid="arrow-left-icon" />,
Building2: () => <span data-testid="building-icon" />,
Calendar: () => <span data-testid="calendar-icon" />,
TrendingUp: () => <span data-testid="trending-up-icon" />,
ChevronLeft: () => <span data-testid="chevron-left-icon" />,
ChevronRight: () => <span data-testid="chevron-right-icon" />,
Filter: () => <span data-testid="filter-icon" />,
Search: () => <span data-testid="search-icon" />,
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, className, variant, size, disabled, onClick, ...props }: any) => (
<button
className={className}
data-variant={variant}
data-size={size}
disabled={disabled}
onClick={onClick}
{...props}
>
{children}
</button>
),
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, className, variant, ...props }: any) => (
<span className={className} data-variant={variant} {...props}>
{children}
</span>
),
}));
jest.mock('@/components/ui/input', () => ({
Input: ({ className, ...props }: any) => (
<input className={className} {...props} />
),
}));
jest.mock('@/components/ui/page-header', () => ({
PageHeader: ({ title, description }: any) => (
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
),
}));
const mockCases = [
{
id: 'case-1',
title: '数字化转型案例',
excerpt: '帮助客户实现数字化转型',
content: '详细的数字化转型案例内容',
category: '制造业',
slug: 'digital-transformation',
date: '2024-01-15',
},
{
id: 'case-2',
title: 'ERP系统实施案例',
excerpt: 'ERP系统成功实施',
content: '详细的ERP系统实施案例内容',
category: '零售业',
slug: 'erp-implementation',
date: '2024-01-10',
},
{
id: 'case-3',
title: '智能制造升级',
excerpt: '智能制造系统升级',
content: '详细的智能制造升级案例内容',
category: '制造业',
slug: 'smart-manufacturing',
date: '2024-01-05',
},
];
jest.mock('@/lib/api/services', () => ({
contentService: {
getNews: jest.fn(),
},
}));
import CasesPage from './page';
import { contentService } from '@/lib/api/services';
describe('CasesPage', () => {
beforeEach(() => {
jest.clearAllMocks();
(contentService.getNews as jest.Mock).mockResolvedValue(mockCases);
});
describe('Rendering', () => {
it('should render loading state initially', () => {
render(<CasesPage />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
});
it('should render cases page after loading', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const pageContainer = document.querySelector('.min-h-screen');
expect(pageContainer).toBeInTheDocument();
});
it('should render page header', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const title = screen.getByText(/与谁同行/i);
expect(title).toBeInTheDocument();
});
it('should render back to home link', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const backLink = screen.getByText(/返回首页/i);
expect(backLink).toBeInTheDocument();
});
it('should render case cards', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const caseTitles = screen.getAllByRole('heading', { level: 3 });
expect(caseTitles.length).toBeGreaterThan(0);
});
it('should render case categories', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
expect(screen.getByText('全部')).toBeInTheDocument();
expect(screen.getByText('金融')).toBeInTheDocument();
expect(screen.getByText('制造')).toBeInTheDocument();
});
it('should render CTA section', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const cta = screen.getByText(/准备开始您的数字化转型之旅/i);
expect(cta).toBeInTheDocument();
});
});
describe('Navigation', () => {
it('should have case detail links', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const links = screen.getAllByRole('link');
const caseLinks = links.filter(link => link.getAttribute('href')?.startsWith('/cases/'));
expect(caseLinks.length).toBeGreaterThan(0);
});
it('should have contact links', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const links = screen.getAllByRole('link');
const contactLinks = links.filter(link => link.getAttribute('href') === '/contact');
expect(contactLinks.length).toBeGreaterThan(0);
});
});
describe('Accessibility', () => {
it('should have proper heading hierarchy', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1).toBeInTheDocument();
});
});
describe('Filtering', () => {
it('should render filter buttons', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const filterButtons = screen.getAllByRole('button');
expect(filterButtons.length).toBeGreaterThan(0);
});
});
describe('Error Handling', () => {
it('should display error message when API fails', async () => {
(contentService.getNews as jest.Mock).mockRejectedValue(new Error('API Error'));
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const errorMessage = screen.getByText(/加载案例失败/i);
expect(errorMessage).toBeInTheDocument();
});
});
});
+13 -79
View File
@@ -1,74 +1,35 @@
'use client'; 'use client';
import { useState, useMemo, useRef, useEffect, ChangeEvent } from 'react'; import { useState, useMemo, useRef, ChangeEvent } from 'react';
import { useInView } from 'framer-motion'; import { useInView } from 'framer-motion';
import { contentService } from '@/lib/api/services'; import { CASES } from '@/lib/constants';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/ui/page-header'; import { PageHeader } from '@/components/ui/page-header';
import { Search, ArrowLeft, Building2, Calendar, TrendingUp, ChevronLeft, ChevronRight, Filter } from 'lucide-react'; import { Search, ArrowLeft, Building2, Calendar, TrendingUp, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
import Link from 'next/link'; import { StaticLink } from '@/components/ui/static-link';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
const industries = ['全部', '金融', '制造', '零售', '医疗', '教育']; const industries = ['全部', ...Array.from(new Set(CASES.map((c) => c.industry)))];
const ITEMS_PER_PAGE = 6; const ITEMS_PER_PAGE = 6;
interface CaseItem {
id: string;
title: string;
description: string;
industry: string;
client: string;
slug: string;
}
export default function CasesPage() { export default function CasesPage() {
const [selectedIndustry, setSelectedIndustry] = useState('全部'); const [selectedIndustry, setSelectedIndustry] = useState('全部');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [cases, setCases] = useState<CaseItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const contentRef = useRef(null); const contentRef = useRef(null);
const isContentInView = useInView(contentRef, { once: true, margin: '-100px' }); const isContentInView = useInView(contentRef, { once: true, margin: '-100px' });
const fetchCases = async () => {
try {
setLoading(true);
setError(null);
const data = await contentService.getNews(['案例'], 100, 'desc');
const caseItems: CaseItem[] = data.map(item => ({
id: item.id,
title: item.title,
description: item.excerpt,
industry: item.category,
client: '客户企业',
slug: item.slug,
}));
setCases(caseItems);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch cases'));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCases();
}, []);
const filteredCases = useMemo(() => { const filteredCases = useMemo(() => {
if (!cases || cases.length === 0) {return [];} return CASES.filter((caseItem) => {
return cases.filter((caseItem) => {
const matchesIndustry = selectedIndustry === '全部' || caseItem.industry === selectedIndustry; const matchesIndustry = selectedIndustry === '全部' || caseItem.industry === selectedIndustry;
const matchesSearch = const matchesSearch =
caseItem.title.toLowerCase().includes(searchQuery.toLowerCase()) || caseItem.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
caseItem.description.toLowerCase().includes(searchQuery.toLowerCase()); caseItem.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchesIndustry && matchesSearch; return matchesIndustry && matchesSearch;
}); });
}, [cases, selectedIndustry, searchQuery]); }, [selectedIndustry, searchQuery]);
const totalPages = Math.ceil(filteredCases.length / ITEMS_PER_PAGE); const totalPages = Math.ceil(filteredCases.length / ITEMS_PER_PAGE);
const paginatedCases = useMemo(() => { const paginatedCases = useMemo(() => {
@@ -92,28 +53,6 @@ export default function CasesPage() {
setCurrentPage(1); setCurrentPage(1);
}; };
if (loading) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#C41E3A] mx-auto mb-4" />
<p className="text-[#5C5C5C]">...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 mb-4"></p>
<Button onClick={() => window.location.reload()}></Button>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<PageHeader <PageHeader
@@ -123,11 +62,6 @@ export default function CasesPage() {
<div className="container-wide relative z-10 py-16" ref={contentRef}> <div className="container-wide relative z-10 py-16" ref={contentRef}>
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<Link href="/" className="inline-flex items-center text-[#5C5C5C] hover:text-[#C41E3A] transition-colors mb-8">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}} animate={isContentInView ? { opacity: 1, y: 0 } : {}}
@@ -183,8 +117,8 @@ export default function CasesPage() {
animate={isContentInView ? { opacity: 1, y: 0 } : {}} animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
> >
<Link <StaticLink
href={`/cases/${caseItem.slug}`} href={`/cases/${caseItem.id}`}
className="group bg-white rounded-2xl border border-[#E5E5E5] overflow-hidden hover:shadow-xl transition-all duration-300 block" className="group bg-white rounded-2xl border border-[#E5E5E5] overflow-hidden hover:shadow-xl transition-all duration-300 block"
> >
<div className="relative h-48 bg-gradient-to-br from-[#F5F5F5] to-[#E5E5E5] overflow-hidden"> <div className="relative h-48 bg-gradient-to-br from-[#F5F5F5] to-[#E5E5E5] overflow-hidden">
@@ -228,7 +162,7 @@ export default function CasesPage() {
<ArrowLeft className="w-4 h-4 ml-2 rotate-180" /> <ArrowLeft className="w-4 h-4 ml-2 rotate-180" />
</div> </div>
</div> </div>
</Link> </StaticLink>
</motion.div> </motion.div>
))} ))}
</div> </div>
@@ -293,15 +227,15 @@ export default function CasesPage() {
</p> </p>
<div className="flex justify-center gap-4"> <div className="flex justify-center gap-4">
<Link href="/contact"> <StaticLink href="/contact">
<Button <Button
size="lg" size="lg"
variant="outline" variant="outline"
> >
</Button> </Button>
</Link> </StaticLink>
<Link href="/contact"> <StaticLink href="/contact">
<Button <Button
size="lg" size="lg"
className="bg-[#C41E3A] hover:bg-[#A01830] text-white" className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
@@ -309,7 +243,7 @@ export default function CasesPage() {
<ArrowLeft className="ml-2 w-4 h-4 rotate-180" /> <ArrowLeft className="ml-2 w-4 h-4 rotate-180" />
</Button> </Button>
</Link> </StaticLink>
</div> </div>
</div> </div>
</motion.div> </motion.div>
-266
View File
@@ -1,266 +0,0 @@
'use server';
import { Resend } from 'resend';
import { z } from 'zod';
const resend = new Resend(process.env.RESEND_API_KEY);
const companyEmail = process.env.COMPANY_EMAIL || 'contact@novalon.cn';
const contactFormSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号码'),
email: z.string().email('请输入有效的邮箱地址'),
subject: z.string().min(2, '主题至少需要2个字符'),
message: z.string().min(10, '留言内容至少需要10个字符'),
website: z.string().optional(),
submitTime: z.string().optional(),
mathHash: z.string().optional(),
mathTimestamp: z.string().optional(),
mathAnswer: z.string().optional(),
});
export interface ContactFormState {
success: boolean;
message?: string;
error?: string;
errors?: Record<string, string>;
}
export async function submitContactForm(
_prevState: ContactFormState | null,
formData: FormData
): Promise<ContactFormState> {
const rawData = {
name: formData.get('name') as string,
phone: formData.get('phone') as string,
email: formData.get('email') as string,
subject: formData.get('subject') as string,
message: formData.get('message') as string,
website: formData.get('website') as string,
submitTime: formData.get('submitTime') as string,
mathHash: formData.get('mathHash') as string,
mathTimestamp: formData.get('mathTimestamp') as string,
mathAnswer: formData.get('mathAnswer') as string,
};
const validationResult = contactFormSchema.safeParse(rawData);
if (!validationResult.success) {
const errors: Record<string, string> = {};
validationResult.error.issues.forEach((issue) => {
const field = issue.path[0] as string;
errors[field] = issue.message;
});
return { success: false, error: '请检查表单字段', errors };
}
const data = validationResult.data;
if (data.website) {
console.log('Honeypot field filled, rejecting request');
return { success: true, message: '消息已发送' };
}
if (data.submitTime) {
const timeDiff = Date.now() - parseInt(data.submitTime);
if (timeDiff < 2000) {
console.log('Submission too fast:', timeDiff);
return { success: false, error: '提交过快,请稍后再试' };
}
}
if (data.mathHash && data.mathTimestamp && data.mathAnswer !== undefined) {
const expectedHash = btoa(`${data.mathAnswer}-${data.mathTimestamp}`);
if (expectedHash !== data.mathHash) {
console.log('Invalid math captcha');
return { success: false, error: '验证码错误,请重新计算' };
}
}
const emailContent = `
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
line-height: 1.6;
color: #1C1C1C;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #ffffff;
}
.header {
background: #C41E3A;
color: white;
padding: 40px 30px;
text-align: center;
border-radius: 8px 8px 0 0;
}
.header h1 {
margin: 0;
font-size: 28px;
font-weight: 600;
}
.header p {
margin: 10px 0 0 0;
font-size: 14px;
opacity: 0.9;
}
.content {
padding: 40px 30px;
background: #ffffff;
}
.info-card {
background: #f9f9f9;
border-radius: 8px;
padding: 20px;
margin-bottom: 25px;
border: 1px solid #e5e5e5;
}
.info-row {
display: flex;
margin-bottom: 12px;
align-items: flex-start;
}
.info-row:last-child {
margin-bottom: 0;
}
.info-label {
font-weight: 600;
color: #1C1C1C;
min-width: 70px;
font-size: 14px;
}
.info-value {
color: #5C5C5C;
font-size: 14px;
flex: 1;
}
.message-box {
background: #fff;
padding: 20px;
border-left: 4px solid #C41E3A;
margin-top: 20px;
border-radius: 0 8px 8px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.message-label {
font-weight: 600;
color: #C41E3A;
font-size: 14px;
margin-bottom: 10px;
}
.message-content {
color: #1C1C1C;
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
}
.footer {
text-align: center;
padding: 30px;
color: #8C8C8C;
font-size: 12px;
border-top: 1px solid #e5e5e5;
}
.footer a {
color: #C41E3A;
text-decoration: none;
}
.divider {
height: 1px;
background: #e5e5e5;
margin: 25px 0;
}
.badge {
display: inline-block;
background: #C41E3A;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📬 新的客户咨询</h1>
<p>来自 睿新致远官方网站</p>
</div>
<div class="content">
<span class="badge">新消息</span>
<div class="info-card">
<div class="info-row">
<div class="info-label">姓名</div>
<div class="info-value">${data.name}</div>
</div>
<div class="info-row">
<div class="info-label">邮箱</div>
<div class="info-value"><a href="mailto:${data.email}" style="color: #C41E3A; text-decoration: none;">${data.email}</a></div>
</div>
${data.phone ? `
<div class="info-row">
<div class="info-label">电话</div>
<div class="info-value">${data.phone}</div>
</div>
` : ''}
<div class="info-row">
<div class="info-label">主题</div>
<div class="info-value">${data.subject}</div>
</div>
</div>
<div class="message-box">
<div class="message-label">咨询内容</div>
<div class="message-content">${data.message}</div>
</div>
<div class="divider"></div>
<div style="text-align: center; color: #8C8C8C; font-size: 13px;">
<p>💡 提示:点击邮箱地址可直接回复客户</p>
</div>
</div>
<div class="footer">
<p style="margin-bottom: 10px;">本邮件由 睿新致远 官网联系表单自动发送,请勿直接回复此邮件</p>
<p style="margin-bottom: 10px;">如需回复客户,请点击上方邮箱地址或直接回复客户的原始邮件</p>
<p style="margin-bottom: 15px;">提交时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</p>
<p style="margin-top: 15px; border-top: 1px solid #e5e5e5; padding-top: 15px;">© ${new Date().getFullYear()} 四川睿新致远科技有限公司. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
try {
const { data: emailData, error } = await resend.emails.send({
from: '睿新致远官网 <onboarding@resend.dev>',
to: [companyEmail],
subject: `📧 ${data.subject} - ${data.name}`,
html: emailContent,
replyTo: data.email,
});
if (error) {
console.error('Resend API error:', error);
return { success: false, error: '邮件发送失败,请稍后重试' };
}
console.log('Email sent successfully:', emailData);
return { success: true, message: '消息已发送' };
} catch (error) {
console.error('Contact form submission error:', error);
return { success: false, error: '提交失败,请重试' };
}
}
+33 -57
View File
@@ -1,16 +1,13 @@
'use client'; 'use client';
import { useState, useEffect, useRef, useActionState } from 'react'; import { useState, useEffect, useRef } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Toast } from '@/components/ui/toast'; import { Toast } from '@/components/ui/toast';
import { sanitizeInput } from '@/lib/sanitize';
import { generateCSRFToken, setCSRFTokenToStorage } from '@/lib/csrf';
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2 } from 'lucide-react'; import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2 } from 'lucide-react';
import { COMPANY_INFO } from '@/lib/constants'; import { COMPANY_INFO } from '@/lib/constants';
import { submitContactForm, ContactFormState } from './actions';
const contactFormSchema = z.object({ const contactFormSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符'), name: z.string().min(2, '姓名至少需要2个字符'),
@@ -35,7 +32,8 @@ export default function ContactPage() {
const [showToast, setShowToast] = useState(false); const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState(''); const [toastMessage, setToastMessage] = useState('');
const [toastType, setToastType] = useState<'success' | 'error'>('success'); const [toastType, setToastType] = useState<'success' | 'error'>('success');
const [csrfToken, setCsrfToken] = useState<string>(''); const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [formData, setFormData] = useState<ContactFormData>({ const [formData, setFormData] = useState<ContactFormData>({
name: '', name: '',
phone: '', phone: '',
@@ -46,47 +44,12 @@ export default function ContactPage() {
const [errors, setErrors] = useState<FormErrors>({}); const [errors, setErrors] = useState<FormErrors>({});
const sectionRef = useRef<HTMLElement>(null); const sectionRef = useRef<HTMLElement>(null);
const [state, formAction, isPending] = useActionState(
submitContactForm,
null as ContactFormState | null
);
const isSubmitted = state?.success === true;
const isSubmitting = isPending;
useEffect(() => { useEffect(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
setIsVisible(true); setIsVisible(true);
const token = generateCSRFToken();
setCsrfToken(token);
setCSRFTokenToStorage(token);
}); });
}, []); }, []);
useEffect(() => {
if (state) {
requestAnimationFrame(() => {
if (state.success) {
setToastMessage(state.message || '表单提交成功!我们会尽快与您联系。');
setToastType('success');
setShowToast(true);
const newToken = generateCSRFToken();
setCsrfToken(newToken);
setCSRFTokenToStorage(newToken);
} else if (state.error) {
setToastMessage(state.error);
setToastType('error');
setShowToast(true);
if (state.errors) {
setErrors(state.errors);
}
}
});
}
}, [state]);
const validateField = (field: keyof ContactFormData, value: string) => { const validateField = (field: keyof ContactFormData, value: string) => {
try { try {
contactFormSchema.shape[field].parse(value); contactFormSchema.shape[field].parse(value);
@@ -102,10 +65,9 @@ export default function ContactPage() {
}; };
const handleChange = (field: keyof ContactFormData, value: string) => { const handleChange = (field: keyof ContactFormData, value: string) => {
const sanitizedValue = sanitizeInput(value); setFormData((prev) => ({ ...prev, [field]: value }));
setFormData((prev) => ({ ...prev, [field]: sanitizedValue }));
if (errors[field]) { if (errors[field]) {
validateField(field, sanitizedValue); validateField(field, value);
} }
}; };
@@ -113,16 +75,9 @@ export default function ContactPage() {
validateField(field, value); validateField(field, value);
}; };
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
if (!csrfToken) {
setToastMessage('安全验证失败,请刷新页面重试。');
setToastType('error');
setShowToast(true);
return;
}
const result = contactFormSchema.safeParse(formData); const result = contactFormSchema.safeParse(formData);
if (!result.success) { if (!result.success) {
@@ -135,14 +90,36 @@ export default function ContactPage() {
return; return;
} }
const form = e.currentTarget; setIsSubmitting(true);
const formDataObj = new FormData(form); try {
formDataObj.set('submitTime', Date.now().toString()); const response = await fetch('https://formspree.io/f/' + process.env.NEXT_PUBLIC_FORMSPREE_ID, {
formAction(formDataObj); method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(formData),
});
if (response.ok) {
setIsSubmitted(true);
setToastMessage('表单提交成功!我们会尽快与您联系。');
setToastType('success');
setShowToast(true);
setFormData({ name: '', phone: '', email: '', subject: '', message: '' });
} else {
setToastMessage('提交失败,请稍后重试或直接发送邮件联系我们。');
setToastType('error');
setShowToast(true);
}
} catch {
setToastMessage('网络错误,请稍后重试。');
setToastType('error');
setShowToast(true);
} finally {
setIsSubmitting(false);
}
} }
return ( return (
<main className="min-h-screen bg-white pt-16"> <main className="min-h-screen bg-white">
{showToast && ( {showToast && (
<Toast <Toast
message={toastMessage} message={toastMessage}
@@ -265,7 +242,6 @@ export default function ContactPage() {
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="space-y-5 flex-1 flex flex-col"> <form onSubmit={handleSubmit} className="space-y-5 flex-1 flex flex-col">
<input type="hidden" name="_csrf" value={csrfToken} />
<input type="text" name="website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" /> <input type="text" name="website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input <Input
+4 -18
View File
@@ -46,17 +46,7 @@ const NewsSection = dynamic(
} }
); );
interface SiteConfig { function HomeContent() {
feature_services?: { enabled: boolean; items: string[] };
feature_products?: { enabled: boolean; showPricing: boolean; featuredProducts: string[] };
feature_news?: { enabled: boolean; displayCount: number; categories: string[]; sortOrder: 'asc' | 'desc' };
}
interface HomeContentProps {
config: SiteConfig;
}
function HomeContent({ config }: HomeContentProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
@@ -74,18 +64,14 @@ function HomeContent({ config }: HomeContentProps) {
return undefined; return undefined;
}, [searchParams]); }, [searchParams]);
const showServices = config.feature_services?.enabled !== false;
const showProducts = config.feature_products?.enabled !== false;
const showNews = config.feature_news?.enabled !== false;
return ( return (
<main className="min-h-screen bg-white dark:bg-(--color-bg-primary)"> <main className="min-h-screen bg-white dark:bg-(--color-bg-primary)">
<HeroSection /> <HeroSection />
{showServices && <ServicesSection config={config.feature_services} />} <ServicesSection />
{showProducts && <ProductsSection config={config.feature_products} />} <ProductsSection />
<CasesSection /> <CasesSection />
<AboutSection /> <AboutSection />
{showNews && <NewsSection config={config.feature_news} />} <NewsSection />
</main> </main>
); );
} }
+1 -1
View File
@@ -28,7 +28,7 @@ export default function MarketingLayout({
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<Header /> <Header />
<ErrorBoundary> <ErrorBoundary>
<main className="flex-1"> <main className="flex-1 pt-16">
{breadcrumbItem && ( {breadcrumbItem && (
<div className="container-wide"> <div className="container-wide">
<Breadcrumb items={[breadcrumbItem]} /> <Breadcrumb items={[breadcrumbItem]} />
@@ -1,10 +1,10 @@
'use client'; 'use client';
import Link from 'next/link'; import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { BackButton } from '@/components/ui/back-button'; import { BackButton } from '@/components/ui/back-button';
import { Calendar } from 'lucide-react'; import { Calendar, ArrowLeft } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useInView } from 'framer-motion'; import { useInView } from 'framer-motion';
import { useRef } from 'react'; import { useRef } from 'react';
@@ -72,7 +72,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
</h2> </h2>
<div className="grid md:grid-cols-3 gap-6"> <div className="grid md:grid-cols-3 gap-6">
{relatedNews.map((related) => ( {relatedNews.map((related) => (
<Link key={related.id} href={`/news/${related.id}`}> <StaticLink key={related.id} href={`/news/${related.id}`}>
<div className="group cursor-pointer"> <div className="group cursor-pointer">
<div className="aspect-video bg-linear-to-br from-[#C41E3A]/10 to-[#1C1C1C]/10 rounded-lg mb-4 flex items-center justify-center group-hover:shadow-lg transition-shadow"> <div className="aspect-video bg-linear-to-br from-[#C41E3A]/10 to-[#1C1C1C]/10 rounded-lg mb-4 flex items-center justify-center group-hover:shadow-lg transition-shadow">
<span className="text-4xl">📰</span> <span className="text-4xl">📰</span>
@@ -87,23 +87,24 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
{related.excerpt} {related.excerpt}
</p> </p>
</div> </div>
</Link> </StaticLink>
))} ))}
</div> </div>
</div> </div>
)} )}
<div className="mt-16 flex justify-center gap-4"> <div className="mt-16 flex justify-center gap-4">
<Link href="/news"> <StaticLink href="/news">
<Button variant="outline" size="lg"> <Button variant="outline" size="lg">
<ArrowLeft className="w-4 h-4 mr-2" />
</Button> </Button>
</Link> </StaticLink>
<Link href="/contact"> <StaticLink href="/contact">
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white"> <Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white">
</Button> </Button>
</Link> </StaticLink>
</div> </div>
</motion.div> </motion.div>
</div> </div>
+7 -38
View File
@@ -2,14 +2,14 @@
import { useState, useMemo, useRef, ChangeEvent } from 'react'; import { useState, useMemo, useRef, ChangeEvent } from 'react';
import { useInView } from 'framer-motion'; import { useInView } from 'framer-motion';
import { useNews } from '@/hooks/use-news'; import { NEWS } from '@/lib/constants';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/ui/page-header'; import { PageHeader } from '@/components/ui/page-header';
import { Search, Calendar, ArrowLeft, Filter, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react'; import { Search, Calendar, Filter, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import Link from 'next/link'; import { StaticLink } from '@/components/ui/static-link';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
const categories = ['全部', '公司新闻', '产品发布', '合作动态', '行业资讯']; const categories = ['全部', '公司新闻', '产品发布', '合作动态', '行业资讯'];
@@ -21,19 +21,15 @@ export default function NewsListPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const contentRef = useRef(null); const contentRef = useRef(null);
const isContentInView = useInView(contentRef, { once: true, margin: '-100px' }); const isContentInView = useInView(contentRef, { once: true, margin: '-100px' });
const { news, loading, error } = useNews();
const filteredNews = useMemo(() => { const filteredNews = useMemo(() => {
if (!news || news.length === 0) {return [];} return NEWS.filter((newsItem) => {
return news.filter((newsItem) => {
const matchesCategory = selectedCategory === '全部' || newsItem.category === selectedCategory; const matchesCategory = selectedCategory === '全部' || newsItem.category === selectedCategory;
const matchesSearch = const matchesSearch =
newsItem.title.toLowerCase().includes(searchQuery.toLowerCase()) || newsItem.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
newsItem.excerpt.toLowerCase().includes(searchQuery.toLowerCase()); newsItem.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch; return matchesCategory && matchesSearch;
}); });
}, [news, selectedCategory, searchQuery]); }, [selectedCategory, searchQuery]);
const totalPages = Math.ceil(filteredNews.length / ITEMS_PER_PAGE); const totalPages = Math.ceil(filteredNews.length / ITEMS_PER_PAGE);
const paginatedNews = useMemo(() => { const paginatedNews = useMemo(() => {
@@ -57,28 +53,6 @@ export default function NewsListPage() {
setCurrentPage(1); setCurrentPage(1);
}; };
if (loading) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#C41E3A] mx-auto mb-4" />
<p className="text-[#5C5C5C]">...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 mb-4"></p>
<Button onClick={() => window.location.reload()}></Button>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<PageHeader <PageHeader
@@ -87,11 +61,6 @@ export default function NewsListPage() {
/> />
<div className="container-wide relative z-10 py-12" ref={contentRef}> <div className="container-wide relative z-10 py-12" ref={contentRef}>
<Link href="/" className="inline-flex items-center text-[#5C5C5C] hover:text-[#C41E3A] transition-colors mb-8">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}} animate={isContentInView ? { opacity: 1, y: 0 } : {}}
@@ -147,7 +116,7 @@ export default function NewsListPage() {
animate={isContentInView ? { opacity: 1, y: 0 } : {}} animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }} transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
> >
<Link href={`/news/${newsItem.id}`}> <StaticLink href={`/news/${newsItem.id}`}>
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A]"> <Card className="h-full hover:shadow-lg transition-shadow cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A]">
<CardContent className="p-0"> <CardContent className="p-0">
{newsItem.image ? ( {newsItem.image ? (
@@ -184,7 +153,7 @@ export default function NewsListPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</Link> </StaticLink>
</motion.div> </motion.div>
))} ))}
</div> </div>
+2 -28
View File
@@ -1,37 +1,11 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { db } from '@/db';
import { siteConfig } from '@/db/schema';
import { HomeContent } from './home-content'; import { HomeContent } from './home-content';
import { SectionSkeleton } from '@/components/ui/loading-skeleton'; import { SectionSkeleton } from '@/components/ui/loading-skeleton';
interface SiteConfig { export default function HomePage() {
feature_services?: { enabled: boolean; items: string[] };
feature_products?: { enabled: boolean; showPricing: boolean; featuredProducts: string[] };
feature_news?: { enabled: boolean; displayCount: number; categories: string[]; sortOrder: 'asc' | 'desc' };
}
async function getSiteConfig(): Promise<SiteConfig> {
try {
const allConfigs = await db.select().from(siteConfig);
const configMap = allConfigs.reduce((acc, config) => {
acc[config.key] = config.value;
return acc;
}, {} as Record<string, any>);
return configMap as SiteConfig;
} catch (error) {
console.error('获取配置失败:', error);
return {};
}
}
export default async function HomePage() {
const config = await getSiteConfig();
return ( return (
<Suspense fallback={<SectionSkeleton />}> <Suspense fallback={<SectionSkeleton />}>
<HomeContent config={config} /> <HomeContent />
</Suspense> </Suspense>
); );
} }
+5 -5
View File
@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import Link from 'next/link'; import { StaticLink } from '@/components/ui/static-link';
import { PRODUCTS } from '@/lib/constants'; import { PRODUCTS } from '@/lib/constants';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { BackButton } from '@/components/ui/back-button'; import { BackButton } from '@/components/ui/back-button';
@@ -211,15 +211,15 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
<div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]"> <div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]">
<Button variant="outline" size="lg" asChild> <Button variant="outline" size="lg" asChild>
<Link href="/contact"> <StaticLink href="/contact">
</Link> </StaticLink>
</Button> </Button>
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild> <Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
<Link href="/contact"> <StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Link> </StaticLink>
</Button> </Button>
</div> </div>
</div> </div>
+8 -39
View File
@@ -2,14 +2,14 @@
import { useState, useMemo, useRef, ChangeEvent } from 'react'; import { useState, useMemo, useRef, ChangeEvent } from 'react';
import { useInView } from 'framer-motion'; import { useInView } from 'framer-motion';
import { useProducts } from '@/hooks/use-products'; import { PRODUCTS } from '@/lib/constants';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/ui/page-header'; import { PageHeader } from '@/components/ui/page-header';
import { Search, ArrowLeft, Check, TrendingUp, ChevronLeft, ChevronRight, Filter } from 'lucide-react'; import { Search, ArrowLeft, Check, TrendingUp, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
import Link from 'next/link'; import { StaticLink } from '@/components/ui/static-link';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
const categories = ['全部', '软件产品', '云服务', '数据分析', '信息安全']; const categories = ['全部', '软件产品', '云服务', '数据分析', '信息安全'];
@@ -21,19 +21,15 @@ export default function ProductsPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const contentRef = useRef(null); const contentRef = useRef(null);
const isContentInView = useInView(contentRef, { once: true, margin: '-100px' }); const isContentInView = useInView(contentRef, { once: true, margin: '-100px' });
const { products, loading, error } = useProducts();
const filteredProducts = useMemo(() => { const filteredProducts = useMemo(() => {
if (!products || products.length === 0) return []; return PRODUCTS.filter((product) => {
return products.filter((product) => {
const matchesCategory = selectedCategory === '全部' || product.category === selectedCategory; const matchesCategory = selectedCategory === '全部' || product.category === selectedCategory;
const matchesSearch = const matchesSearch =
product.title.toLowerCase().includes(searchQuery.toLowerCase()) || product.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
product.description.toLowerCase().includes(searchQuery.toLowerCase()); product.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch; return matchesCategory && matchesSearch;
}); });
}, [products, selectedCategory, searchQuery]); }, [selectedCategory, searchQuery]);
const totalPages = Math.ceil(filteredProducts.length / ITEMS_PER_PAGE); const totalPages = Math.ceil(filteredProducts.length / ITEMS_PER_PAGE);
const paginatedProducts = useMemo(() => { const paginatedProducts = useMemo(() => {
@@ -57,28 +53,6 @@ export default function ProductsPage() {
setCurrentPage(1); setCurrentPage(1);
}; };
if (loading) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#C41E3A] mx-auto mb-4"></div>
<p className="text-[#5C5C5C]">...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 mb-4"></p>
<Button onClick={() => window.location.reload()}></Button>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<PageHeader <PageHeader
@@ -88,11 +62,6 @@ export default function ProductsPage() {
<div className="container-wide relative z-10 py-16" ref={contentRef}> <div className="container-wide relative z-10 py-16" ref={contentRef}>
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<Link href="/" className="inline-flex items-center text-[#5C5C5C] hover:text-[#C41E3A] transition-colors mb-8">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}} animate={isContentInView ? { opacity: 1, y: 0 } : {}}
@@ -148,7 +117,7 @@ export default function ProductsPage() {
animate={isContentInView ? { opacity: 1, y: 0 } : {}} animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
> >
<Link href={`/products/${product.slug}`}> <StaticLink href={`/products/${product.id}`}>
<Card className="h-full group cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A] transition-colors"> <Card className="h-full group cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
<CardHeader> <CardHeader>
<Badge variant="secondary" className="w-fit mb-3"> <Badge variant="secondary" className="w-fit mb-3">
@@ -197,7 +166,7 @@ export default function ProductsPage() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
</Link> </StaticLink>
</motion.div> </motion.div>
))} ))}
</div> </div>
@@ -266,10 +235,10 @@ export default function ProductsPage() {
className="bg-[#C41E3A] hover:bg-[#A01830] text-white" className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
asChild asChild
> >
<Link href="/contact"> <StaticLink href="/contact">
<ArrowLeft className="ml-2 w-4 h-4 rotate-180" /> <ArrowLeft className="ml-2 w-4 h-4 rotate-180" />
</Link> </StaticLink>
</Button> </Button>
</div> </div>
</motion.div> </motion.div>
+34 -34
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useRef } from 'react';
import Link from 'next/link'; import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { BackButton } from '@/components/ui/back-button'; import { BackButton } from '@/components/ui/back-button';
@@ -26,19 +26,19 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg> </svg>
), ),
Cloud: () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
),
BarChart3: () => ( BarChart3: () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg> </svg>
), ),
Shield: () => ( Lightbulb: () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
),
Puzzle: () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
</svg> </svg>
), ),
}; };
@@ -50,23 +50,23 @@ const challenges = {
{ title: '项目延期', description: '开发进度难以把控,上线时间一拖再拖' }, { title: '项目延期', description: '开发进度难以把控,上线时间一拖再拖' },
{ title: '维护成本高', description: '系统上线后问题不断,运维压力巨大' }, { title: '维护成本高', description: '系统上线后问题不断,运维压力巨大' },
], ],
cloud: [
{ title: '资源浪费', description: '服务器资源利用率低,成本居高不下' },
{ title: '扩展困难', description: '业务增长时系统无法快速扩容' },
{ title: '迁移风险', description: '担心数据丢失、业务中断' },
{ title: '安全顾虑', description: '不确定云端数据是否安全' },
],
data: [ data: [
{ title: '数据孤岛', description: '各系统数据分散,无法整合分析' }, { title: '数据孤岛', description: '各系统数据分散,无法整合分析' },
{ title: '决策盲区', description: '缺乏数据支撑,决策凭感觉' }, { title: '决策盲区', description: '缺乏数据支撑,决策凭感觉' },
{ title: '报表滞后', description: '手工制作报表,时效性差' }, { title: '报表滞后', description: '手工制作报表,时效性差' },
{ title: '价值难挖', description: '数据很多,但不知道怎么用' }, { title: '价值难挖', description: '数据很多,但不知道怎么用' },
], ],
security: [ consulting: [
{ title: '安全漏洞', description: '系统存在未知漏洞,随时可能被攻击' }, { title: '方向不明', description: '数字化转型不知道从哪里入手' },
{ title: '合规压力', description: '监管要求越来越严,不知如何应对' }, { title: '技术债务', description: '历史系统包袱重,新技术难以引入' },
{ title: '内部威胁', description: '员工操作不规范,数据泄露风险' }, { title: '人才短缺', description: '缺乏专业的技术规划和架构人才' },
{ title: '应急能力弱', description: '安全事件发生后不知所措' }, { title: '投入浪费', description: 'IT投入不少,但看不到明显效果' },
],
solutions: [
{ title: '行业壁垒', description: '不了解行业最佳实践,走弯路' },
{ title: '方案碎片化', description: '各系统各自为政,无法协同' },
{ title: '实施风险', description: '大型项目实施失败率高' },
{ title: '效果难量化', description: '投入产出比不清晰,难以评估' },
], ],
}; };
@@ -76,20 +76,20 @@ const outcomes = {
{ value: '50%', label: '返工率降低' }, { value: '50%', label: '返工率降低' },
{ value: '100%', label: '按时交付率' }, { value: '100%', label: '按时交付率' },
], ],
cloud: [
{ value: '40%', label: '成本降低' },
{ value: '99.9%', label: '可用性保障' },
{ value: '10x', label: '弹性扩展能力' },
],
data: [ data: [
{ value: '70%', label: '决策效率提升' }, { value: '70%', label: '决策效率提升' },
{ value: '实时', label: '数据更新' }, { value: '实时', label: '数据更新' },
{ value: '100+', label: '可视化报表' }, { value: '100+', label: '可视化报表' },
], ],
security: [ consulting: [
{ value: '99%', label: '漏洞修复率' }, { value: '60%', label: '方向明确度' },
{ value: '100%', label: '合规达标' }, { value: '40%', label: '试错成本降低' },
{ value: '24/7', label: '安全监控' }, { value: '3x', label: '转型速度提升' },
],
solutions: [
{ value: '50%', label: '实施周期缩短' },
{ value: '30%', label: '成本降低' },
{ value: '95%', label: '客户满意度' },
], ],
}; };
@@ -238,7 +238,7 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
</div> </div>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
{relatedCases.map((caseItem) => ( {relatedCases.map((caseItem) => (
<Link <StaticLink
key={caseItem.id} key={caseItem.id}
href={`/cases/${caseItem.id}`} href={`/cases/${caseItem.id}`}
className="group p-4 bg-white rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors" className="group p-4 bg-white rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors"
@@ -252,23 +252,23 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
<p className="text-sm text-[#5C5C5C] mt-2 line-clamp-2"> <p className="text-sm text-[#5C5C5C] mt-2 line-clamp-2">
{caseItem.description} {caseItem.description}
</p> </p>
</Link> </StaticLink>
))} ))}
</div> </div>
</section> </section>
<div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]"> <div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]">
<Link href="/services"> <StaticLink href="/services">
<Button variant="outline" size="lg"> <Button variant="outline" size="lg">
</Button> </Button>
</Link> </StaticLink>
<Link href="/contact"> <StaticLink href="/contact">
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white"> <Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white">
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
</Link> </StaticLink>
</div> </div>
</div> </div>
</div> </div>
+13 -44
View File
@@ -2,23 +2,23 @@
import { useState, useMemo, useRef, ChangeEvent } from 'react'; import { useState, useMemo, useRef, ChangeEvent } from 'react';
import { useInView } from 'framer-motion'; import { useInView } from 'framer-motion';
import { useServices } from '@/hooks/use-services'; import { SERVICES } from '@/lib/constants';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { PageHeader } from '@/components/ui/page-header'; import { PageHeader } from '@/components/ui/page-header';
import { Search, ArrowLeft, Code, Cloud, BarChart3, Shield, ChevronLeft, ChevronRight, Filter } from 'lucide-react'; import { Search, ArrowLeft, Code, BarChart3, Lightbulb, Puzzle, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
import Link from 'next/link'; import { StaticLink } from '@/components/ui/static-link';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = { const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Code, Code,
Cloud,
BarChart3, BarChart3,
Shield, Lightbulb,
Puzzle,
}; };
const categories = ['全部', '软件开发', '云服务', '数据分析', '信息安全']; const categories = ['全部', ...SERVICES.map((s) => s.title)];
const ITEMS_PER_PAGE = 6; const ITEMS_PER_PAGE = 6;
export default function ServicesPage() { export default function ServicesPage() {
@@ -27,19 +27,15 @@ export default function ServicesPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const contentRef = useRef(null); const contentRef = useRef(null);
const isContentInView = useInView(contentRef, { once: true, margin: '-100px' }); const isContentInView = useInView(contentRef, { once: true, margin: '-100px' });
const { services, loading, error } = useServices();
const filteredServices = useMemo(() => { const filteredServices = useMemo(() => {
if (!services || services.length === 0) return []; return SERVICES.filter((service) => {
return services.filter((service) => {
const matchesCategory = selectedCategory === '全部' || service.title.includes(selectedCategory); const matchesCategory = selectedCategory === '全部' || service.title.includes(selectedCategory);
const matchesSearch = const matchesSearch =
service.title.toLowerCase().includes(searchQuery.toLowerCase()) || service.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
service.description.toLowerCase().includes(searchQuery.toLowerCase()); service.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch; return matchesCategory && matchesSearch;
}); });
}, [services, selectedCategory, searchQuery]); }, [selectedCategory, searchQuery]);
const totalPages = Math.ceil(filteredServices.length / ITEMS_PER_PAGE); const totalPages = Math.ceil(filteredServices.length / ITEMS_PER_PAGE);
const paginatedServices = useMemo(() => { const paginatedServices = useMemo(() => {
@@ -63,28 +59,6 @@ export default function ServicesPage() {
setCurrentPage(1); setCurrentPage(1);
}; };
if (loading) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#C41E3A] mx-auto mb-4"></div>
<p className="text-[#5C5C5C]">...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 mb-4"></p>
<Button onClick={() => window.location.reload()}></Button>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<PageHeader <PageHeader
@@ -94,11 +68,6 @@ export default function ServicesPage() {
<div className="container-wide relative z-10 py-16" ref={contentRef}> <div className="container-wide relative z-10 py-16" ref={contentRef}>
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<Link href="/" className="inline-flex items-center text-[#5C5C5C] hover:text-[#C41E3A] transition-colors mb-8">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}} animate={isContentInView ? { opacity: 1, y: 0 } : {}}
@@ -156,8 +125,8 @@ export default function ServicesPage() {
animate={isContentInView ? { opacity: 1, y: 0 } : {}} animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
> >
<Link <StaticLink
href={`/services/${service.slug}`} href={`/services/${service.id}`}
className="group bg-white rounded-2xl border border-[#E5E5E5] overflow-hidden hover:shadow-xl transition-all duration-300 block h-full" className="group bg-white rounded-2xl border border-[#E5E5E5] overflow-hidden hover:shadow-xl transition-all duration-300 block h-full"
> >
<div className="p-8"> <div className="p-8">
@@ -189,7 +158,7 @@ export default function ServicesPage() {
</div> </div>
</div> </div>
</div> </div>
</Link> </StaticLink>
</motion.div> </motion.div>
); );
})} })}
@@ -259,10 +228,10 @@ export default function ServicesPage() {
className="bg-[#C41E3A] hover:bg-[#A01830] text-white" className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
asChild asChild
> >
<Link href="/contact"> <StaticLink href="/contact">
<ArrowLeft className="ml-2 w-4 h-4 rotate-180" /> <ArrowLeft className="ml-2 w-4 h-4 rotate-180" />
</Link> </StaticLink>
</Button> </Button>
</div> </div>
</motion.div> </motion.div>
+5 -5
View File
@@ -3,7 +3,7 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useInView } from 'framer-motion'; import { useInView } from 'framer-motion';
import { useRef } from 'react'; import { useRef } from 'react';
import Link from 'next/link'; import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/ui/page-header'; import { PageHeader } from '@/components/ui/page-header';
import { ArrowRight, Lightbulb, Cpu, Users, CheckCircle2 } from 'lucide-react'; import { ArrowRight, Lightbulb, Cpu, Users, CheckCircle2 } from 'lucide-react';
@@ -114,7 +114,7 @@ export default function SolutionsPage() {
<div className="space-y-6 mb-8"> <div className="space-y-6 mb-8">
<p className="text-lg text-[#1C1C1C] leading-relaxed"> <p className="text-lg text-[#1C1C1C] leading-relaxed">
"最火""最对" &ldquo;&rdquo;&ldquo;&rdquo;
</p> </p>
<p className="text-lg text-[#1C1C1C] leading-relaxed"> <p className="text-lg text-[#1C1C1C] leading-relaxed">
沿 沿
@@ -255,17 +255,17 @@ export default function SolutionsPage() {
variant="outline" variant="outline"
asChild asChild
> >
<Link href="/contact"></Link> <StaticLink href="/contact"></StaticLink>
</Button> </Button>
<Button <Button
size="lg" size="lg"
className="bg-[#C41E3A] hover:bg-[#A01830] text-white" className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
asChild asChild
> >
<Link href="/contact"> <StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Link> </StaticLink>
</Button> </Button>
</div> </div>
</div> </div>
-83
View File
@@ -1,83 +0,0 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ContentEditPage from './page';
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
back: jest.fn(),
}),
useParams: () => ({
id: 'new',
}),
}));
jest.mock('next/link', () => {
return ({ children, href }: { children: React.ReactNode; href: string }) => {
return <a href={href}>{children}</a>;
};
});
jest.mock('next/dynamic', () => () => {
return function MockEditor() {
return <div data-testid="rich-text-editor">Editor</div>;
};
});
global.fetch = jest.fn();
describe('ContentEditPage', () => {
beforeEach(() => {
jest.clearAllMocks();
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
type: 'news',
title: 'Test Content',
slug: 'test-content',
excerpt: 'Test excerpt',
content: '<p>Test content</p>',
coverImage: '',
category: '',
tags: [],
status: 'draft',
}),
});
});
describe('Rendering', () => {
it('should render content edit page', () => {
render(<ContentEditPage />);
const container = document.body;
expect(container).toBeTruthy();
});
it('should render form', () => {
render(<ContentEditPage />);
const container = document.body;
expect(container).toBeTruthy();
});
it('should render back button', () => {
render(<ContentEditPage />);
const container = document.body;
expect(container).toBeTruthy();
});
});
describe('Functionality', () => {
it('should initialize with default values for new content', () => {
render(<ContentEditPage />);
const container = document.body;
expect(container).toBeTruthy();
});
});
describe('Accessibility', () => {
it('should have form labels', () => {
render(<ContentEditPage />);
const container = document.body;
expect(container).toBeTruthy();
});
});
});
-396
View File
@@ -1,396 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import {
ArrowLeft,
Save,
Loader2,
Eye,
Upload
} from 'lucide-react';
import dynamic from 'next/dynamic';
const RichTextEditor = dynamic(
() => import('@/components/admin/RichTextEditor'),
{
ssr: false,
loading: () => (
<div className="h-64 border border-gray-300 rounded-lg flex items-center justify-center bg-gray-50">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
)
}
);
const typeOptions = [
{ value: 'news', label: '新闻' },
{ value: 'product', label: '产品' },
{ value: 'service', label: '服务' },
{ value: 'case', label: '案例' },
];
const statusOptions = [
{ value: 'draft', label: '草稿' },
{ value: 'published', label: '发布' },
{ value: 'archived', label: '归档' },
];
export default function ContentEditPage() {
const router = useRouter();
const params = useParams();
const isNew = params.id === 'new';
const contentId = isNew ? null : (params.id as string);
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [formData, setFormData] = useState({
type: 'news',
title: '',
slug: '',
excerpt: '',
content: '',
coverImage: '',
category: '',
tags: [] as string[],
status: 'draft',
});
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (!isNew && contentId) {
fetchContent();
}
}, [isNew, contentId]);
const fetchContent = async () => {
try {
const res = await fetch(`/api/admin/content/${contentId}`);
const data = await res.json();
if (res.ok) {
setFormData({
type: data.type,
title: data.title,
slug: data.slug,
excerpt: data.excerpt || '',
content: data.content || '',
coverImage: data.coverImage || '',
category: data.category || '',
tags: data.tags || [],
status: data.status,
});
} else {
router.push('/admin/content');
}
} catch (error) {
console.error('获取内容失败:', error);
} finally {
setLoading(false);
}
};
const generateSlug = (title: string) => {
return title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-|-$/g, '');
};
const handleTitleChange = (title: string) => {
setFormData(prev => ({
...prev,
title,
slug: prev.slug || generateSlug(title),
}));
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const uploadFormData = new FormData();
uploadFormData.append('file', file);
uploadFormData.append('type', 'image');
const res = await fetch('/api/admin/upload', {
method: 'POST',
body: uploadFormData,
});
const data = await res.json();
if (res.ok) {
setFormData(prev => ({ ...prev, coverImage: data.file.url }));
}
} catch (error) {
console.error('上传失败:', error);
} finally {
setUploading(false);
}
};
const validate = () => {
const newErrors: Record<string, string> = {};
if (!formData.title.trim()) {
newErrors.title = '请输入标题';
}
if (!formData.slug.trim()) {
newErrors.slug = '请输入 Slug';
}
if (!formData.type) {
newErrors.type = '请选择类型';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async (publish: boolean = false) => {
if (!validate()) return;
setSaving(true);
try {
const url = isNew
? '/api/admin/content'
: `/api/admin/content/${contentId}`;
const body = {
...formData,
status: publish ? 'published' : formData.status,
contentBody: formData.content,
};
const res = await fetch(url, {
method: isNew ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (res.ok) {
if (isNew) {
router.push(`/admin/content/${data.id}`);
}
alert('保存成功');
} else {
alert(data.error || '保存失败');
}
} catch (error) {
console.error('保存失败:', error);
alert('保存失败');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-[#C41E3A]" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/admin/content"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-2xl font-bold text-gray-900">
{isNew ? '新建内容' : '编辑内容'}
</h1>
</div>
<div className="flex gap-3">
<button
onClick={() => handleSave(false)}
disabled={saving}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 transition-colors flex items-center gap-2"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
稿
</button>
<button
onClick={() => handleSave(true)}
disabled={saving}
className="px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#a01830] disabled:opacity-50 transition-colors flex items-center gap-2"
>
<Eye className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.title}
onChange={(e) => handleTitleChange(e.target.value)}
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none ${
errors.title ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="请输入标题"
/>
{errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Slug <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData(prev => ({ ...prev, slug: e.target.value }))}
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none ${
errors.slug ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="url-slug"
/>
{errors.slug && <p className="text-red-500 text-sm mt-1">{errors.slug}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
value={formData.excerpt}
onChange={(e) => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none resize-none"
placeholder="请输入摘要(可选)"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<RichTextEditor
content={formData.content}
onChange={(content: string) => setFormData(prev => ({ ...prev, content }))}
/>
</div>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="font-medium text-gray-900 mb-4"></h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<select
value={formData.type}
onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
>
{typeOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={formData.status}
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
>
{statusOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
placeholder="分类名称"
/>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="font-medium text-gray-900 mb-4"></h3>
{formData.coverImage ? (
<div className="relative">
<img
src={formData.coverImage}
alt="封面"
className="w-full h-40 object-cover rounded-lg"
/>
<button
onClick={() => setFormData(prev => ({ ...prev, coverImage: '' }))}
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow hover:bg-gray-100"
>
×
</button>
</div>
) : (
<label className="block">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
disabled={uploading}
/>
<div className="w-full h-40 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-[#C41E3A] hover:bg-red-50 transition-colors">
{uploading ? (
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
) : (
<>
<Upload className="h-6 w-6 text-gray-400 mb-2" />
<span className="text-sm text-gray-500"></span>
</>
)}
</div>
</label>
)}
</div>
</div>
</div>
</div>
);
}
-90
View File
@@ -1,90 +0,0 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ContentListPage from './page';
jest.mock('next/navigation', () => ({
useSearchParams: () => ({
get: jest.fn(() => null),
}),
}));
jest.mock('next/link', () => {
return ({ children, href }: { children: React.ReactNode; href: string }) => {
return <a href={href}>{children}</a>;
};
});
global.fetch = jest.fn();
describe('ContentListPage', () => {
beforeEach(() => {
jest.clearAllMocks();
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
items: [
{
id: 'test-content',
type: 'news',
title: 'Test Content',
slug: 'test-content',
excerpt: 'Test excerpt',
status: 'published',
category: 'test',
createdAt: '2024-01-01',
publishedAt: '2024-01-01',
},
],
pagination: {
page: 1,
limit: 20,
total: 1,
totalPages: 1,
},
}),
});
});
describe('Rendering', () => {
it('should render content list page', () => {
render(<ContentListPage />);
const container = screen.getByText(/内容管理/i).closest('div');
expect(container).toBeInTheDocument();
});
it('should render page title', () => {
render(<ContentListPage />);
const title = screen.getByRole('heading', { level: 1 });
expect(title).toBeInTheDocument();
});
it('should render search input', () => {
render(<ContentListPage />);
const searchInput = screen.getByPlaceholderText(/搜索/i);
expect(searchInput).toBeInTheDocument();
});
it('should render add content button', () => {
render(<ContentListPage />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
});
describe('Functionality', () => {
it('should fetch content on mount', async () => {
render(<ContentListPage />);
expect(global.fetch).toHaveBeenCalled();
});
});
describe('Accessibility', () => {
it('should have proper heading hierarchy', () => {
render(<ContentListPage />);
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1).toBeInTheDocument();
});
});
});
-324
View File
@@ -1,324 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import {
Plus,
Search,
Edit,
Trash2,
FileText,
Loader2
} from 'lucide-react';
interface ContentItem {
id: string;
type: 'news' | 'product' | 'service' | 'case';
title: string;
slug: string;
excerpt: string | null;
status: 'draft' | 'published' | 'archived';
category: string | null;
createdAt: string;
publishedAt: string | null;
}
interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
const typeLabels: Record<string, string> = {
news: '新闻',
product: '产品',
service: '服务',
case: '案例',
};
const statusLabels: Record<string, string> = {
draft: '草稿',
published: '已发布',
archived: '已归档',
};
const statusColors: Record<string, string> = {
draft: 'bg-yellow-100 text-yellow-800',
published: 'bg-green-100 text-green-800',
archived: 'bg-gray-100 text-gray-800',
};
export default function ContentListPage() {
const searchParams = useSearchParams();
const [items, setItems] = useState<ContentItem[]>([]);
const [pagination, setPagination] = useState<Pagination>({
page: 1,
limit: 20,
total: 0,
totalPages: 0,
});
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(searchParams.get('search') || '');
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchContent = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set('page', pagination.page.toString());
params.set('limit', pagination.limit.toString());
if (search) params.set('search', search);
if (typeFilter) params.set('type', typeFilter);
if (statusFilter) params.set('status', statusFilter);
const res = await fetch(`/api/admin/content?${params}`);
const data = await res.json();
if (res.ok) {
setItems(data.items);
setPagination(data.pagination);
}
} catch (error) {
console.error('获取内容列表失败:', error);
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, search, typeFilter, statusFilter]);
useEffect(() => {
fetchContent();
}, [fetchContent]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPagination(prev => ({ ...prev, page: 1 }));
fetchContent();
};
const handleDelete = async () => {
if (!deleteId) return;
setDeleting(true);
try {
const res = await fetch(`/api/admin/content/${deleteId}`, {
method: 'DELETE',
});
if (res.ok) {
setItems(items.filter(item => item.id !== deleteId));
setDeleteId(null);
}
} catch (error) {
console.error('删除失败:', error);
} finally {
setDeleting(false);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<Link
href="/admin/content/new"
className="inline-flex items-center gap-2 px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#a01830] transition-colors"
>
<Plus className="h-5 w-5" />
</Link>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<form onSubmit={handleSearch} className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索标题..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
/>
</div>
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value);
setPagination(prev => ({ ...prev, page: 1 }));
}}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
>
<option value=""></option>
{Object.entries(typeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPagination(prev => ({ ...prev, page: 1 }));
}}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
>
<option value=""></option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<button
type="submit"
className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
</form>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-[#C41E3A]" />
</div>
) : items.length === 0 ? (
<div className="text-center py-12">
<FileText className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500"></p>
<Link
href="/admin/content/new"
className="inline-block mt-4 text-[#C41E3A] hover:underline"
>
</Link>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600"></th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600"></th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600"></th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600"></th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600"></th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-4 px-4">
<div>
<p className="font-medium text-gray-900">{item.title}</p>
<p className="text-sm text-gray-500">{item.slug}</p>
</div>
</td>
<td className="py-4 px-4">
<span className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded">
{typeLabels[item.type]}
</span>
</td>
<td className="py-4 px-4">
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColors[item.status]}`}>
{statusLabels[item.status]}
</span>
</td>
<td className="py-4 px-4 text-gray-600">
{item.category || '-'}
</td>
<td className="py-4 px-4 text-gray-600">
{formatDate(item.createdAt)}
</td>
<td className="py-4 px-4">
<div className="flex items-center justify-end gap-2">
<Link
href={`/admin/content/${item.id}`}
className="p-2 text-gray-400 hover:text-[#C41E3A] hover:bg-red-50 rounded-lg transition-colors"
title="编辑"
>
<Edit className="h-5 w-5" />
</Link>
<button
onClick={() => setDeleteId(item.id)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="删除"
>
<Trash2 className="h-5 w-5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-6 border-t border-gray-200">
<p className="text-sm text-gray-600">
{pagination.total}
</p>
<div className="flex gap-2">
<button
onClick={() => setPagination(prev => ({ ...prev, page: prev.page - 1 }))}
disabled={pagination.page === 1}
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => setPagination(prev => ({ ...prev, page: prev.page + 1 }))}
disabled={pagination.page === pagination.totalPages}
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
)}
</>
)}
</div>
{deleteId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-6"></p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteId(null)}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 transition-colors"
>
{deleting ? '删除中...' : '确认删除'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
-154
View File
@@ -1,154 +0,0 @@
'use client';
import { useSession, signOut } from 'next-auth/react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
FileText,
Settings,
Users,
LayoutDashboard,
LogOut,
Menu,
X,
Activity
} from 'lucide-react';
import { useState, useEffect } from 'react';
const navigation = [
{ name: '仪表盘', href: '/admin', icon: LayoutDashboard },
{ name: '内容管理', href: '/admin/content', icon: FileText },
{ name: '配置中心', href: '/admin/settings', icon: Settings },
{ name: '用户管理', href: '/admin/users', icon: Users },
{ name: '审计日志', href: '/admin/logs', icon: Activity },
];
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const { data: session, status } = useSession();
const pathname = usePathname();
const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const isLoginPage = pathname === '/admin/login';
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (mounted && status === 'unauthenticated' && !isLoginPage) {
router.push('/admin/login');
}
}, [mounted, status, isLoginPage, router]);
if (!mounted) {
return null;
}
if (isLoginPage) {
return <div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">{children}</div>;
}
if (status === 'loading') {
return null;
}
if (status === 'unauthenticated') {
return null;
}
return (
<div className="min-h-screen bg-gray-50">
<div className="lg:hidden fixed top-0 left-0 right-0 z-40 bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between">
<Link href="/admin" className="text-xl font-bold text-[#C41E3A]">
</Link>
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 rounded-md text-gray-600 hover:bg-gray-100"
>
{sidebarOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
{sidebarOpen && (
<div
className="lg:hidden fixed inset-0 z-30 bg-black/50"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside className={`
fixed top-0 left-0 z-30 h-full w-64 bg-white border-r border-gray-200 transform transition-transform duration-300 ease-in-out
lg:translate-x-0
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<div className="h-full flex flex-col">
<div className="h-16 flex items-center px-6 border-b border-gray-200">
<Link href="/admin" className="text-xl font-bold text-[#C41E3A]">
</Link>
</div>
<nav className="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
{navigation.map((item) => {
const isActive = pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href));
return (
<Link
key={item.name}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`
flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors
${isActive
? 'bg-[#C41E3A] text-white'
: 'text-gray-700 hover:bg-gray-100'
}
`}
>
<item.icon className="h-5 w-5" />
{item.name}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-gray-200">
<div className="flex items-center gap-3 px-4 py-3">
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-medium">
{session?.user?.name?.[0] || 'U'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{session?.user?.name}
</p>
<p className="text-xs text-gray-500 truncate">
{session?.user?.isAdmin ? '管理员' : '用户'}
</p>
</div>
<button
onClick={() => signOut({ callbackUrl: '/admin/login' })}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg"
title="退出登录"
>
<LogOut className="h-5 w-5" />
</button>
</div>
</div>
</div>
</aside>
<main className="lg:ml-64 min-h-screen">
<div className="p-6 lg:p-8 pt-20 lg:pt-8">
{children}
</div>
</main>
</div>
);
}
-100
View File
@@ -1,100 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import LoginPage from './page';
jest.mock('next-auth/react', () => ({
signIn: jest.fn(),
}));
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
}),
useSearchParams: () => ({
get: jest.fn(() => null),
}),
}));
jest.mock('next/link', () => {
return ({ children, href }: { children: React.ReactNode; href: string }) => {
return <a href={href}>{children}</a>;
};
});
describe('LoginPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render login page', () => {
render(<LoginPage />);
const container = screen.getByText('管理后台登录').closest('div');
expect(container).toBeInTheDocument();
});
it('should render email input', () => {
render(<LoginPage />);
const emailInput = screen.getByLabelText(/邮箱地址/i);
expect(emailInput).toBeInTheDocument();
});
it('should render password input', () => {
render(<LoginPage />);
const passwordInput = screen.getByLabelText(/密码/i);
expect(passwordInput).toBeInTheDocument();
});
it('should render login button', () => {
render(<LoginPage />);
const loginButton = screen.getByRole('button', { name: /登录/i });
expect(loginButton).toBeInTheDocument();
});
});
describe('Functionality', () => {
it('should update email value on change', () => {
render(<LoginPage />);
const emailInput = screen.getByLabelText(/邮箱地址/i) as HTMLInputElement;
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
expect(emailInput.value).toBe('test@example.com');
});
it('should update password value on change', () => {
render(<LoginPage />);
const passwordInput = screen.getByLabelText(/密码/i) as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: 'password123' } });
expect(passwordInput.value).toBe('password123');
});
it('should toggle password visibility', () => {
render(<LoginPage />);
const passwordInput = screen.getByLabelText(/密码/i) as HTMLInputElement;
expect(passwordInput.type).toBe('password');
const toggleButtons = screen.getAllByRole('button');
const toggleButton = toggleButtons.find(btn =>
btn.querySelector('svg') && btn !== screen.getByRole('button', { name: /登录/i })
);
if (toggleButton) {
fireEvent.click(toggleButton);
expect(passwordInput.type).toBe('text');
}
});
});
describe('Accessibility', () => {
it('should have form labels', () => {
render(<LoginPage />);
expect(screen.getByLabelText(/邮箱地址/i)).toBeInTheDocument();
expect(screen.getByLabelText(/密码/i)).toBeInTheDocument();
});
});
});
-123
View File
@@ -1,123 +0,0 @@
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { Eye, EyeOff, Mail, Lock, AlertCircle } from 'lucide-react';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('admin@novalon.cn');
const [password, setPassword] = useState('admin123456');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('邮箱或密码错误');
} else {
router.push('/admin');
}
} catch (err) {
setError('登录失败,请稍后重试');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 px-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl border border-gray-200 p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-[#C41E3A]"></h1>
<p className="text-gray-600 mt-2"></p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3 text-red-700">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="请输入邮箱"
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none transition-all"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="请输入密码"
className="w-full pl-10 pr-12 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none transition-all"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-[#C41E3A] text-white font-medium rounded-lg hover:bg-[#a01830] focus:ring-2 focus:ring-offset-2 focus:ring-[#C41E3A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<div className="mt-6 text-center">
<a href="/" className="text-sm text-gray-600 hover:text-[#C41E3A] transition-colors">
</a>
</div>
</div>
<p className="text-center text-xs text-gray-500 mt-6">
© {new Date().getFullYear()}
</p>
</div>
</div>
);
}
-108
View File
@@ -1,108 +0,0 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import AdminDashboard from './page';
jest.mock('@/lib/auth', () => ({
auth: jest.fn().mockResolvedValue({
user: { name: '测试用户' },
}),
}));
jest.mock('@/db', () => ({
db: {
select: jest.fn().mockReturnValue({
from: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
orderBy: jest.fn().mockReturnValue({
limit: jest.fn().mockResolvedValue([]),
}),
}),
orderBy: jest.fn().mockReturnValue({
limit: jest.fn().mockResolvedValue([]),
}),
}),
}),
},
}));
jest.mock('next/link', () => {
return ({ children, href }: { children: React.ReactNode; href: string }) => {
return <a href={href}>{children}</a>;
};
});
describe('AdminDashboard', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render dashboard', async () => {
const dashboard = await AdminDashboard();
render(dashboard);
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toBeInTheDocument();
expect(heading).toHaveTextContent('仪表盘');
});
it('should render welcome message', async () => {
const dashboard = await AdminDashboard();
render(dashboard);
const welcome = screen.getByText(/欢迎回来/i);
expect(welcome).toBeInTheDocument();
});
it('should render stat cards', async () => {
const dashboard = await AdminDashboard();
render(dashboard);
const totalContent = screen.getByText('总内容数');
const published = screen.getByText('已发布');
const draft = screen.getByText('草稿');
const users = screen.getByText('用户数');
expect(totalContent).toBeInTheDocument();
expect(published).toBeInTheDocument();
expect(draft).toBeInTheDocument();
expect(users).toBeInTheDocument();
});
it('should render recent content section', async () => {
const dashboard = await AdminDashboard();
render(dashboard);
const recentContent = screen.getByText('最近内容');
expect(recentContent).toBeInTheDocument();
});
it('should render quick actions section', async () => {
const dashboard = await AdminDashboard();
render(dashboard);
const quickActions = screen.getByText('快捷操作');
expect(quickActions).toBeInTheDocument();
});
});
describe('Navigation', () => {
it('should have content management link', async () => {
const dashboard = await AdminDashboard();
render(dashboard);
const contentLink = screen.getByRole('link', { name: /总内容数/i });
expect(contentLink).toBeInTheDocument();
expect(contentLink).toHaveAttribute('href', '/admin/content');
});
it('should have users link', async () => {
const dashboard = await AdminDashboard();
render(dashboard);
const usersLink = screen.getByRole('link', { name: /用户数/i });
expect(usersLink).toBeInTheDocument();
expect(usersLink).toHaveAttribute('href', '/admin/users');
});
});
});
-161
View File
@@ -1,161 +0,0 @@
import { auth } from '@/lib/auth';
import { db } from '@/db';
import { content, users } from '@/db/schema';
import { desc, eq, sql } from 'drizzle-orm';
import Link from 'next/link';
import { FileText, Settings, Users, TrendingUp } from 'lucide-react';
async function getStats() {
const [
contentCount,
publishedCount,
draftCount,
userCount,
recentContent,
] = await Promise.all([
db.select({ count: sql<number>`count(*)` }).from(content),
db.select({ count: sql<number>`count(*)` }).from(content).where(eq(content.status, 'published')),
db.select({ count: sql<number>`count(*)` }).from(content).where(eq(content.status, 'draft')),
db.select({ count: sql<number>`count(*)` }).from(users),
db.select().from(content).orderBy(desc(content.createdAt)).limit(5),
]);
return {
contentCount: contentCount[0]?.count || 0,
publishedCount: publishedCount[0]?.count || 0,
draftCount: draftCount[0]?.count || 0,
userCount: userCount[0]?.count || 0,
recentContent,
};
}
export default async function AdminDashboard() {
const session = await auth();
const stats = await getStats();
const statCards = [
{
name: '总内容数',
value: stats.contentCount,
icon: FileText,
color: 'bg-blue-500',
href: '/admin/content'
},
{
name: '已发布',
value: stats.publishedCount,
icon: TrendingUp,
color: 'bg-green-500',
href: '/admin/content?status=published'
},
{
name: '草稿',
value: stats.draftCount,
icon: FileText,
color: 'bg-yellow-500',
href: '/admin/content?status=draft'
},
{
name: '用户数',
value: stats.userCount,
icon: Users,
color: 'bg-purple-500',
href: '/admin/users'
},
];
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1">{session?.user?.name}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{statCards.map((stat) => (
<Link
key={stat.name}
href={stat.href}
className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">{stat.name}</p>
<p className="text-3xl font-bold text-gray-900 mt-2">{stat.value}</p>
</div>
<div className={`${stat.color} p-3 rounded-lg`}>
<stat.icon className="h-6 w-6 text-white" />
</div>
</div>
</Link>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900"></h2>
<Link
href="/admin/content"
className="text-sm text-[#C41E3A] hover:underline"
>
</Link>
</div>
{stats.recentContent.length === 0 ? (
<p className="text-gray-500 text-center py-8"></p>
) : (
<div className="space-y-4">
{stats.recentContent.map((item) => (
<div
key={item.id}
className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{item.title}
</p>
<p className="text-xs text-gray-500 mt-1">
{item.type} · {item.status === 'published' ? '已发布' : '草稿'}
</p>
</div>
<Link
href={`/admin/content/${item.id}`}
className="text-sm text-[#C41E3A] hover:underline ml-4"
>
</Link>
</div>
))}
</div>
)}
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900"></h2>
</div>
<div className="grid grid-cols-2 gap-4">
<Link
href="/admin/content/new"
className="flex flex-col items-center justify-center p-6 rounded-lg border-2 border-dashed border-gray-300 hover:border-[#C41E3A] hover:bg-red-50 transition-colors"
>
<FileText className="h-8 w-8 text-gray-400 mb-2" />
<span className="text-sm font-medium text-gray-600"></span>
</Link>
<Link
href="/admin/config"
className="flex flex-col items-center justify-center p-6 rounded-lg border-2 border-dashed border-gray-300 hover:border-[#C41E3A] hover:bg-red-50 transition-colors"
>
<Settings className="h-8 w-8 text-gray-400 mb-2" />
<span className="text-sm font-medium text-gray-600"></span>
</Link>
</div>
</div>
</div>
</div>
);
}
-148
View File
@@ -1,148 +0,0 @@
import { describe, it, expect, jest, beforeAll, afterEach } from '@jest/globals';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import SecurityDashboard from './page';
jest.mock('lucide-react', () => ({
Shield: () => <span data-testid="shield-icon" />,
AlertTriangle: () => <span data-testid="alert-icon" />,
Activity: () => <span data-testid="activity-icon" />,
Lock: () => <span data-testid="lock-icon" />,
RefreshCw: () => <span data-testid="refresh-cw-icon" />,
TrendingUp: () => <span data-testid="trending-up-icon" />,
TrendingDown: () => <span data-testid="trending-down-icon" />,
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, disabled, ...props }: any) => (
<button disabled={disabled} {...props}>
{children}
</button>
),
}));
jest.mock('@/components/ui/card', () => ({
Card: ({ children }: any) => <div data-testid="card">{children}</div>,
CardHeader: ({ children }: any) => <div data-testid="card-header">{children}</div>,
CardTitle: ({ children }: any) => <h3 data-testid="card-title">{children}</h3>,
CardContent: ({ children }: any) => <div data-testid="card-content">{children}</div>,
}));
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({
success: true,
logs: [
{
id: '1',
timestamp: Date.now(),
type: 'captcha',
severity: 'high',
message: '验证码验证失败',
ip: '192.168.1.1',
},
],
stats: {
totalRequests: 100,
blockedRequests: 5,
captchaAttempts: 10,
rateLimitHits: 3,
maliciousContentDetected: 2,
successRate: 95,
},
}),
} as Response)
);
describe('SecurityDashboard', () => {
beforeAll(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render security dashboard', () => {
render(<SecurityDashboard />);
expect(screen.getByText('安全监控仪表板')).toBeInTheDocument();
expect(screen.getByText('实时监控网站安全状态和威胁检测')).toBeInTheDocument();
});
it('should render all stat cards', () => {
render(<SecurityDashboard />);
expect(screen.getByText('总请求数')).toBeInTheDocument();
expect(screen.getByText('已拦截请求')).toBeInTheDocument();
expect(screen.getByText('验证码尝试')).toBeInTheDocument();
expect(screen.getByText('频率限制命中')).toBeInTheDocument();
expect(screen.getByText('恶意内容检测')).toBeInTheDocument();
expect(screen.getByText('成功率')).toBeInTheDocument();
});
it('should display stats values', async () => {
render(<SecurityDashboard />);
await waitFor(() => {
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('95%')).toBeInTheDocument();
});
});
});
describe('Security Logs', () => {
it('should render security logs section', async () => {
render(<SecurityDashboard />);
await waitFor(() => {
expect(screen.getByText('安全日志')).toBeInTheDocument();
});
});
it('should display log entries', async () => {
render(<SecurityDashboard />);
await waitFor(() => {
expect(screen.getByText('验证码验证失败')).toBeInTheDocument();
expect(screen.getByText('IP: 192.168.1.1')).toBeInTheDocument();
});
});
it('should have filter buttons', () => {
render(<SecurityDashboard />);
expect(screen.getByText('全部')).toBeInTheDocument();
expect(screen.getByText('高危')).toBeInTheDocument();
expect(screen.getByText('中危')).toBeInTheDocument();
expect(screen.getByText('低危')).toBeInTheDocument();
});
});
describe('Refresh Functionality', () => {
it('should have refresh button', async () => {
render(<SecurityDashboard />);
await waitFor(() => {
expect(screen.getByTestId('refresh-cw-icon')).toBeInTheDocument();
});
});
it('should call fetch when refresh is clicked', async () => {
render(<SecurityDashboard />);
await waitFor(() => {
const refreshButton = screen.getAllByRole('button')[0];
expect(refreshButton).not.toBeDisabled();
refreshButton.click();
expect(global.fetch).toHaveBeenCalled();
});
});
});
});
-271
View File
@@ -1,271 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { Shield, AlertTriangle, Activity, Lock, RefreshCw, TrendingUp, TrendingDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface SecurityLog {
id: string;
timestamp: number;
type: 'captcha' | 'rate_limit' | 'sanitization' | 'malicious_content';
severity: 'low' | 'medium' | 'high';
message: string;
ip?: string;
email?: string;
}
interface SecurityStats {
totalRequests: number;
blockedRequests: number;
captchaAttempts: number;
rateLimitHits: number;
maliciousContentDetected: number;
successRate: number;
}
export default function SecurityDashboard() {
const [logs, setLogs] = useState<SecurityLog[]>([]);
const [stats, setStats] = useState<SecurityStats>({
totalRequests: 0,
blockedRequests: 0,
captchaAttempts: 0,
rateLimitHits: 0,
maliciousContentDetected: 0,
successRate: 100,
});
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'all' | 'high' | 'medium' | 'low'>('all');
useEffect(() => {
fetchSecurityData();
}, []);
const fetchSecurityData = async () => {
setLoading(true);
try {
const response = await fetch('/api/admin/security');
if (response.ok) {
const data = await response.json();
setLogs(data.logs || []);
setStats(data.stats || {
totalRequests: 0,
blockedRequests: 0,
captchaAttempts: 0,
rateLimitHits: 0,
maliciousContentDetected: 0,
successRate: 100,
});
}
} catch (error) {
console.error('Failed to fetch security data:', error);
} finally {
setLoading(false);
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'high':
return 'text-red-600 bg-red-50';
case 'medium':
return 'text-yellow-600 bg-yellow-50';
case 'low':
return 'text-blue-600 bg-blue-50';
default:
return 'text-gray-600 bg-gray-50';
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'captcha':
return <Lock className="w-4 h-4" />;
case 'rate_limit':
return <Activity className="w-4 h-4" />;
case 'sanitization':
return <Shield className="w-4 h-4" />;
case 'malicious_content':
return <AlertTriangle className="w-4 h-4" />;
default:
return null;
}
};
const filteredLogs = filter === 'all'
? logs
: logs.filter(log => log.severity === filter);
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1"></p>
</div>
<Button
onClick={fetchSecurityData}
disabled={loading}
variant="outline"
size="icon"
>
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-gray-900">{stats.totalRequests}</div>
<div className="flex items-center text-sm text-green-600 mt-2">
<TrendingUp className="w-4 h-4 mr-1" />
<span></span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-red-600">{stats.blockedRequests}</div>
<div className="flex items-center text-sm text-gray-600 mt-2">
<span></span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-blue-600">{stats.captchaAttempts}</div>
<div className="flex items-center text-sm text-gray-600 mt-2">
<span></span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-yellow-600">{stats.rateLimitHits}</div>
<div className="flex items-center text-sm text-gray-600 mt-2">
<span></span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-purple-600">{stats.maliciousContentDetected}</div>
<div className="flex items-center text-sm text-gray-600 mt-2">
<span></span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-green-600">{stats.successRate}%</div>
<div className="flex items-center text-sm text-gray-600 mt-2">
<TrendingDown className="w-4 h-4 mr-1" />
<span></span>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle></CardTitle>
<div className="flex gap-2">
<Button
variant={filter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('all')}
>
</Button>
<Button
variant={filter === 'high' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('high')}
>
</Button>
<Button
variant={filter === 'medium' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('medium')}
>
</Button>
<Button
variant={filter === 'low' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('low')}
>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-8 h-8 animate-spin text-gray-400" />
</div>
) : filteredLogs.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<Shield className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<p></p>
</div>
) : (
<div className="space-y-3">
{filteredLogs.map((log) => (
<div
key={log.id}
className="flex items-start gap-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
>
<div className={`flex-shrink-0 p-2 rounded-full ${getSeverityColor(log.severity)}`}>
{getTypeIcon(log.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{log.message}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${getSeverityColor(log.severity)}`}>
{log.severity === 'high' ? '高危' : log.severity === 'medium' ? '中危' : '低危'}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>{new Date(log.timestamp).toLocaleString('zh-CN')}</span>
{log.ip && <span>IP: {log.ip}</span>}
{log.email && <span>: {log.email}</span>}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
-57
View File
@@ -1,57 +0,0 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SettingsPage from './page';
global.fetch = jest.fn();
describe('SettingsPage', () => {
beforeEach(() => {
jest.clearAllMocks();
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
configs: [
{
id: 'test-config',
key: 'test.key',
value: { enabled: true },
category: 'feature',
description: 'Test config',
updatedAt: '2024-01-01',
},
],
}),
});
});
describe('Rendering', () => {
it('should render settings page', () => {
render(<SettingsPage />);
const container = document.body;
expect(container).toBeTruthy();
});
it('should render page content', () => {
render(<SettingsPage />);
const content = document.querySelector('main') || document.body.firstChild;
expect(content).toBeTruthy();
});
});
describe('Functionality', () => {
it('should fetch configs on mount', async () => {
render(<SettingsPage />);
expect(global.fetch).toHaveBeenCalledWith('/api/admin/config');
});
});
describe('Accessibility', () => {
it('should have accessible content', () => {
render(<SettingsPage />);
const content = document.body;
expect(content).toBeTruthy();
});
});
});
-278
View File
@@ -1,278 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import {
Save,
RefreshCw,
Loader2,
ChevronDown,
ChevronUp
} from 'lucide-react';
interface ConfigItem {
id: string;
key: string;
value: Record<string, any>;
category: 'feature' | 'style' | 'seo' | 'general';
description: string | null;
updatedAt: string;
}
const categoryLabels = {
feature: '功能配置',
style: '样式配置',
seo: 'SEO 配置',
general: '常规配置'
};
const categoryColors = {
feature: 'bg-blue-100 text-blue-800',
style: 'bg-purple-100 text-purple-800',
seo: 'bg-green-100 text-green-800',
general: 'bg-gray-100 text-gray-800'
};
export default function SettingsPage() {
const [configs, setConfigs] = useState<ConfigItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['feature', 'seo']));
const [editedValues, setEditedValues] = useState<Record<string, Record<string, any>>>({});
useEffect(() => {
fetchConfigs();
}, []);
const fetchConfigs = async () => {
try {
setLoading(true);
const res = await fetch('/api/admin/config');
const data = await res.json();
if (res.ok) {
setConfigs(data.configs || []);
}
} catch (error) {
console.error('获取配置失败:', error);
} finally {
setLoading(false);
}
};
const handleSave = async (configId: string) => {
const editedValue = editedValues[configId];
if (!editedValue) return;
try {
setSaving(configId);
const res = await fetch('/api/admin/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: configId,
value: editedValue
})
});
if (res.ok) {
setEditedValues(prev => {
const updated = { ...prev };
delete updated[configId];
return updated;
});
await fetchConfigs();
}
} catch (error) {
console.error('保存配置失败:', error);
} finally {
setSaving(null);
}
};
const toggleCategory = (category: string) => {
setExpandedCategories(prev => {
const updated = new Set(prev);
if (updated.has(category)) {
updated.delete(category);
} else {
updated.add(category);
}
return updated;
});
};
const handleValueChange = (configId: string, field: string, value: any) => {
setEditedValues(prev => ({
...prev,
[configId]: {
...prev[configId],
[field]: value
}
}));
};
const getConfigValue = (config: ConfigItem, field: string) => {
if (editedValues[config.id]?.[field] !== undefined) {
return editedValues[config.id]![field];
}
return config.value[field];
};
const hasChanges = (configId: string) => {
return editedValues[configId] && Object.keys(editedValues[configId]).length > 0;
};
const groupedConfigs = configs.reduce((acc, config) => {
if (!acc[config.category]) {
acc[config.category] = [];
}
acc[config.category]!.push(config);
return acc;
}, {} as Record<string, ConfigItem[]>);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1"></p>
</div>
<button
onClick={fetchConfigs}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="space-y-4">
{Object.entries(groupedConfigs).map(([category, categoryConfigs]) => (
<div key={category} className="bg-white rounded-lg border overflow-hidden">
<button
onClick={() => toggleCategory(category)}
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${categoryColors[category as keyof typeof categoryColors]}`}>
{categoryLabels[category as keyof typeof categoryLabels]}
</span>
<span className="text-gray-600 text-sm">
{categoryConfigs.length}
</span>
</div>
{expandedCategories.has(category) ? (
<ChevronUp className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
</button>
{expandedCategories.has(category) && (
<div className="border-t divide-y">
{categoryConfigs.map(config => (
<div key={config.id} className="p-4">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-medium text-gray-900">{config.key}</h3>
{config.description && (
<p className="text-sm text-gray-600 mt-1">{config.description}</p>
)}
</div>
{hasChanges(config.id) && (
<button
onClick={() => handleSave(config.id)}
disabled={saving === config.id}
className="flex items-center gap-2 px-3 py-1.5 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
>
{saving === config.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</button>
)}
</div>
<div className="space-y-3">
{Object.entries(config.value).map(([field, value]) => {
const currentValue = getConfigValue(config, field);
return (
<div key={field} className="flex items-start gap-4">
<label className="w-32 text-sm font-medium text-gray-700 pt-2">
{field}
</label>
<div className="flex-1">
{typeof value === 'boolean' ? (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={currentValue}
onChange={(e) => handleValueChange(config.id, field, e.target.checked)}
className="w-4 h-4 text-[#C41E3A] border-gray-300 rounded focus:ring-[#C41E3A]"
/>
<span className="text-sm text-gray-600">
{currentValue ? '已启用' : '已禁用'}
</span>
</label>
) : typeof value === 'string' ? (
<input
type="text"
value={currentValue}
onChange={(e) => handleValueChange(config.id, field, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent"
/>
) : typeof value === 'number' ? (
<input
type="number"
value={currentValue}
onChange={(e) => handleValueChange(config.id, field, Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent"
/>
) : Array.isArray(value) ? (
<textarea
value={Array.isArray(currentValue) ? currentValue.join('\n') : currentValue}
onChange={(e) => handleValueChange(config.id, field, e.target.value.split('\n').filter(Boolean))}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent font-mono text-sm"
placeholder="每行一个值"
/>
) : (
<textarea
value={JSON.stringify(currentValue, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
handleValueChange(config.id, field, parsed);
} catch (err) {
// Invalid JSON, ignore
}
}}
rows={5}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent font-mono text-sm"
/>
)}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
);
}
-62
View File
@@ -1,62 +0,0 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import UsersPage from './page';
global.fetch = jest.fn();
describe('UsersPage', () => {
beforeEach(() => {
jest.clearAllMocks();
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
users: [
{
id: 'test-user',
email: 'test@example.com',
name: 'Test User',
role: 'admin',
createdAt: '2024-01-01',
},
],
}),
});
});
describe('Rendering', () => {
it('should render users page', () => {
render(<UsersPage />);
const container = document.body;
expect(container).toBeTruthy();
});
it('should render page content', () => {
render(<UsersPage />);
const content = document.querySelector('main') || document.body.firstChild;
expect(content).toBeTruthy();
});
it('should render add user button', () => {
render(<UsersPage />);
const container = document.body;
expect(container).toBeTruthy();
});
});
describe('Functionality', () => {
it('should fetch users on mount', async () => {
render(<UsersPage />);
expect(global.fetch).toHaveBeenCalledWith('/api/admin/users');
});
});
describe('Accessibility', () => {
it('should have proper heading hierarchy', () => {
render(<UsersPage />);
const container = document.body;
expect(container).toBeTruthy();
});
});
});
-422
View File
@@ -1,422 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import {
Users as UsersIcon,
Plus,
Edit,
Trash2,
Loader2,
Search
} from 'lucide-react';
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string;
}
const roleLabels = {
admin: '管理员',
editor: '编辑',
viewer: '查看者'
};
const roleColors = {
admin: 'bg-red-100 text-red-800',
editor: 'bg-blue-100 text-blue-800',
viewer: 'bg-gray-100 text-gray-800'
};
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [saving, setSaving] = useState(false);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [formData, setFormData] = useState({
email: '',
name: '',
password: '',
role: 'viewer' as 'admin' | 'editor' | 'viewer'
});
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
setLoading(true);
const res = await fetch('/api/admin/users');
const data = await res.json();
if (res.ok) {
setUsers(data.users || []);
}
} catch (error) {
console.error('获取用户列表失败:', error);
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
if (!formData.email || !formData.name || !formData.password || !formData.role) {
return;
}
try {
setSaving(true);
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (res.ok) {
setShowCreateModal(false);
setFormData({ email: '', name: '', password: '', role: 'viewer' });
await fetchUsers();
} else {
const data = await res.json();
alert(data.error || '创建失败');
}
} catch (error) {
console.error('创建用户失败:', error);
} finally {
setSaving(false);
}
};
const handleDelete = async (userId: string) => {
if (deletingUserId) {
console.log('删除操作正在进行中,请勿重复点击');
return;
}
if (!confirm('确定要删除此用户吗?此操作不可恢复。')) {
return;
}
try {
setDeletingUserId(userId);
const res = await fetch(`/api/admin/users/${userId}`, {
method: 'DELETE'
});
if (res.ok) {
await fetchUsers();
} else {
const data = await res.json();
alert(data.error || '删除失败');
}
} catch (error) {
console.error('删除用户失败:', error);
alert('删除失败,请稍后重试');
} finally {
setDeletingUserId(null);
}
};
const filteredUsers = users.filter(user =>
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1"></p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="bg-white rounded-lg border">
<div className="p-4 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="搜索用户..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredUsers.map(user => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
<UsersIcon className="h-5 w-5 text-gray-600" />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{user.name}</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${roleColors[user.role]}`}>
{roleLabels[user.role]}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString('zh-CN')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={(e) => {
e.stopPropagation();
setSelectedUser(user);
setFormData({
email: user.email,
name: user.name,
password: '',
role: user.role
});
setShowEditModal(true);
}}
className="text-[#C41E3A] hover:text-[#A01830] mr-4"
>
<Edit className="h-4 w-4 inline" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(user.id);
}}
disabled={deletingUserId === user.id}
className="text-red-600 hover:text-red-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
{deletingUserId === user.id ? (
<Loader2 className="h-4 w-4 inline animate-spin" />
) : (
<Trash2 className="h-4 w-4 inline" />
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredUsers.length === 0 && (
<div className="text-center py-12 text-gray-500">
</div>
)}
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
>
<option value="viewer"></option>
<option value="editor"></option>
<option value="admin"></option>
</select>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => {
setShowCreateModal(false);
setFormData({ email: '', name: '', password: '', role: 'viewer' });
}}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
</button>
<button
onClick={handleCreate}
disabled={saving}
className="px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] disabled:opacity-50"
>
{saving ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{showEditModal && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
>
<option value="viewer"></option>
<option value="editor"></option>
<option value="admin"></option>
</select>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => {
setShowEditModal(false);
setSelectedUser(null);
setFormData({ email: '', name: '', password: '', role: 'viewer' });
}}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
</button>
<button
onClick={async () => {
setSaving(true);
try {
const updateData: any = {
email: formData.email,
name: formData.name,
role: formData.role
};
if (formData.password) {
updateData.password = formData.password;
}
const res = await fetch(`/api/admin/users/${selectedUser.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
});
if (res.ok) {
setShowEditModal(false);
setSelectedUser(null);
setFormData({ email: '', name: '', password: '', role: 'viewer' });
await fetchUsers();
}
} catch (error) {
console.error('更新用户失败:', error);
} finally {
setSaving(false);
}
}}
disabled={saving}
className="px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] disabled:opacity-50"
>
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
-69
View File
@@ -1,69 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
export default function ApiDocsPage() {
const [spec, setSpec] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/docs')
.then((res) => {
if (!res.ok) {
throw new Error('Failed to load API documentation');
}
return res.json();
})
.then((data) => {
setSpec(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#C41E3A] mx-auto mb-4"></div>
<p className="text-[#5C5C5C]">API文档中...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="text-red-500 mb-4">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-[#C41E3A] text-white rounded-md hover:bg-[#A01830] transition-colors"
>
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<div className="swagger-ui-wrapper">
{spec && <SwaggerUI spec={spec} />}
</div>
</div>
);
}
-179
View File
@@ -1,179 +0,0 @@
import { GET, POST, PUT } from './route';
import { NextRequest } from 'next/server';
jest.mock('@/lib/auth', () => ({
auth: jest.fn(),
}));
jest.mock('@/lib/auth/permissions', () => ({
hasPermission: jest.fn(),
}));
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: jest.fn(),
getAdminUserId: jest.fn(),
}));
jest.mock('@/db', () => {
const mockSelect = jest.fn().mockReturnValue({
from: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
limit: jest.fn().mockResolvedValue([]),
orderBy: jest.fn().mockResolvedValue([]),
}),
}),
});
const mockUpdate = jest.fn().mockReturnValue({
set: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
returning: jest.fn().mockResolvedValue([{
id: 'test-id',
key: 'test_key',
value: 'test_value',
category: 'general',
}]),
}),
}),
});
return {
db: {
select: mockSelect,
update: mockUpdate,
insert: jest.fn().mockReturnValue({
values: jest.fn().mockReturnValue({
returning: jest.fn().mockResolvedValue([{
id: 'test-id',
key: 'test_key',
value: 'test_value',
category: 'general',
}]),
}),
}),
},
};
});
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
describe('/api/admin/config', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET', () => {
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/config');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 if no permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/config');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return configs if authenticated and has permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
const request = new NextRequest('http://localhost/api/admin/config');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.configs).toBeDefined();
expect(Array.isArray(data.configs)).toBe(true);
});
});
describe('POST', () => {
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/config', {
method: 'POST',
body: JSON.stringify({ key: 'test', value: {} }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 400 if missing required fields', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockGetAdminUserId.mockResolvedValueOnce('1');
const request = new NextRequest('http://localhost/api/admin/config', {
method: 'POST',
body: JSON.stringify({ key: 'test' }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe('缺少必要字段');
});
});
describe('PUT', () => {
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/config', {
method: 'PUT',
body: JSON.stringify({ configs: [] }),
});
const response = await PUT(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 if no permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/config', {
method: 'PUT',
body: JSON.stringify({ configs: [] }),
});
const response = await PUT(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 400 if configs is not an array', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockGetAdminUserId.mockResolvedValueOnce('1');
const request = new NextRequest('http://localhost/api/admin/config', {
method: 'PUT',
body: JSON.stringify({ configs: 'not-array' }),
});
const response = await PUT(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe('无效的数据格式');
});
});
});
-209
View File
@@ -1,209 +0,0 @@
import { NextRequest } from 'next/server';
import { db } from '@/db';
import { siteConfig } from '@/db/schema';
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
import { forbidden, success, notFound, validationError, badRequest, handleApiError } from '@/lib/api-response';
import { eq, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export async function GET(request: NextRequest) {
try {
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
}
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');
const key = searchParams.get('key');
if (key) {
const config = await db
.select()
.from(siteConfig)
.where(eq(siteConfig.key, key))
.limit(1);
if (config.length === 0) {
return notFound('配置不存在');
}
return success(config[0]);
}
const conditions = [];
if (category) {
conditions.push(eq(siteConfig.category, category as 'feature' | 'style' | 'seo' | 'general'));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const configs = await db
.select()
.from(siteConfig)
.where(whereClause)
.orderBy(siteConfig.key);
return success({
configs: configs,
});
} catch (error) {
return handleApiError(error);
}
}
export async function POST(request: NextRequest) {
try {
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
if (!isAdmin || !userId) {
return forbidden();
}
const body = await request.json();
const { key, value, category, description } = body;
if (!key || !value || !category) {
return validationError('缺少必要字段', { required: ['key', 'value', 'category'] });
}
const existing = await db
.select()
.from(siteConfig)
.where(eq(siteConfig.key, key))
.limit(1);
const now = new Date();
if (existing.length > 0) {
const updated = await db
.update(siteConfig)
.set({
value,
description: description || existing[0]!.description,
updatedAt: now,
updatedBy: userId,
})
.where(eq(siteConfig.key, key))
.returning();
return success(updated[0], 200);
}
const newConfig = await db
.insert(siteConfig)
.values({
id: nanoid(),
key,
value,
category,
description: description || null,
updatedAt: now,
updatedBy: userId,
})
.returning();
return success(newConfig[0], 201);
} catch (error) {
return handleApiError(error);
}
}
export async function PUT(request: NextRequest) {
try {
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
if (!isAdmin || !userId) {
return forbidden();
}
const body = await request.json();
const { configs } = body as { configs: Array<{ key: string; value: unknown; description?: string }> };
if (!Array.isArray(configs)) {
return badRequest('无效的数据格式');
}
const now = new Date();
const results = [];
for (const config of configs) {
const existing = await db
.select()
.from(siteConfig)
.where(eq(siteConfig.key, config.key))
.limit(1);
if (existing.length > 0) {
const updated = await db
.update(siteConfig)
.set({
value: config.value,
description: config.description || existing[0]!.description,
updatedAt: now,
updatedBy: userId,
})
.where(eq(siteConfig.key, config.key))
.returning();
results.push(updated[0]);
} else {
const created = await db
.insert(siteConfig)
.values({
id: nanoid(),
key: config.key,
value: config.value,
category: 'general',
description: config.description || null,
updatedAt: now,
updatedBy: userId,
})
.returning();
results.push(created[0]);
}
}
return success(results);
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(request: NextRequest) {
try {
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
}
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (!key) {
return badRequest('缺少 key 参数');
}
const existing = await db
.select()
.from(siteConfig)
.where(eq(siteConfig.key, key))
.limit(1);
if (existing.length === 0) {
return notFound('配置不存在');
}
await db
.delete(siteConfig)
.where(eq(siteConfig.key, key));
return success({ success: true });
} catch (error) {
return handleApiError(error);
}
}
@@ -1,188 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
jest.mock('@/db', () => ({
db: {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
},
}));
jest.mock('@/lib/auth', () => ({
auth: jest.fn(),
}));
jest.mock('@/lib/auth/permissions', () => ({
hasPermission: jest.fn(),
}));
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: jest.fn(),
getAdminUserId: jest.fn(),
}));
jest.mock('@/lib/audit', () => ({
createAuditLog: jest.fn().mockResolvedValue({}),
}));
const { db } = require('@/db');
const { auth } = require('@/lib/auth');
const { hasPermission } = require('@/lib/auth/permissions');
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
describe('GET /api/admin/content/[id]', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const { GET } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123');
const params = Promise.resolve({ id: '123' });
const response = await GET(request, { params });
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 if no permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const { GET } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123');
const params = Promise.resolve({ id: '123' });
const response = await GET(request, { params });
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 404 if content not found', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
db.limit.mockResolvedValue([]);
const { GET } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123');
const params = Promise.resolve({ id: '123' });
const response = await GET(request, { params });
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe('内容不存在');
});
it('should return content if found', async () => {
const mockContent = {
id: '123',
title: 'Test Content',
status: 'published',
};
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
db.limit.mockResolvedValue([mockContent]);
db.orderBy.mockResolvedValue([]);
const { GET } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123');
const params = Promise.resolve({ id: '123' });
const response = await GET(request, { params });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.title).toBe('Test Content');
});
});
describe('PUT /api/admin/content/[id]', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const { PUT } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123', {
method: 'PUT',
body: JSON.stringify({ title: 'Updated' }),
});
const params = Promise.resolve({ id: '123' });
const response = await PUT(request, { params });
const data = await response.json();
expect(response.status).toBe(403);
});
it('should return 403 if no permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const { PUT } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123', {
method: 'PUT',
body: JSON.stringify({ title: 'Updated' }),
});
const params = Promise.resolve({ id: '123' });
const response = await PUT(request, { params });
const data = await response.json();
expect(response.status).toBe(403);
});
});
describe('DELETE /api/admin/content/[id]', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const { DELETE } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123', {
method: 'DELETE',
});
const params = Promise.resolve({ id: '123' });
const response = await DELETE(request, { params });
const data = await response.json();
expect(response.status).toBe(403);
});
it('should return 403 if no permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const { DELETE } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123', {
method: 'DELETE',
});
const params = Promise.resolve({ id: '123' });
const response = await DELETE(request, { params });
const data = await response.json();
expect(response.status).toBe(403);
});
});
-185
View File
@@ -1,185 +0,0 @@
import { NextRequest } from 'next/server';
import { db } from '@/db';
import { content, contentVersions } from '@/db/schema';
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
import { createAuditLog } from '@/lib/audit';
import { forbidden, notFound, success, handleApiError } from '@/lib/api-response';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
}
const { id } = await params;
const item = await db
.select()
.from(content)
.where(eq(content.id, id))
.limit(1);
if (item.length === 0) {
return notFound('内容不存在');
}
const versions = await db
.select()
.from(contentVersions)
.where(eq(contentVersions.contentId, id))
.orderBy(contentVersions.version);
return success({
...item[0],
versions,
});
} catch (error) {
return handleApiError(error);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
if (!isAdmin || !userId) {
return forbidden();
}
const { id } = await params;
const body = await request.json();
const existingContent = await db
.select()
.from(content)
.where(eq(content.id, id))
.limit(1);
if (existingContent.length === 0) {
return notFound('内容不存在');
}
const current = existingContent[0]!;
const now = new Date();
const maxVersion = await db
.select({ max: contentVersions.version })
.from(contentVersions)
.where(eq(contentVersions.contentId, id));
const nextVersion = (maxVersion[0]?.max || 0) + 1;
await db.insert(contentVersions).values({
id: nanoid(),
contentId: id,
version: nextVersion,
title: current.title,
content: current.content,
changes: {
from: {
title: current.title,
content: current.content,
excerpt: current.excerpt,
status: current.status,
},
to: body,
},
changedBy: userId,
changedAt: now,
});
const updateData: Record<string, unknown> = {
updatedAt: now,
};
if (body.title) updateData.title = body.title;
if (body.slug) updateData.slug = body.slug;
if (body.excerpt !== undefined) updateData.excerpt = body.excerpt;
if (body.contentBody !== undefined) updateData.content = body.contentBody;
if (body.coverImage !== undefined) updateData.coverImage = body.coverImage;
if (body.category !== undefined) updateData.category = body.category;
if (body.tags !== undefined) updateData.tags = body.tags;
if (body.metadata !== undefined) updateData.metadata = body.metadata;
if (body.status) {
updateData.status = body.status;
if (body.status === 'published' && current.status !== 'published') {
updateData.publishedAt = now;
}
}
const updated = await db
.update(content)
.set(updateData)
.where(eq(content.id, id))
.returning();
await createAuditLog({
userId,
action: 'update',
resourceType: 'content',
resourceId: id,
details: {
changes: updateData,
},
});
return success(updated[0]);
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
if (!isAdmin || !userId) {
return forbidden();
}
const { id } = await params;
const existingContent = await db
.select()
.from(content)
.where(eq(content.id, id))
.limit(1);
if (existingContent.length === 0) {
return notFound('内容不存在');
}
await db.delete(contentVersions).where(eq(contentVersions.contentId, id));
await db.delete(content).where(eq(content.id, id));
await createAuditLog({
userId,
action: 'delete',
resourceType: 'content',
resourceId: id,
details: {
title: existingContent[0]!.title,
},
});
return success({ success: true });
} catch (error) {
return handleApiError(error);
}
}
-143
View File
@@ -1,143 +0,0 @@
import { describe, it, expect, jest, beforeAll, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
import '@testing-library/jest-dom';
const mockAuth = jest.fn();
const mockHasPermission = jest.fn();
const mockCheckIsAdmin = jest.fn();
const mockGetAdminUserId = jest.fn();
const mockDbSelect = jest.fn();
const mockDbInsert = jest.fn();
jest.mock('@/lib/auth', () => ({
auth: mockAuth,
}));
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: mockCheckIsAdmin,
getAdminUserId: mockGetAdminUserId,
}));
jest.mock('@/lib/auth/permissions', () => ({
hasPermission: mockHasPermission,
}));
jest.mock('@/db', () => ({
db: {
select: () => ({
from: () => ({
where: () => ({
orderBy: () => ({
limit: () => ({
offset: mockDbSelect,
}),
}),
}),
}),
}),
insert: () => ({
values: () => ({
returning: mockDbInsert,
}),
}),
},
}));
jest.mock('drizzle-orm', () => ({
eq: jest.fn(),
desc: jest.fn(),
and: jest.fn(),
like: jest.fn(),
sql: jest.fn(),
}));
jest.mock('nanoid', () => ({
nanoid: () => 'test-id-123',
}));
jest.mock('@/lib/audit', () => ({
createAuditLog: jest.fn(),
}));
jest.mock('@/db/schema', () => ({
content: {},
}));
import { GET, POST } from './route';
describe('/api/admin/content', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET', () => {
it('should return 401 when not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/content');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 when user lacks permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false, userId: '1' });
const request = new NextRequest('http://localhost/api/admin/content');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return content list when authorized', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockDbSelect.mockResolvedValueOnce([]);
mockDbSelect.mockResolvedValueOnce([{ count: 0 }]);
const request = new NextRequest('http://localhost/api/admin/content');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.items).toEqual([]);
expect(data.pagination).toBeDefined();
});
});
describe('POST', () => {
it('should return 401 when not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/content', {
method: 'POST',
body: JSON.stringify({ type: 'news', title: 'Test', slug: 'test' }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 400 when missing required fields', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockGetAdminUserId.mockResolvedValueOnce('1');
mockDbSelect.mockResolvedValueOnce([]);
const request = new NextRequest('http://localhost/api/admin/content', {
method: 'POST',
body: JSON.stringify({ type: 'news' }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe('缺少必要字段');
});
});
});
-296
View File
@@ -1,296 +0,0 @@
import { NextRequest } from 'next/server';
import { db } from '@/db';
import { content } from '@/db/schema';
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
import { createAuditLog } from '@/lib/audit';
import { forbidden, badRequest, success, handleApiError, validationError } from '@/lib/api-response';
import { eq, desc, and, like, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';
/**
* @openapi
* /api/admin/content:
* get:
* tags:
* - Admin
* - Content
* summary: 获取内容列表
* description: 管理员获取内容列表,支持分页、筛选和搜索
* operationId: getAdminContent
* security:
* - bearerAuth: []
* parameters:
* - name: type
* in: query
* description: 内容类型
* schema:
* type: string
* enum: [news, product, service, case]
* - name: status
* in: query
* description: 内容状态
* schema:
* type: string
* enum: [draft, published, archived]
* - name: search
* in: query
* description: 搜索关键词
* schema:
* type: string
* - name: page
* in: query
* description: 页码
* schema:
* type: integer
* default: 1
* - name: limit
* in: query
* description: 每页数量
* schema:
* type: integer
* default: 20
* responses:
* 200:
* description: 成功获取内容列表
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* items:
* type: array
* items:
* $ref: '#/components/schemas/Content'
* pagination:
* type: object
* properties:
* page:
* type: integer
* limit:
* type: integer
* total:
* type: integer
* totalPages:
* type: integer
* 403:
* description: 权限不足
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export async function GET(request: NextRequest) {
try {
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
}
const { searchParams } = new URL(request.url);
const type = searchParams.get('type');
const status = searchParams.get('status');
const search = searchParams.get('search');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const offset = (page - 1) * limit;
const conditions = [];
if (type) {
conditions.push(eq(content.type, type as 'news' | 'product' | 'service' | 'case'));
}
if (status) {
conditions.push(eq(content.status, status as 'draft' | 'published' | 'archived'));
}
if (search) {
conditions.push(like(content.title, `%${search}%`));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [items, countResult] = await Promise.all([
db
.select()
.from(content)
.where(whereClause)
.orderBy(desc(content.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(content)
.where(whereClause),
]);
const total = countResult[0]?.count || 0;
return success({
items,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
return handleApiError(error);
}
}
/**
* @openapi
* /api/admin/content:
* post:
* tags:
* - Admin
* - Content
* summary: 创建新内容
* description: 管理员创建新的内容(新闻、产品、服务、案例)
* operationId: createContent
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - type
* - title
* - slug
* properties:
* type:
* type: string
* enum: [news, product, service, case]
* description: 内容类型
* title:
* type: string
* description: 标题
* slug:
* type: string
* description: URL别名
* excerpt:
* type: string
* description: 摘要
* contentBody:
* type: string
* description: 内容正文
* coverImage:
* type: string
* description: 封面图片URL
* category:
* type: string
* description: 分类
* tags:
* type: array
* items:
* type: string
* description: 标签列表
* status:
* type: string
* enum: [draft, published, archived]
* default: draft
* description: 状态
* metadata:
* type: object
* description: 元数据
* responses:
* 201:
* description: 内容创建成功
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* $ref: '#/components/schemas/Content'
* 400:
* description: 请求参数错误
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: 权限不足
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export async function POST(request: NextRequest) {
try {
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
if (!isAdmin || !userId) {
return forbidden();
}
const body = await request.json();
const { type, title, slug, excerpt, contentBody, coverImage, category, tags, status: contentStatus, metadata } = body;
if (!type || !title || !slug) {
return validationError('缺少必要字段', { required: ['type', 'title', 'slug'] });
}
const existingContent = await db
.select()
.from(content)
.where(eq(content.slug, slug))
.limit(1);
if (existingContent.length > 0) {
return badRequest('Slug 已存在');
}
const now = new Date();
const newContent = await db
.insert(content)
.values({
id: nanoid(),
type,
title,
slug,
excerpt: excerpt || null,
content: contentBody || '',
coverImage: coverImage || null,
category: category || null,
tags: tags || [],
status: contentStatus || 'draft',
publishedAt: contentStatus === 'published' ? now : null,
authorId: userId,
metadata: metadata || null,
createdAt: now,
updatedAt: now,
})
.returning();
await createAuditLog({
userId,
action: 'create',
resourceType: 'content',
resourceId: newContent[0]!.id,
details: {
type,
title,
status: contentStatus || 'draft',
},
});
return success(newContent[0], 201);
} catch (error) {
return handleApiError(error);
}
}
-26
View File
@@ -1,26 +0,0 @@
import { NextResponse } from 'next/server';
import { SecurityLogger } from '@/lib/security/logger';
const securityLogger = new SecurityLogger();
export async function GET() {
try {
const logs = securityLogger.getRecentLogs(100);
const stats = securityLogger.getStats();
return NextResponse.json({
success: true,
logs,
stats,
});
} catch (error) {
console.error('Error fetching security data:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch security data'
},
{ status: 500 }
);
}
}
-102
View File
@@ -1,102 +0,0 @@
import { POST, DELETE } from './route';
import { NextRequest } from 'next/server';
jest.mock('@/lib/auth', () => ({
auth: jest.fn(),
}));
jest.mock('@/lib/auth/permissions', () => ({
hasPermission: jest.fn(),
}));
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: jest.fn(),
getAdminUserId: jest.fn(),
}));
jest.mock('@/lib/audit', () => ({
createAuditLog: jest.fn(),
}));
jest.mock('@/lib/upload', () => ({
uploadFile: jest.fn().mockResolvedValue({
id: 'test-id',
name: 'test.jpg',
type: 'image',
size: 1024,
url: 'https://example.com/test.jpg',
}),
deleteFile: jest.fn(),
}));
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
describe('/api/admin/upload', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('POST', () => {
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const formData = new FormData();
formData.append('file', new File(['test'], 'test.jpg', { type: 'image/jpeg' }));
const request = new NextRequest('http://localhost/api/admin/upload', {
method: 'POST',
body: formData,
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 if no permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/upload', {
method: 'POST',
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 400 if no file', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockGetAdminUserId.mockResolvedValueOnce('1');
const request = {
formData: jest.fn().mockResolvedValue(new FormData()),
} as any;
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe('未找到文件');
});
});
describe('DELETE', () => {
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/upload?url=test.jpg', {
method: 'DELETE',
});
const response = await DELETE(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
});
});
-84
View File
@@ -1,84 +0,0 @@
import { NextRequest } from 'next/server';
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
import { createAuditLog } from '@/lib/audit';
import { uploadFile, deleteFile } from '@/lib/upload';
import { forbidden, badRequest, notFound, success, handleApiError } from '@/lib/api-response';
export async function POST(request: NextRequest) {
try {
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
if (!isAdmin || !userId) {
return forbidden();
}
const formData = await request.formData();
const file = formData.get('file') as File | null;
const type = (formData.get('type') as 'image' | 'document') || 'image';
if (!file) {
return badRequest('未找到文件');
}
const result = await uploadFile(file, {
type,
userId,
});
await createAuditLog({
userId,
action: 'upload',
resourceType: 'file',
resourceId: result.id,
details: {
fileName: result.name,
fileType: result.type,
fileSize: result.size,
url: result.url,
},
});
return success({
success: true,
file: result,
});
} catch (error) {
console.error('文件上传失败:', error);
if (error instanceof Error) {
return badRequest(error.message);
}
return handleApiError(error);
}
}
export async function DELETE(request: NextRequest) {
try {
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
if (!isAdmin || !userId) {
return forbidden();
}
const { searchParams } = new URL(request.url);
const fileUrl = searchParams.get('url');
if (!fileUrl) {
return badRequest('缺少文件 URL');
}
const result = await deleteFile(fileUrl);
if (!result) {
return notFound('文件不存在或删除失败');
}
return success({ success: true });
} catch (error) {
console.error('文件删除失败:', error);
return handleApiError(error);
}
}
-119
View File
@@ -1,119 +0,0 @@
import { GET, PUT, DELETE } from './route';
import { NextRequest } from 'next/server';
jest.mock('@/lib/auth', () => ({
auth: jest.fn(),
}));
jest.mock('@/lib/auth/permissions', () => ({
hasPermission: jest.fn(),
}));
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: jest.fn(),
}));
jest.mock('@/db', () => ({
db: {
select: jest.fn().mockReturnValue({
from: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
limit: jest.fn().mockResolvedValue([{
id: 'test-user-id',
email: 'test@example.com',
name: 'Test User',
isAdmin: true,
}]),
}),
}),
}),
update: jest.fn().mockReturnValue({
set: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
returning: jest.fn().mockResolvedValue([{
id: 'test-user-id',
email: 'updated@example.com',
name: 'Updated User',
}]),
}),
}),
}),
delete: jest.fn().mockReturnValue({
where: jest.fn().mockResolvedValue(undefined),
}),
},
}));
const { checkIsAdmin: mockCheckIsAdmin } = require('@/lib/auth/check-permission');
describe('/api/admin/users/[id]', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET', () => {
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/users/test-id');
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 if no permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/users/test-id');
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return user if authenticated and has permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
const request = new NextRequest('http://localhost/api/admin/users/test-id');
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.user).toBeDefined();
});
});
describe('PUT', () => {
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/users/test-id', {
method: 'PUT',
body: JSON.stringify({ name: 'Updated User' }),
});
const response = await PUT(request, { params: Promise.resolve({ id: 'test-id' }) });
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
});
describe('DELETE', () => {
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/users/test-id', {
method: 'DELETE',
});
const response = await DELETE(request, { params: Promise.resolve({ id: 'test-id' }) });
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
});
});
-121
View File
@@ -1,121 +0,0 @@
import { NextRequest } from 'next/server';
import { db } from '@/db';
import { users } from '@/db/schema';
import { checkIsAdmin } from '@/lib/auth/check-permission';
import { forbidden, notFound, success, handleApiError } from '@/lib/api-response';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
}
const { id } = await params;
const user = await db
.select({
id: users.id,
email: users.email,
name: users.name,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.where(eq(users.id, id))
.limit(1);
if (user.length === 0) {
return notFound('用户不存在');
}
return success({ user: user[0] });
} catch (error) {
return handleApiError(error);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
}
const { id } = await params;
const body = await request.json();
const { email, name, password } = body;
const existingUser = await db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
if (existingUser.length === 0) {
return notFound('用户不存在');
}
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
};
if (email) updateData.email = email;
if (name) updateData.name = name;
if (password) {
updateData.passwordHash = await bcrypt.hash(password, 10);
}
const updated = await db
.update(users)
.set(updateData)
.where(eq(users.id, id))
.returning();
return success({
user: {
id: updated[0]!.id,
email: updated[0]!.email,
name: updated[0]!.name,
isAdmin: updated[0]!.isAdmin,
},
});
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
}
const { id } = await params;
await db
.delete(users)
.where(eq(users.id, id));
return success({ success: true });
} catch (error) {
return handleApiError(error);
}
}
-102
View File
@@ -1,102 +0,0 @@
import { describe, it, expect, jest, beforeAll, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
import '@testing-library/jest-dom';
const mockAuth = jest.fn();
const mockHasPermission = jest.fn();
const mockCheckIsAdmin = jest.fn();
const mockDbSelect = jest.fn();
const mockDbInsert = jest.fn();
jest.mock('@/lib/auth', () => ({
auth: mockAuth,
}));
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: mockCheckIsAdmin,
}));
jest.mock('@/lib/auth/permissions', () => ({
hasPermission: mockHasPermission,
}));
jest.mock('@/db', () => ({
db: {
select: () => ({
from: () => ({
where: () => ({
limit: mockDbSelect,
}),
orderBy: () => mockDbSelect(),
}),
}),
insert: () => ({
values: () => ({
returning: mockDbInsert,
}),
}),
},
}));
jest.mock('drizzle-orm', () => ({
eq: jest.fn(),
desc: jest.fn(),
}));
jest.mock('nanoid', () => ({
nanoid: () => 'test-id-123',
}));
jest.mock('bcryptjs', () => ({
hash: jest.fn().mockResolvedValue('hashed-password'),
}));
jest.mock('@/db/schema', () => ({
users: {},
}));
import { GET } from './route';
describe('/api/admin/users', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET', () => {
it('should return 401 when not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/users');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 when user lacks permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false, userId: '1' });
const request = new NextRequest('http://localhost/api/admin/users');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return users list when authorized', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockDbSelect.mockResolvedValueOnce([
{ id: '1', email: 'admin@example.com', name: 'Admin', isAdmin: true },
]);
const request = new NextRequest('http://localhost/api/admin/users');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.users).toBeDefined();
});
});
});
-33
View File
@@ -1,33 +0,0 @@
import { NextRequest } from 'next/server';
import { db } from '@/db';
import { users } from '@/db/schema';
import { checkIsAdmin } from '@/lib/auth/check-permission';
import { forbidden, success, handleApiError } from '@/lib/api-response';
import { desc } from 'drizzle-orm';
export async function GET(_request: NextRequest) {
try {
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
}
const allUsers = await db
.select({
id: users.id,
email: users.email,
name: users.name,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.orderBy(desc(users.createdAt));
return success({ users: allUsers });
} catch (error) {
console.error('获取用户列表失败:', error);
return handleApiError(error);
}
}

Some files were not shown because too many files have changed in this diff Show More