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:
+1
-10
@@ -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
|
|
||||||
@@ -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
@@ -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
@@ -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"]
|
|
||||||
|
|||||||
@@ -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系统使用指南
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
|
||||||
@@ -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]
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
所有配置文件和文档都已经更新完成,联系方式现在统一且准确!
|
|
||||||
@@ -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
@@ -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 },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
@@ -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)
|
|
||||||
@@ -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标签
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题2:API不显示在文档中
|
|
||||||
|
|
||||||
**症状**:某些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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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自动执行和报告
|
|
||||||
|
|
||||||
开始使用分层测试,提高测试效率,缩短反馈周期!
|
|
||||||
@@ -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;
|
|
||||||
@@ -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`);
|
|
||||||
@@ -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`);
|
|
||||||
@@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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 || '未设置'}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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.
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
Executable
+84
@@ -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 "========================================="
|
||||||
@@ -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,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"
|
|
||||||
|
|||||||
@@ -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 "请重启应用以使更改生效"
|
|
||||||
@@ -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 "✅ 推荐使用方法1(Web UI设置)"
|
|
||||||
echo ""
|
|
||||||
@@ -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 "========================================="
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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证书配置完成!"
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: '提交失败,请重试' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
我们不追逐"最火"的技术,只选择"最对"的技术。
|
我们不追逐“最火”的技术,只选择“最对”的技术。
|
||||||
</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>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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('无效的数据格式');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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('缺少必要字段');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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('无权限执行此操作');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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('无权限执行此操作');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
Reference in New Issue
Block a user