Compare commits
10 Commits
86edcf21b3
...
23a4e58d91
| Author | SHA1 | Date | |
|---|---|---|---|
| 23a4e58d91 | |||
| fa917639d2 | |||
| e2a703fac5 | |||
| 505afb8c0d | |||
| 2b5ff62ff0 | |||
| ffc41ecae5 | |||
| 367a2e3f46 | |||
| 82d3ae5b44 | |||
| 2a7ca140c7 | |||
| 8375c2d6e4 |
32
.dockerignore
Normal file
32
.dockerignore
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 忽略不需要的文件和目录
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.nyc_output
|
||||||
|
coverage
|
||||||
|
.cache
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# 只保留dist目录和配置文件
|
||||||
|
!dist/
|
||||||
|
!nginx.conf
|
||||||
|
!Dockerfile
|
||||||
|
!docker-compose.yml
|
||||||
|
|
||||||
|
# 忽略开发相关文件
|
||||||
|
src/
|
||||||
|
public/
|
||||||
|
*.md
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# 忽略构建缓存
|
||||||
|
.next
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
36
.env.production
Normal file
36
.env.production
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 生产环境配置文件
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# 应用配置
|
||||||
|
APP_NAME=AI Chat
|
||||||
|
APP_VERSION=1.0.0
|
||||||
|
APP_PORT=80
|
||||||
|
|
||||||
|
# 域名配置(请修改为您的实际域名)
|
||||||
|
DOMAIN=your-domain.com
|
||||||
|
API_BASE_URL=https://your-api-domain.com
|
||||||
|
|
||||||
|
# SSL配置
|
||||||
|
SSL_CERT_PATH=/etc/nginx/ssl/fullchain.pem
|
||||||
|
SSL_KEY_PATH=/etc/nginx/ssl/privkey.pem
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
LOG_LEVEL=warn
|
||||||
|
LOG_PATH=/var/log/nginx
|
||||||
|
|
||||||
|
# 安全配置
|
||||||
|
ENABLE_SECURITY_HEADERS=true
|
||||||
|
ENABLE_RATE_LIMITING=true
|
||||||
|
|
||||||
|
# 缓存配置
|
||||||
|
ENABLE_GZIP=true
|
||||||
|
STATIC_CACHE_TIME=31536000 # 1年
|
||||||
|
HTML_CACHE_TIME=0
|
||||||
|
|
||||||
|
# 监控配置
|
||||||
|
ENABLE_HEALTH_CHECK=true
|
||||||
|
HEALTH_CHECK_PATH=/health
|
||||||
|
|
||||||
|
# 备份配置
|
||||||
|
BACKUP_RETENTION_DAYS=7
|
||||||
|
AUTO_BACKUP=true
|
||||||
70
.eslintrc.js
Normal file
70
.eslintrc.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ['taro/react'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true
|
||||||
|
},
|
||||||
|
useJSXTextNode: true,
|
||||||
|
project: './tsconfig.json'
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
'@typescript-eslint',
|
||||||
|
'react',
|
||||||
|
'react-hooks'
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
// TypeScript 相关规则
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', {
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_'
|
||||||
|
}],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-empty-function': 'warn',
|
||||||
|
|
||||||
|
// React 相关规则
|
||||||
|
'react/jsx-uses-react': 'off',
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
|
||||||
|
// 通用规则
|
||||||
|
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
|
'no-unused-vars': 'off', // 使用 TypeScript 版本
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
|
||||||
|
// 代码风格
|
||||||
|
'indent': ['error', 2],
|
||||||
|
'quotes': ['error', 'single'],
|
||||||
|
'semi': ['error', 'always'],
|
||||||
|
'comma-dangle': ['error', 'never'],
|
||||||
|
'object-curly-spacing': ['error', 'always'],
|
||||||
|
'array-bracket-spacing': ['error', 'never'],
|
||||||
|
|
||||||
|
// Taro 特定规则
|
||||||
|
'taro/this-props-function': 'error',
|
||||||
|
'taro/no-stateless-component': 'off',
|
||||||
|
'taro/jsx-handler-names': 'off'
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es6: true,
|
||||||
|
node: true
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ignorePatterns: [
|
||||||
|
'dist/',
|
||||||
|
'node_modules/',
|
||||||
|
'*.config.js',
|
||||||
|
'*.config.ts'
|
||||||
|
]
|
||||||
|
};
|
||||||
16
.workbuddy/memory/2026-04-06.md
Normal file
16
.workbuddy/memory/2026-04-06.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# 2026-04-06 工作记录
|
||||||
|
|
||||||
|
## 首页视频替换
|
||||||
|
|
||||||
|
- 文件:`src/pages/index/Video.tsx`
|
||||||
|
- 旧视频:`https://oss.wsdns.cn/20250722/018be1bd1c8b4cc4a15076ad0578b88d.mp4`
|
||||||
|
- 新视频(微信传入 7.mp4,约 11MB):`https://oss.wsdns.cn/20260406/2be0376cac054f2ba86dd35a2bc52e11.mp4`
|
||||||
|
- 上传方式:通过 `https://server.websoft.top/api/oss/upload` + Header `TenantId: 10556`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 光荣榜列表去重处理
|
||||||
|
|
||||||
|
- 问题:`src/honor/list.tsx` 渲染的"2024年立功受奖光荣榜"列表中,"黄富钜"出现了两次(后端数据库重复条目导致)。
|
||||||
|
- 修复:在 `list.tsx` 的数据赋值逻辑中,第一页和翻页追加时均按 `articleId` 去重,防止同一条目重复展示。
|
||||||
|
- 建议:根本解决方案是从后端数据库中删除重复的文章记录。
|
||||||
0
.workbuddy/memory/MEMORY.md
Normal file
0
.workbuddy/memory/MEMORY.md
Normal file
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 使用官方nginx镜像作为基础镜像
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 删除nginx默认的静态文件
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
|
||||||
|
# 复制dist目录下的所有文件到nginx的html目录
|
||||||
|
COPY dist/ /usr/share/nginx/html/
|
||||||
|
|
||||||
|
# 创建自定义nginx配置文件
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# 暴露80端口
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# 启动nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
70
Dockerfile.prod
Normal file
70
Dockerfile.prod
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# 生产环境多阶段构建 Dockerfile
|
||||||
|
# 第一阶段:构建应用
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制package文件
|
||||||
|
COPY package*.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# 安装pnpm
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建应用
|
||||||
|
RUN pnpm run build:h5
|
||||||
|
|
||||||
|
# 第二阶段:生产环境镜像
|
||||||
|
FROM nginx:1.25-alpine AS production
|
||||||
|
|
||||||
|
# 安装必要的工具
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
curl \
|
||||||
|
tzdata \
|
||||||
|
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
|
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||||
|
&& apk del tzdata
|
||||||
|
|
||||||
|
# 创建nginx用户和组
|
||||||
|
RUN addgroup -g 1001 -S nginx-app && \
|
||||||
|
adduser -S -D -H -u 1001 -h /var/cache/nginx -s /sbin/nologin -G nginx-app -g nginx-app nginx-app
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 删除nginx默认文件
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
|
||||||
|
# 从构建阶段复制构建产物
|
||||||
|
COPY --from=builder /app/dist/ /usr/share/nginx/html/
|
||||||
|
|
||||||
|
# 复制nginx配置
|
||||||
|
COPY nginx.prod.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# 创建日志目录
|
||||||
|
RUN mkdir -p /var/log/nginx && \
|
||||||
|
chown -R nginx-app:nginx-app /var/log/nginx && \
|
||||||
|
chown -R nginx-app:nginx-app /usr/share/nginx/html && \
|
||||||
|
chown -R nginx-app:nginx-app /var/cache/nginx
|
||||||
|
|
||||||
|
# 设置正确的权限
|
||||||
|
RUN chmod -R 755 /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:80/ || exit 1
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# 使用非root用户运行
|
||||||
|
USER nginx-app
|
||||||
|
|
||||||
|
# 启动nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
143
README-Docker.md
Normal file
143
README-Docker.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# AI Chat Docker 部署指南
|
||||||
|
|
||||||
|
## 📦 文件说明
|
||||||
|
|
||||||
|
- `Dockerfile` - Docker镜像构建文件
|
||||||
|
- `docker-compose.yml` - Docker Compose配置文件
|
||||||
|
- `nginx.conf` - Nginx服务器配置
|
||||||
|
- `proxy.conf` - 反向代理配置(可选)
|
||||||
|
- `.dockerignore` - Docker构建忽略文件
|
||||||
|
|
||||||
|
## 🚀 快速启动
|
||||||
|
|
||||||
|
### 1. 基础部署(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建并启动服务
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 查看服务状态
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f web
|
||||||
|
```
|
||||||
|
|
||||||
|
访问地址:http://localhost:3000
|
||||||
|
|
||||||
|
### 2. 使用反向代理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动包含反向代理的完整服务
|
||||||
|
docker-compose --profile proxy up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
访问地址:http://localhost
|
||||||
|
|
||||||
|
### 3. 手动构建镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
docker build -t ai-chat:latest .
|
||||||
|
|
||||||
|
# 运行容器
|
||||||
|
docker run -d -p 3000:80 --name ai-chat-container ai-chat:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 配置说明
|
||||||
|
|
||||||
|
### 端口配置
|
||||||
|
- 默认端口:3000 (可在docker-compose.yml中修改)
|
||||||
|
- 反向代理端口:80/443
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
- `TZ=Asia/Shanghai` - 时区设置
|
||||||
|
|
||||||
|
### 数据卷
|
||||||
|
- `./logs:/var/log/nginx` - Nginx日志持久化
|
||||||
|
|
||||||
|
## 📝 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止服务
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# 重新构建并启动
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# 查看容器状态
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# 进入容器
|
||||||
|
docker-compose exec web sh
|
||||||
|
|
||||||
|
# 查看实时日志
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 清理未使用的镜像
|
||||||
|
docker image prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 生产环境配置
|
||||||
|
|
||||||
|
### 1. HTTPS配置
|
||||||
|
1. 将SSL证书放入 `./ssl/` 目录
|
||||||
|
2. 取消注释 `proxy.conf` 中的HTTPS配置
|
||||||
|
3. 重启服务
|
||||||
|
|
||||||
|
### 2. 性能优化
|
||||||
|
- 启用gzip压缩 ✅
|
||||||
|
- 静态资源缓存 ✅
|
||||||
|
- 安全头设置 ✅
|
||||||
|
|
||||||
|
### 3. 监控和日志
|
||||||
|
```bash
|
||||||
|
# 查看资源使用情况
|
||||||
|
docker stats ai-chat-web
|
||||||
|
|
||||||
|
# 查看详细信息
|
||||||
|
docker inspect ai-chat-web
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 故障排除
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **端口被占用**
|
||||||
|
```bash
|
||||||
|
# 修改docker-compose.yml中的端口映射
|
||||||
|
ports:
|
||||||
|
- "8080:80" # 改为其他端口
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **权限问题**
|
||||||
|
```bash
|
||||||
|
# 确保dist目录有正确权限
|
||||||
|
chmod -R 755 dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **容器无法启动**
|
||||||
|
```bash
|
||||||
|
# 查看详细错误信息
|
||||||
|
docker-compose logs web
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 监控指标
|
||||||
|
|
||||||
|
- 容器状态:`docker-compose ps`
|
||||||
|
- 资源使用:`docker stats`
|
||||||
|
- 访问日志:`./logs/access.log`
|
||||||
|
- 错误日志:`./logs/error.log`
|
||||||
|
|
||||||
|
## 🔄 更新部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 重新构建应用
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 2. 重新构建Docker镜像
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# 3. 重启服务
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
300
README-Production.md
Normal file
300
README-Production.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# 🚀 AI Chat 生产环境部署指南
|
||||||
|
|
||||||
|
## 📋 概述
|
||||||
|
|
||||||
|
本指南提供了完整的生产环境Docker部署方案,包括安全配置、性能优化、监控和自动化部署。
|
||||||
|
|
||||||
|
## 🏗️ 架构说明
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ 用户请求 │───▶│ Nginx Proxy │───▶│ AI Chat App │
|
||||||
|
│ │ │ (HTTPS/SSL) │ │ (Frontend) │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 日志 & 监控 │
|
||||||
|
│ (可选组件) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 快速部署
|
||||||
|
|
||||||
|
### 1. 基础HTTP部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一键部署(推荐)
|
||||||
|
./deploy-prod.sh
|
||||||
|
|
||||||
|
# 或手动部署
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
访问地址:http://your-server-ip
|
||||||
|
|
||||||
|
### 2. HTTPS部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 配置SSL证书
|
||||||
|
./ssl-setup.sh
|
||||||
|
|
||||||
|
# 2. HTTPS部署
|
||||||
|
./deploy-prod.sh https
|
||||||
|
```
|
||||||
|
|
||||||
|
访问地址:https://your-domain.com
|
||||||
|
|
||||||
|
## 🔧 详细配置
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- **操作系统**: Linux (Ubuntu 20.04+ / CentOS 8+ 推荐)
|
||||||
|
- **Docker**: 20.10+
|
||||||
|
- **Docker Compose**: 2.0+
|
||||||
|
- **内存**: 最低 1GB,推荐 2GB+
|
||||||
|
- **存储**: 最低 10GB 可用空间
|
||||||
|
- **网络**: 80/443 端口开放
|
||||||
|
|
||||||
|
### 文件说明
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `Dockerfile.prod` | 生产环境多阶段构建 |
|
||||||
|
| `docker-compose.prod.yml` | 生产环境编排配置 |
|
||||||
|
| `nginx.prod.conf` | 生产级Nginx配置 |
|
||||||
|
| `nginx-proxy.conf` | HTTPS反向代理配置 |
|
||||||
|
| `deploy-prod.sh` | 自动化部署脚本 |
|
||||||
|
| `ssl-setup.sh` | SSL证书配置脚本 |
|
||||||
|
| `.env.production` | 生产环境变量 |
|
||||||
|
|
||||||
|
## 🔒 SSL/HTTPS 配置
|
||||||
|
|
||||||
|
### Let's Encrypt (推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 自动配置Let's Encrypt证书
|
||||||
|
./ssl-setup.sh
|
||||||
|
# 选择选项1,输入域名和邮箱
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义证书
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 将证书文件放入ssl目录
|
||||||
|
cp your-cert.pem ssl/fullchain.pem
|
||||||
|
cp your-key.pem ssl/privkey.pem
|
||||||
|
|
||||||
|
# 部署HTTPS
|
||||||
|
./deploy-prod.sh https
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 监控和日志
|
||||||
|
|
||||||
|
### 启用监控模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy-prod.sh monitoring
|
||||||
|
```
|
||||||
|
|
||||||
|
包含以下组件:
|
||||||
|
- **Watchtower**: 自动更新容器
|
||||||
|
- **Promtail**: 日志收集(可选)
|
||||||
|
|
||||||
|
### 日志查看
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 应用日志
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f ai-chat-web
|
||||||
|
|
||||||
|
# Nginx访问日志
|
||||||
|
tail -f logs/nginx/access.log
|
||||||
|
|
||||||
|
# 错误日志
|
||||||
|
tail -f logs/nginx/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 运维命令
|
||||||
|
|
||||||
|
### 基础操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看服务状态
|
||||||
|
docker-compose -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
docker-compose -f docker-compose.prod.yml restart
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# 查看资源使用
|
||||||
|
docker stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 拉取最新代码
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 2. 重新部署
|
||||||
|
./deploy-prod.sh
|
||||||
|
|
||||||
|
# 3. 清理旧镜像
|
||||||
|
docker image prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 备份和恢复
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 自动备份(部署时自动执行)
|
||||||
|
tar -czf backup_$(date +%Y%m%d_%H%M%S).tar.gz dist/ logs/
|
||||||
|
|
||||||
|
# 恢复备份
|
||||||
|
tar -xzf backup_20240101_120000.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛡️ 安全配置
|
||||||
|
|
||||||
|
### 已启用的安全特性
|
||||||
|
|
||||||
|
✅ **HTTPS强制重定向**
|
||||||
|
✅ **安全HTTP头**
|
||||||
|
✅ **HSTS (HTTP Strict Transport Security)**
|
||||||
|
✅ **XSS保护**
|
||||||
|
✅ **内容类型嗅探保护**
|
||||||
|
✅ **点击劫持保护**
|
||||||
|
✅ **CSP (Content Security Policy)**
|
||||||
|
✅ **非root用户运行**
|
||||||
|
|
||||||
|
### 额外安全建议
|
||||||
|
|
||||||
|
1. **防火墙配置**
|
||||||
|
```bash
|
||||||
|
# 只开放必要端口
|
||||||
|
ufw allow 22 # SSH
|
||||||
|
ufw allow 80 # HTTP
|
||||||
|
ufw allow 443 # HTTPS
|
||||||
|
ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **定期更新**
|
||||||
|
```bash
|
||||||
|
# 系统更新
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Docker镜像更新(Watchtower自动处理)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 故障排除
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **容器启动失败**
|
||||||
|
```bash
|
||||||
|
# 查看详细错误
|
||||||
|
docker-compose -f docker-compose.prod.yml logs ai-chat-web
|
||||||
|
|
||||||
|
# 检查端口占用
|
||||||
|
netstat -tlnp | grep :80
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **SSL证书问题**
|
||||||
|
```bash
|
||||||
|
# 检查证书有效性
|
||||||
|
openssl x509 -in ssl/fullchain.pem -text -noout
|
||||||
|
|
||||||
|
# 重新生成证书
|
||||||
|
./ssl-setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **性能问题**
|
||||||
|
```bash
|
||||||
|
# 查看资源使用
|
||||||
|
docker stats
|
||||||
|
|
||||||
|
# 查看系统负载
|
||||||
|
htop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 健康检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 应用健康检查
|
||||||
|
curl http://localhost/health
|
||||||
|
|
||||||
|
# 容器健康状态
|
||||||
|
docker-compose -f docker-compose.prod.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 性能优化
|
||||||
|
|
||||||
|
### 已启用的优化
|
||||||
|
|
||||||
|
✅ **Gzip压缩** - 减少传输大小
|
||||||
|
✅ **静态资源缓存** - 1年缓存期
|
||||||
|
✅ **HTTP/2支持** - 提升加载速度
|
||||||
|
✅ **连接复用** - 减少连接开销
|
||||||
|
✅ **多阶段构建** - 减小镜像大小
|
||||||
|
|
||||||
|
### 进一步优化
|
||||||
|
|
||||||
|
1. **CDN配置**
|
||||||
|
```bash
|
||||||
|
# 配置CDN加速静态资源
|
||||||
|
# 修改nginx.prod.conf中的静态资源处理
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **缓存策略**
|
||||||
|
```bash
|
||||||
|
# 根据业务需求调整缓存时间
|
||||||
|
# 编辑nginx.prod.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 CI/CD 集成
|
||||||
|
|
||||||
|
### GitHub Actions 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/deploy.yml
|
||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Deploy to server
|
||||||
|
run: |
|
||||||
|
ssh user@server 'cd /path/to/app && git pull && ./deploy-prod.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📞 技术支持
|
||||||
|
|
||||||
|
如果遇到问题,请检查:
|
||||||
|
|
||||||
|
1. **日志文件**: `logs/nginx/error.log`
|
||||||
|
2. **容器状态**: `docker-compose ps`
|
||||||
|
3. **系统资源**: `htop`, `df -h`
|
||||||
|
4. **网络连接**: `netstat -tlnp`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 生产环境检查清单
|
||||||
|
|
||||||
|
- [ ] 域名DNS解析正确
|
||||||
|
- [ ] SSL证书配置完成
|
||||||
|
- [ ] 防火墙规则设置
|
||||||
|
- [ ] 备份策略配置
|
||||||
|
- [ ] 监控告警设置
|
||||||
|
- [ ] 性能测试通过
|
||||||
|
- [ ] 安全扫描通过
|
||||||
|
- [ ] 文档更新完成
|
||||||
|
|
||||||
|
**🎉 恭喜!您的AI Chat应用已成功部署到生产环境!**
|
||||||
12
config/app.ts
Normal file
12
config/app.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { API_BASE_URL } from './env'
|
||||||
|
|
||||||
|
// 租户ID - 请根据实际情况修改
|
||||||
|
export const TenantId = '10556';
|
||||||
|
// 接口地址 - 请根据实际情况修改
|
||||||
|
export const BaseUrl = API_BASE_URL;
|
||||||
|
// 当前版本
|
||||||
|
export const Version = 'v3.0.8';
|
||||||
|
// 版权信息
|
||||||
|
export const Copyright = 'WebSoft Inc.';
|
||||||
|
|
||||||
|
// java -jar CertificateDownloader.jar -k 0kF5OlPr482EZwtn9zGufUcqa7ovgxRL -m 1723321338 -f ./apiclient_key.pem -s 2B933F7C35014A1C363642623E4A62364B34C4EB -o ./
|
||||||
42
config/env.ts
Normal file
42
config/env.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// 环境变量配置
|
||||||
|
export const ENV_CONFIG = {
|
||||||
|
// 开发环境
|
||||||
|
development: {
|
||||||
|
API_BASE_URL: 'https://cms-api.websoft.top/api',
|
||||||
|
APP_NAME: '开发环境',
|
||||||
|
DEBUG: 'true',
|
||||||
|
},
|
||||||
|
// 生产环境
|
||||||
|
production: {
|
||||||
|
API_BASE_URL: 'https://cms-api.websoft.top/api',
|
||||||
|
APP_NAME: '九运售电云',
|
||||||
|
DEBUG: 'false',
|
||||||
|
},
|
||||||
|
// 测试环境
|
||||||
|
test: {
|
||||||
|
API_BASE_URL: 'https://cms-api.s209.websoft.top/api',
|
||||||
|
APP_NAME: '测试环境',
|
||||||
|
DEBUG: 'true',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前环境配置
|
||||||
|
export function getEnvConfig() {
|
||||||
|
const env = process.env.NODE_ENV || 'development'
|
||||||
|
if (env === 'production') {
|
||||||
|
return ENV_CONFIG.production
|
||||||
|
} else { // @ts-ignore
|
||||||
|
if (env === 'test') {
|
||||||
|
return ENV_CONFIG.test
|
||||||
|
} else {
|
||||||
|
return ENV_CONFIG.development
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出环境变量
|
||||||
|
export const {
|
||||||
|
API_BASE_URL,
|
||||||
|
APP_NAME,
|
||||||
|
DEBUG
|
||||||
|
} = getEnvConfig()
|
||||||
220
deploy-prod.sh
Executable file
220
deploy-prod.sh
Executable file
@@ -0,0 +1,220 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# AI Chat 生产环境部署脚本
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 日志函数
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查必要的工具
|
||||||
|
check_requirements() {
|
||||||
|
log_info "检查部署环境..."
|
||||||
|
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
log_error "Docker 未安装,请先安装 Docker"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
log_error "Docker Compose 未安装,请先安装 Docker Compose"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "环境检查通过"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 创建必要的目录
|
||||||
|
create_directories() {
|
||||||
|
log_info "创建必要的目录..."
|
||||||
|
|
||||||
|
mkdir -p logs/nginx
|
||||||
|
mkdir -p logs/proxy
|
||||||
|
mkdir -p ssl
|
||||||
|
mkdir -p backups
|
||||||
|
|
||||||
|
log_success "目录创建完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 备份当前部署
|
||||||
|
backup_current() {
|
||||||
|
if [ -d "dist" ]; then
|
||||||
|
log_info "备份当前部署..."
|
||||||
|
timestamp=$(date +%Y%m%d_%H%M%S)
|
||||||
|
tar -czf "backups/backup_${timestamp}.tar.gz" dist/ logs/ 2>/dev/null || true
|
||||||
|
log_success "备份完成: backups/backup_${timestamp}.tar.gz"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建应用
|
||||||
|
build_app() {
|
||||||
|
log_info "构建应用..."
|
||||||
|
|
||||||
|
if [ ! -f "package.json" ]; then
|
||||||
|
log_error "package.json 不存在,请确保在项目根目录运行此脚本"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查是否有pnpm
|
||||||
|
if command -v pnpm &> /dev/null; then
|
||||||
|
log_info "使用 pnpm 安装依赖..."
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm run build:h5
|
||||||
|
elif command -v npm &> /dev/null; then
|
||||||
|
log_info "使用 npm 安装依赖..."
|
||||||
|
npm ci
|
||||||
|
npm run build:h5
|
||||||
|
else
|
||||||
|
log_error "未找到 npm 或 pnpm"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "dist" ]; then
|
||||||
|
log_error "构建失败,dist 目录不存在"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "应用构建完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 部署应用
|
||||||
|
deploy_app() {
|
||||||
|
local mode=$1
|
||||||
|
|
||||||
|
log_info "开始部署应用 (模式: $mode)..."
|
||||||
|
|
||||||
|
case $mode in
|
||||||
|
"basic")
|
||||||
|
docker-compose -f docker-compose.prod.yml down 2>/dev/null || true
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d --build
|
||||||
|
;;
|
||||||
|
"https")
|
||||||
|
if [ ! -f "ssl/fullchain.pem" ] || [ ! -f "ssl/privkey.pem" ]; then
|
||||||
|
log_warning "SSL证书不存在,请先配置SSL证书"
|
||||||
|
log_info "将使用基础模式部署..."
|
||||||
|
docker-compose -f docker-compose.prod.yml down 2>/dev/null || true
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d --build
|
||||||
|
else
|
||||||
|
docker-compose -f docker-compose.prod.yml --profile https down 2>/dev/null || true
|
||||||
|
docker-compose -f docker-compose.prod.yml --profile https up -d --build
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"monitoring")
|
||||||
|
docker-compose -f docker-compose.prod.yml --profile monitoring down 2>/dev/null || true
|
||||||
|
docker-compose -f docker-compose.prod.yml --profile monitoring up -d --build
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "未知的部署模式: $mode"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
log_success "应用部署完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 等待服务启动
|
||||||
|
wait_for_service() {
|
||||||
|
log_info "等待服务启动..."
|
||||||
|
|
||||||
|
local max_attempts=30
|
||||||
|
local attempt=1
|
||||||
|
|
||||||
|
while [ $attempt -le $max_attempts ]; do
|
||||||
|
if curl -s http://localhost/health > /dev/null 2>&1; then
|
||||||
|
log_success "服务启动成功"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "等待服务启动... ($attempt/$max_attempts)"
|
||||||
|
sleep 2
|
||||||
|
((attempt++))
|
||||||
|
done
|
||||||
|
|
||||||
|
log_warning "服务启动超时,请检查日志"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 显示部署信息
|
||||||
|
show_deployment_info() {
|
||||||
|
log_success "🎉 部署完成!"
|
||||||
|
echo ""
|
||||||
|
echo "📋 部署信息:"
|
||||||
|
echo " - 访问地址: http://localhost"
|
||||||
|
echo " - 健康检查: http://localhost/health"
|
||||||
|
echo " - 日志目录: ./logs/"
|
||||||
|
echo ""
|
||||||
|
echo "🔧 管理命令:"
|
||||||
|
echo " - 查看状态: docker-compose -f docker-compose.prod.yml ps"
|
||||||
|
echo " - 查看日志: docker-compose -f docker-compose.prod.yml logs -f"
|
||||||
|
echo " - 停止服务: docker-compose -f docker-compose.prod.yml down"
|
||||||
|
echo " - 重启服务: docker-compose -f docker-compose.prod.yml restart"
|
||||||
|
echo ""
|
||||||
|
echo "📊 监控:"
|
||||||
|
echo " - 容器状态: docker stats"
|
||||||
|
echo " - 系统资源: htop"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主函数
|
||||||
|
main() {
|
||||||
|
echo "🚀 AI Chat 生产环境部署脚本"
|
||||||
|
echo "================================"
|
||||||
|
|
||||||
|
# 检查参数
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "请选择部署模式:"
|
||||||
|
echo "1) 基础模式 (HTTP)"
|
||||||
|
echo "2) HTTPS模式"
|
||||||
|
echo "3) 监控模式"
|
||||||
|
echo "4) 仅构建"
|
||||||
|
echo ""
|
||||||
|
read -p "请输入选择 (1-4): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1) MODE="basic" ;;
|
||||||
|
2) MODE="https" ;;
|
||||||
|
3) MODE="monitoring" ;;
|
||||||
|
4) MODE="build-only" ;;
|
||||||
|
*) log_error "无效选择"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
MODE=$1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 执行部署步骤
|
||||||
|
check_requirements
|
||||||
|
create_directories
|
||||||
|
backup_current
|
||||||
|
build_app
|
||||||
|
|
||||||
|
if [ "$MODE" != "build-only" ]; then
|
||||||
|
deploy_app $MODE
|
||||||
|
wait_for_service
|
||||||
|
show_deployment_info
|
||||||
|
else
|
||||||
|
log_success "构建完成,跳过部署"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 运行主函数
|
||||||
|
main "$@"
|
||||||
87
deploy.sh
Executable file
87
deploy.sh
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# AI Chat Docker 部署脚本
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 开始部署 AI Chat 应用..."
|
||||||
|
|
||||||
|
# 检查Docker是否安装
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ Docker 未安装,请先安装 Docker"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查Docker Compose是否安装
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
echo "❌ Docker Compose 未安装,请先安装 Docker Compose"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查dist目录是否存在
|
||||||
|
if [ ! -d "dist" ]; then
|
||||||
|
echo "❌ dist 目录不存在,请先构建项目"
|
||||||
|
echo "运行: npm run build"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建日志目录
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# 选择部署模式
|
||||||
|
echo "请选择部署模式:"
|
||||||
|
echo "1) 简单模式 (端口 3000)"
|
||||||
|
echo "2) 完整模式 (端口 3000)"
|
||||||
|
echo "3) 带反向代理 (端口 80)"
|
||||||
|
|
||||||
|
read -p "请输入选择 (1-3): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
echo "📦 使用简单模式部署..."
|
||||||
|
docker-compose -f docker-compose.simple.yml down 2>/dev/null || true
|
||||||
|
docker-compose -f docker-compose.simple.yml up -d --build
|
||||||
|
echo "✅ 部署完成!访问地址: http://localhost:3000"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo "📦 使用完整模式部署..."
|
||||||
|
docker-compose down 2>/dev/null || true
|
||||||
|
docker-compose up -d --build
|
||||||
|
echo "✅ 部署完成!访问地址: http://localhost:3000"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo "📦 使用反向代理模式部署..."
|
||||||
|
docker-compose --profile proxy down 2>/dev/null || true
|
||||||
|
docker-compose --profile proxy up -d --build
|
||||||
|
echo "✅ 部署完成!访问地址: http://localhost"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ 无效选择"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# 等待服务启动
|
||||||
|
echo "⏳ 等待服务启动..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# 检查服务状态
|
||||||
|
if [ "$choice" = "1" ]; then
|
||||||
|
docker-compose -f docker-compose.simple.yml ps
|
||||||
|
else
|
||||||
|
docker-compose ps
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 部署完成!"
|
||||||
|
echo ""
|
||||||
|
echo "📋 常用命令:"
|
||||||
|
echo " 查看日志: docker-compose logs -f"
|
||||||
|
echo " 停止服务: docker-compose down"
|
||||||
|
echo " 重启服务: docker-compose restart"
|
||||||
|
echo ""
|
||||||
|
echo "🔍 健康检查:"
|
||||||
|
if curl -s http://localhost:3000 > /dev/null 2>&1 || curl -s http://localhost > /dev/null 2>&1; then
|
||||||
|
echo "✅ 服务运行正常"
|
||||||
|
else
|
||||||
|
echo "⚠️ 服务可能还在启动中,请稍等片刻"
|
||||||
|
fi
|
||||||
105
docker-compose.prod.yml
Normal file
105
docker-compose.prod.yml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# 前端应用
|
||||||
|
ai-chat-web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.prod
|
||||||
|
args:
|
||||||
|
- NODE_ENV=production
|
||||||
|
image: ai-chat:latest
|
||||||
|
container_name: ai-chat-web-prod
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- NODE_ENV=production
|
||||||
|
volumes:
|
||||||
|
- ./logs/nginx:/var/log/nginx
|
||||||
|
- ./ssl:/etc/nginx/ssl:ro # SSL证书目录(如果需要HTTPS)
|
||||||
|
networks:
|
||||||
|
- ai-chat-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.ai-chat.rule=Host(`your-domain.com`)"
|
||||||
|
- "traefik.http.services.ai-chat.loadbalancer.server.port=80"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1.0'
|
||||||
|
memory: 512M
|
||||||
|
reservations:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 256M
|
||||||
|
|
||||||
|
# 反向代理(可选,用于HTTPS和负载均衡)
|
||||||
|
nginx-proxy:
|
||||||
|
image: nginx:1.25-alpine
|
||||||
|
container_name: ai-chat-proxy-prod
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./ssl:/etc/nginx/ssl:ro
|
||||||
|
- ./logs/proxy:/var/log/nginx
|
||||||
|
depends_on:
|
||||||
|
- ai-chat-web
|
||||||
|
networks:
|
||||||
|
- ai-chat-network
|
||||||
|
profiles:
|
||||||
|
- https # 使用profile控制HTTPS代理
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "nginx", "-t"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# 监控服务(可选)
|
||||||
|
watchtower:
|
||||||
|
image: containrrr/watchtower
|
||||||
|
container_name: ai-chat-watchtower
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
- WATCHTOWER_CLEANUP=true
|
||||||
|
- WATCHTOWER_POLL_INTERVAL=3600 # 每小时检查一次更新
|
||||||
|
- WATCHTOWER_INCLUDE_STOPPED=true
|
||||||
|
profiles:
|
||||||
|
- monitoring
|
||||||
|
command: ai-chat-web-prod
|
||||||
|
|
||||||
|
# 日志收集(可选)
|
||||||
|
promtail:
|
||||||
|
image: grafana/promtail:latest
|
||||||
|
container_name: ai-chat-promtail
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./logs:/var/log/app:ro
|
||||||
|
- ./promtail-config.yml:/etc/promtail/config.yml:ro
|
||||||
|
networks:
|
||||||
|
- ai-chat-network
|
||||||
|
profiles:
|
||||||
|
- logging
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ai-chat-network:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.20.0.0/16
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nginx-logs:
|
||||||
|
driver: local
|
||||||
|
ssl-certs:
|
||||||
|
driver: local
|
||||||
19
docker-compose.simple.yml
Normal file
19
docker-compose.simple.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
ai-chat:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: ai-chat-app
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
48
docker-compose.yml
Normal file
48
docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: ai-chat-web
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
volumes:
|
||||||
|
# 可选:如果需要持久化nginx日志
|
||||||
|
- ./logs:/var/log/nginx
|
||||||
|
networks:
|
||||||
|
- ai-chat-network
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.ai-chat.rule=Host(`localhost`)"
|
||||||
|
- "traefik.http.services.ai-chat.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
# 可选:如果需要反向代理
|
||||||
|
nginx-proxy:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: ai-chat-proxy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./proxy.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
- ./ssl:/etc/nginx/ssl # SSL证书目录(如果需要HTTPS)
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- ai-chat-network
|
||||||
|
profiles:
|
||||||
|
- proxy # 使用profile控制是否启动代理
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ai-chat-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
logs:
|
||||||
|
driver: local
|
||||||
137
docs/代码质量分析报告.md
Normal file
137
docs/代码质量分析报告.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# 项目代码质量分析报告
|
||||||
|
|
||||||
|
## 📊 项目概况
|
||||||
|
|
||||||
|
**项目名称**: template-10556 (红色文化宣传)
|
||||||
|
**技术栈**: Taro 4.0.8 + React 18.3.1 + TypeScript 5.7.2 + NutUI
|
||||||
|
**分析时间**: 2025-07-11
|
||||||
|
|
||||||
|
## 🎯 分析结果总结
|
||||||
|
|
||||||
|
### ✅ 优点
|
||||||
|
- 使用TypeScript提供类型安全
|
||||||
|
- 采用Taro框架支持多端开发
|
||||||
|
- 组件化开发,结构相对清晰
|
||||||
|
- 有完整的API接口定义
|
||||||
|
- 使用现代化的React Hooks
|
||||||
|
|
||||||
|
### ⚠️ 发现的问题
|
||||||
|
1. **重复代码严重**: 存在多个功能相同的TabBar组件
|
||||||
|
2. **文档管理混乱**: .md文件散落在各个目录
|
||||||
|
3. **API文件冗余**: 大量未使用的API接口文件
|
||||||
|
4. **缺少代码规范**: 没有ESLint配置文件
|
||||||
|
5. **类型定义不完整**: 部分函数使用any类型
|
||||||
|
6. **版本控制问题**: dist目录被提交到版本控制
|
||||||
|
|
||||||
|
## 🧹 已完成的清理工作
|
||||||
|
|
||||||
|
### 1. 清理重复的TabBar组件
|
||||||
|
**删除的文件**:
|
||||||
|
- `src/components/BasicTabBar.tsx` 及其样式文件
|
||||||
|
- `src/components/SimpleTabBar.tsx` 及其样式文件
|
||||||
|
- `src/components/TabBar.tsx` 及其样式文件
|
||||||
|
- `src/components/H5TabBar.tsx` 及其样式文件
|
||||||
|
- `src/components/SimpleH5TabBar.tsx` 及其样式文件
|
||||||
|
- `src/custom-tab-bar/` 整个目录
|
||||||
|
|
||||||
|
**原因**: 项目当前使用系统TabBar (`custom: false`),自定义TabBar组件都是冗余的。
|
||||||
|
|
||||||
|
### 2. 清理文档和测试文件
|
||||||
|
**删除的文件**:
|
||||||
|
- 12个.md文档文件从components目录
|
||||||
|
- 3个测试相关的.md文件从pages/ai目录
|
||||||
|
- `src/components/MarkdownTest.tsx` 测试组件
|
||||||
|
|
||||||
|
**原因**: 这些文档文件应该统一管理,不应该散落在源码目录中。
|
||||||
|
|
||||||
|
### 3. 清理未使用的API文件
|
||||||
|
**删除的API目录** (共32个):
|
||||||
|
- `cmsAdRecord`, `cmsArticleCategory`, `cmsArticleComment` 等
|
||||||
|
- 这些API接口在项目中完全没有被引用
|
||||||
|
|
||||||
|
**保留的API目录**:
|
||||||
|
- `cmsArticle` (15次引用) - 文章相关功能
|
||||||
|
- `cmsNavigation` (11次引用) - 导航菜单功能
|
||||||
|
- `cmsWebsite` (3次引用) - 网站信息功能
|
||||||
|
- `cmsAd` (2次引用) - 广告功能
|
||||||
|
- 其他有实际使用的API
|
||||||
|
|
||||||
|
### 4. 清理无用的工具文件
|
||||||
|
**删除的文件**:
|
||||||
|
- `src/utils/ai-token-example.ts` - 示例文件
|
||||||
|
- `src/utils/test-ai-token.md` - 测试文档
|
||||||
|
|
||||||
|
### 5. 设置代码规范
|
||||||
|
**新增文件**:
|
||||||
|
- `.eslintrc.js` - ESLint配置文件,包含TypeScript和React规则
|
||||||
|
|
||||||
|
### 6. 优化版本控制
|
||||||
|
**更新文件**:
|
||||||
|
- `.gitignore` - 添加了完整的忽略规则,包括dist目录
|
||||||
|
|
||||||
|
### 7. 修复类型问题
|
||||||
|
**修复的文件**:
|
||||||
|
- `src/utils/common.ts` - 添加了完整的TypeScript类型定义
|
||||||
|
|
||||||
|
## 📈 清理效果
|
||||||
|
|
||||||
|
### 文件数量减少
|
||||||
|
- **删除文件总数**: 约60个文件
|
||||||
|
- **删除API目录**: 32个未使用的API目录
|
||||||
|
- **删除文档文件**: 15个散落的.md文件
|
||||||
|
- **删除重复组件**: 10个TabBar相关文件
|
||||||
|
|
||||||
|
### 代码质量提升
|
||||||
|
- ✅ 消除了重复代码
|
||||||
|
- ✅ 统一了代码规范
|
||||||
|
- ✅ 改善了类型安全
|
||||||
|
- ✅ 优化了项目结构
|
||||||
|
- ✅ 减少了维护成本
|
||||||
|
|
||||||
|
## 🔧 建议的后续改进
|
||||||
|
|
||||||
|
### 1. 代码规范化
|
||||||
|
- 运行ESLint检查并修复所有警告
|
||||||
|
- 统一代码格式化规则
|
||||||
|
- 添加Prettier配置
|
||||||
|
|
||||||
|
### 2. 类型安全
|
||||||
|
- 消除所有any类型的使用
|
||||||
|
- 为所有函数添加返回类型
|
||||||
|
- 完善接口定义
|
||||||
|
|
||||||
|
### 3. 测试覆盖
|
||||||
|
- 添加单元测试
|
||||||
|
- 添加组件测试
|
||||||
|
- 设置测试覆盖率目标
|
||||||
|
|
||||||
|
### 4. 性能优化
|
||||||
|
- 分析bundle大小
|
||||||
|
- 实现代码分割
|
||||||
|
- 优化图片资源
|
||||||
|
|
||||||
|
### 5. 文档完善
|
||||||
|
- 创建开发文档
|
||||||
|
- 添加API文档
|
||||||
|
- 完善README
|
||||||
|
|
||||||
|
## 📋 质量评分
|
||||||
|
|
||||||
|
| 项目 | 清理前 | 清理后 | 改善 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| 代码重复度 | 高 | 低 | ⬆️ 显著改善 |
|
||||||
|
| 文件组织 | 混乱 | 清晰 | ⬆️ 显著改善 |
|
||||||
|
| 类型安全 | 中等 | 良好 | ⬆️ 改善 |
|
||||||
|
| 代码规范 | 无 | 有 | ⬆️ 新增 |
|
||||||
|
| 维护性 | 低 | 高 | ⬆️ 显著改善 |
|
||||||
|
|
||||||
|
## 🎉 总结
|
||||||
|
|
||||||
|
通过本次代码质量分析和清理工作,项目的整体质量得到了显著提升:
|
||||||
|
|
||||||
|
1. **减少了约60个冗余文件**,使项目结构更加清晰
|
||||||
|
2. **建立了代码规范体系**,为后续开发提供了标准
|
||||||
|
3. **提高了代码的可维护性**,降低了技术债务
|
||||||
|
4. **优化了版本控制**,避免了不必要的文件提交
|
||||||
|
|
||||||
|
项目现在具备了更好的可维护性和扩展性,为后续的功能开发和团队协作奠定了良好的基础。
|
||||||
84
monitoring.yml
Normal file
84
monitoring.yml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 监控配置文件
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Prometheus监控
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
container_name: ai-chat-prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
|
- '--storage.tsdb.path=/prometheus'
|
||||||
|
- '--web.console.libraries=/etc/prometheus/console_libraries'
|
||||||
|
- '--web.console.templates=/etc/prometheus/consoles'
|
||||||
|
- '--storage.tsdb.retention.time=200h'
|
||||||
|
- '--web.enable-lifecycle'
|
||||||
|
networks:
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
# Grafana仪表板
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
container_name: ai-chat-grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=admin123
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning
|
||||||
|
networks:
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
# Node Exporter (系统监控)
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:latest
|
||||||
|
container_name: ai-chat-node-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9100:9100"
|
||||||
|
volumes:
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /:/rootfs:ro
|
||||||
|
command:
|
||||||
|
- '--path.procfs=/host/proc'
|
||||||
|
- '--path.rootfs=/rootfs'
|
||||||
|
- '--path.sysfs=/host/sys'
|
||||||
|
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||||
|
networks:
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
# cAdvisor (容器监控)
|
||||||
|
cadvisor:
|
||||||
|
image: gcr.io/cadvisor/cadvisor:latest
|
||||||
|
container_name: ai-chat-cadvisor
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- /:/rootfs:ro
|
||||||
|
- /var/run:/var/run:ro
|
||||||
|
- /sys:/sys:ro
|
||||||
|
- /var/lib/docker/:/var/lib/docker:ro
|
||||||
|
- /dev/disk/:/dev/disk:ro
|
||||||
|
privileged: true
|
||||||
|
devices:
|
||||||
|
- /dev/kmsg
|
||||||
|
networks:
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
networks:
|
||||||
|
monitoring:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
84
nginx-proxy.conf
Normal file
84
nginx-proxy.conf
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# HTTPS反向代理配置
|
||||||
|
upstream ai-chat-backend {
|
||||||
|
server ai-chat-web:80;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP重定向到HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com www.your-domain.com;
|
||||||
|
|
||||||
|
# Let's Encrypt验证
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 重定向到HTTPS
|
||||||
|
location / {
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS主服务器
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com www.your-domain.com;
|
||||||
|
|
||||||
|
# SSL配置
|
||||||
|
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||||
|
|
||||||
|
# SSL安全配置
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# HSTS
|
||||||
|
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;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
access_log /var/log/nginx/access.log combined;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
# 代理配置
|
||||||
|
location / {
|
||||||
|
proxy_pass http://ai-chat-backend;
|
||||||
|
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;
|
||||||
|
|
||||||
|
# WebSocket支持
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# 超时设置
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
|
||||||
|
# 缓冲设置
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 4k;
|
||||||
|
proxy_buffers 8 4k;
|
||||||
|
|
||||||
|
# 连接复用
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
proxy_pass http://ai-chat-backend/health;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
nginx.conf
Normal file
57
nginx.conf
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# 设置根目录
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
# 启用gzip压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/json
|
||||||
|
application/javascript
|
||||||
|
application/xml+rss
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# 静态资源缓存设置
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTML文件不缓存
|
||||||
|
location ~* \.html$ {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA路由支持 - 所有路由都返回index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 安全头设置
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# 错误页面
|
||||||
|
error_page 404 /index.html;
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
nginx.prod.conf
Normal file
129
nginx.prod.conf
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# 生产环境 Nginx 配置
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# 安全配置
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
# 设置根目录
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
access_log /var/log/nginx/access.log combined;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
# 启用gzip压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/json
|
||||||
|
application/javascript
|
||||||
|
application/xml+rss
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml
|
||||||
|
application/x-font-ttf
|
||||||
|
application/vnd.ms-fontobject
|
||||||
|
font/opentype;
|
||||||
|
|
||||||
|
# Brotli压缩(如果支持)
|
||||||
|
# brotli on;
|
||||||
|
# brotli_comp_level 6;
|
||||||
|
# brotli_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# 静态资源缓存设置
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
access_log off;
|
||||||
|
|
||||||
|
# 预压缩文件支持
|
||||||
|
location ~* \.(js|css)$ {
|
||||||
|
gzip_static on;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTML文件不缓存
|
||||||
|
location ~* \.html$ {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API代理(如果需要)
|
||||||
|
location /api/ {
|
||||||
|
# 替换为您的后端API地址
|
||||||
|
proxy_pass http://backend-api:8080/;
|
||||||
|
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;
|
||||||
|
|
||||||
|
# WebSocket支持
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# 超时设置
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
|
||||||
|
# 缓冲设置
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 4k;
|
||||||
|
proxy_buffers 8 4k;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA路由支持 - 所有路由都返回index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
|
# 安全头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss: https:;" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 健康检查端点
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 禁止访问敏感文件
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.(env|log|config)$ {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 限制请求大小
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
# 错误页面
|
||||||
|
error_page 404 /index.html;
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
proxy.conf
Normal file
51
proxy.conf
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 反向代理配置(可选)
|
||||||
|
upstream ai-chat-backend {
|
||||||
|
server web:80;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# 重定向到HTTPS(如果需要)
|
||||||
|
# return 301 https://$server_name$request_uri;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://ai-chat-backend;
|
||||||
|
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;
|
||||||
|
|
||||||
|
# WebSocket支持
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# 超时设置
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS配置(如果需要)
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
# server_name localhost;
|
||||||
|
#
|
||||||
|
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||||
|
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||||
|
#
|
||||||
|
# location / {
|
||||||
|
# proxy_pass http://ai-chat-backend;
|
||||||
|
# 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_http_version 1.1;
|
||||||
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
|
# proxy_set_header Connection "upgrade";
|
||||||
|
# }
|
||||||
|
# }
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import request from '@/utils/request';
|
import request from '@/utils/request';
|
||||||
import type { ApiResult } from '@/api/index';
|
import type {ApiResult} from '@/api/index';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI聊天消息接口
|
* AI聊天消息接口
|
||||||
@@ -39,9 +39,9 @@ export async function sendAiMessage(data: AiChatMessage) {
|
|||||||
/**
|
/**
|
||||||
* 停止AI聊天
|
* 停止AI聊天
|
||||||
*/
|
*/
|
||||||
export async function stopAiMessage(data: { taskId: string; type?: string }) {
|
export async function stopAiMessage(data: { taskId: string; authCode?: string; user?: string; type?: string }) {
|
||||||
const res = await request.post<ApiResult<string>>(
|
const res = await request.post<ApiResult<string>>(
|
||||||
'/chat/messageStop',
|
'https://ai-console.gxshucheng.com/ai-console-api/stop/v1',
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function uploadOssByPath(filePath: string) {
|
|||||||
let stsExpired = Taro.getStorageSync('stsExpiredAt');
|
let stsExpired = Taro.getStorageSync('stsExpiredAt');
|
||||||
if (!sts || (stsExpired && dayjs().isBefore(dayjs(stsExpired)))) {
|
if (!sts || (stsExpired && dayjs().isBefore(dayjs(stsExpired)))) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const {data: {data: {credentials}}} = await request.get(`https://server.gxwebsoft.com/api/oss/getSTSToken`)
|
const {data: {data: {credentials}}} = await request.get(`https://server.websoft.top/api/oss/getSTSToken`)
|
||||||
Taro.setStorageSync('sts', credentials)
|
Taro.setStorageSync('sts', credentials)
|
||||||
Taro.setStorageSync('stsExpiredAt', credentials.expiration)
|
Taro.setStorageSync('stsExpiredAt', credentials.expiration)
|
||||||
sts = credentials
|
sts = credentials
|
||||||
@@ -66,7 +66,7 @@ export async function uploadFile() {
|
|||||||
const tempFilePath = res.tempFilePaths[0];
|
const tempFilePath = res.tempFilePaths[0];
|
||||||
// 上传图片到OSS
|
// 上传图片到OSS
|
||||||
Taro.uploadFile({
|
Taro.uploadFile({
|
||||||
url: 'https://server.gxwebsoft.com/api/oss/upload',
|
url: 'https://server.websoft.top/api/oss/upload',
|
||||||
filePath: tempFilePath,
|
filePath: tempFilePath,
|
||||||
name: 'file',
|
name: 'file',
|
||||||
header: {
|
header: {
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ export default defineAppConfig({
|
|||||||
"detail"
|
"detail"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"root": "yxtwjr",
|
||||||
|
"pages": [
|
||||||
|
"index",
|
||||||
|
"detail"
|
||||||
|
]
|
||||||
|
},
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
backgroundTextStyle: 'dark',
|
backgroundTextStyle: 'dark',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
// import {Tag} from '@nutui/nutui-react-taro'
|
import Taro from '@tarojs/taro'
|
||||||
import {useRouter} from '@tarojs/taro'
|
import {useRouter} from '@tarojs/taro'
|
||||||
import {Divider} from '@nutui/nutui-react-taro'
|
import {Divider} from '@nutui/nutui-react-taro'
|
||||||
import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
||||||
@@ -20,6 +20,7 @@ function Detail() {
|
|||||||
getCmsArticle(Number(params.id)).then(data => {
|
getCmsArticle(Number(params.id)).then(data => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setItem(data)
|
setItem(data)
|
||||||
|
Taro.setNavigationBarTitle({title: `${data.categoryName} - 文章详情`})
|
||||||
// setViews(data.actualViews)
|
// setViews(data.actualViews)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const Index = () => {
|
|||||||
// 当前栏目信息
|
// 当前栏目信息
|
||||||
if (navs) {
|
if (navs) {
|
||||||
setNavigation(navs);
|
setNavigation(navs);
|
||||||
|
Taro.setNavigationBarTitle({title: `${navs.title}`})
|
||||||
}
|
}
|
||||||
// 获取子级栏目
|
// 获取子级栏目
|
||||||
if (childCateogry) {
|
if (childCateogry) {
|
||||||
|
|||||||
204
src/components/SimpleH5TabBar.scss
Normal file
204
src/components/SimpleH5TabBar.scss
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/* H5专用TabBar样式 */
|
||||||
|
.simple-h5-tabbar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-top: 1px solid #e5e5e5;
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.h5-tabbar-container {
|
||||||
|
display: flex;
|
||||||
|
height: 88px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 4px 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.h5-tabbar-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
// 普通图标样式
|
||||||
|
.h5-normal-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
|
.h5-icon-emoji {
|
||||||
|
font-size: 20px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保图片图标保持正方形
|
||||||
|
image {
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 特殊AI按钮样式
|
||||||
|
.h5-special-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: -10px;
|
||||||
|
|
||||||
|
.h5-ai-circle {
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
top: -8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
.h5-ai-text {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保AI图标保持正方形并居中
|
||||||
|
image {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 光晕效果
|
||||||
|
&::before {
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.8);
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: -7px;
|
||||||
|
right: -7px;
|
||||||
|
bottom: -7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字样式
|
||||||
|
.h5-tabbar-text {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #8a8a8a;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.special-text {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: -12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中状态
|
||||||
|
&.selected {
|
||||||
|
.h5-normal-icon {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
|
||||||
|
.h5-icon-emoji {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 特殊项目样式
|
||||||
|
&.special {
|
||||||
|
.h5-special-icon .h5-ai-circle {
|
||||||
|
&::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击效果
|
||||||
|
&:active {
|
||||||
|
.h5-tabbar-text {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// H5全屏适配 - 移除PC端限制,因为项目只用于H5端
|
||||||
|
|
||||||
|
// 暗色主题支持
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.simple-h5-tabbar {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border-top-color: #333;
|
||||||
|
|
||||||
|
.h5-tabbar-container .h5-tabbar-item {
|
||||||
|
.h5-tabbar-text {
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
color: #d81e06;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.special-text {
|
||||||
|
color: #ff6b35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画效果
|
||||||
|
@keyframes h5-bounce {
|
||||||
|
0%, 20%, 50%, 80%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中时的弹跳效果
|
||||||
|
.simple-h5-tabbar .h5-tabbar-container .h5-tabbar-item.selected .h5-normal-icon {
|
||||||
|
animation: h5-bounce 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI按钮的脉冲效果
|
||||||
|
@keyframes h5-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 4px 20px rgba(255, 107, 53, 0.6);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-h5-tabbar .h5-tabbar-container .h5-tabbar-item.special.selected .h5-special-icon .h5-ai-circle {
|
||||||
|
animation: h5-pulse 2s infinite;
|
||||||
|
}
|
||||||
110
src/components/SimpleH5TabBar.tsx
Normal file
110
src/components/SimpleH5TabBar.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import {useState, useEffect} from 'react';
|
||||||
|
import {View, Text, Image} from '@tarojs/components';
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import './SimpleH5TabBar.scss';
|
||||||
|
|
||||||
|
interface TabBarProps {
|
||||||
|
current?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabBarItem {
|
||||||
|
pagePath: string;
|
||||||
|
text: string;
|
||||||
|
icon: string;
|
||||||
|
isSpecial?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabBarData: TabBarItem[] = [
|
||||||
|
{
|
||||||
|
pagePath: 'pages/index/index',
|
||||||
|
text: '首页',
|
||||||
|
icon: 'https://oss.wsdns.cn/20250711/3f000045aac44a0480b4be0a2c225fe1.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/ai/index',
|
||||||
|
text: 'AI问答',
|
||||||
|
icon: 'https://oss.wsdns.cn/20250711/86fdc1ad45a041d797b582ba42c34698.png',
|
||||||
|
isSpecial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/user/user',
|
||||||
|
text: '我的',
|
||||||
|
icon: 'https://oss.wsdns.cn/20250711/2baaec717bca49e7b58f36a3bd75fc14.png',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function SimpleH5TabBar({current}: TabBarProps) {
|
||||||
|
const [selected, setSelected] = useState(current || 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('SimpleH5TabBar 组件已挂载, current:', current);
|
||||||
|
if (typeof current === 'number') {
|
||||||
|
setSelected(current);
|
||||||
|
}
|
||||||
|
}, [current]);
|
||||||
|
|
||||||
|
const switchTab = (index: number) => {
|
||||||
|
console.log('SimpleH5TabBar 点击项目:', index);
|
||||||
|
setSelected(index);
|
||||||
|
|
||||||
|
const urls = [
|
||||||
|
'/pages/index/index',
|
||||||
|
'/pages/ai/index',
|
||||||
|
'/pages/user/user'
|
||||||
|
];
|
||||||
|
|
||||||
|
Taro.switchTab({
|
||||||
|
url: urls[index],
|
||||||
|
success: () => console.log('跳转成功:', urls[index]),
|
||||||
|
fail: (error) => console.error('跳转失败:', error)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="simple-h5-tabbar">
|
||||||
|
<View className="h5-tabbar-container">
|
||||||
|
{tabBarData.map((item, index) => (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
className={`h5-tabbar-item ${selected === index ? 'selected' : ''} ${item.isSpecial ? 'special' : ''}`}
|
||||||
|
onClick={() => switchTab(index)}
|
||||||
|
>
|
||||||
|
{item.isSpecial ? (
|
||||||
|
<View className="h5-special-icon">
|
||||||
|
<View className="h5-ai-circle">
|
||||||
|
<Image
|
||||||
|
src={item.icon}
|
||||||
|
style={{
|
||||||
|
width: '68px',
|
||||||
|
height: '68px',
|
||||||
|
objectFit: 'contain'
|
||||||
|
}}
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="h5-normal-icon">
|
||||||
|
<Image
|
||||||
|
src={item.icon}
|
||||||
|
style={{
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
objectFit: 'contain'
|
||||||
|
}}
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
className={`h5-tabbar-text ${selected === index ? 'selected' : ''} ${item.isSpecial ? 'special-text' : ''}`}>
|
||||||
|
{item.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SimpleH5TabBar;
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import SimpleTabBar from '../components/SimpleTabBar';
|
|
||||||
|
|
||||||
export default function CustomTabBarWrapper() {
|
|
||||||
return <SimpleTabBar />;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default definePageConfig({
|
|
||||||
navigationBarTitleText: '红色资源'
|
|
||||||
})
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import {useEffect, useState} from "react";
|
|
||||||
import {pageCmsArticle} from "@/api/cms/cmsArticle";
|
|
||||||
import {CmsArticle} from "@/api/cms/cmsArticle/model";
|
|
||||||
import Taro from '@tarojs/taro'
|
|
||||||
import {useRouter} from '@tarojs/taro'
|
|
||||||
import {Image} from '@nutui/nutui-react-taro'
|
|
||||||
import {getCmsNavigation} from "@/api/cms/cmsNavigation";
|
|
||||||
import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文章终极列表
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
const Article = () => {
|
|
||||||
const {params} = useRouter();
|
|
||||||
const [navigation, setNavigation] = useState<CmsNavigation>()
|
|
||||||
const [list, setList] = useState<CmsArticle[]>([])
|
|
||||||
|
|
||||||
const reload = async () => {
|
|
||||||
const categoryId = Number(params.id);
|
|
||||||
const nav = await getCmsNavigation(categoryId);
|
|
||||||
const articles = await pageCmsArticle({categoryId});
|
|
||||||
|
|
||||||
if(nav){
|
|
||||||
setNavigation(nav);
|
|
||||||
}
|
|
||||||
if(articles){
|
|
||||||
setList(articles?.list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reload()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-container">
|
|
||||||
<Image
|
|
||||||
src={navigation?.style}
|
|
||||||
style={{width: '100%', height: 'auto'}}
|
|
||||||
mode="widthFix"
|
|
||||||
/>
|
|
||||||
<div className={'bg-white rounded-lg py-3 px-2'}>
|
|
||||||
{/* 宫格布局容器 */}
|
|
||||||
<div className={'grid grid-cols-3'}>
|
|
||||||
{
|
|
||||||
list.map((item, index) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={'flex flex-col items-center cursor-pointer my-1'}
|
|
||||||
onClick={() => Taro.navigateTo({url: `/pages/article/detail?id=${item.articleId}`})}
|
|
||||||
>
|
|
||||||
{/* 图片容器 */}
|
|
||||||
<div className={'w-full mb-2 flex justify-center'}>
|
|
||||||
<img
|
|
||||||
className={'object-cover rounded-lg'}
|
|
||||||
src={item.image}
|
|
||||||
alt={item.title || ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* 标题 */}
|
|
||||||
<div className={'text-xs text-center text-gray-800 leading-tight px-1'}>
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
export default Article
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '详情',
|
navigationBarTitleText: '老兵风采 - 文章详情',
|
||||||
navigationBarBackgroundColor: '#ffe0e0'
|
navigationBarBackgroundColor: '#ffe0e0'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.content{
|
.content{
|
||||||
img{
|
img{
|
||||||
margin: 12px;
|
margin: 12px auto;
|
||||||
|
max-width: 80%;
|
||||||
background-color: #F2FE03;
|
background-color: #F2FE03;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -6,14 +6,14 @@ import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
|||||||
import {View, RichText} from '@tarojs/components'
|
import {View, RichText} from '@tarojs/components'
|
||||||
// import Line from "@/components/Gap";
|
// import Line from "@/components/Gap";
|
||||||
import {getCmsArticle} from "@/api/cms/cmsArticle";
|
import {getCmsArticle} from "@/api/cms/cmsArticle";
|
||||||
import './detail.css';
|
import './detail.scss';
|
||||||
|
|
||||||
function Detail() {
|
function Detail() {
|
||||||
const {params} = useRouter();
|
const {params} = useRouter();
|
||||||
// 文章详情
|
// 文章详情
|
||||||
const [item, setItem] = useState<CmsArticle>()
|
const [item, setItem] = useState<CmsArticle>()
|
||||||
|
|
||||||
const reload = () => {
|
const reload = async () => {
|
||||||
getCmsArticle(Number(params.id)).then(data => {
|
getCmsArticle(Number(params.id)).then(data => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setItem(data)
|
setItem(data)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Taro from '@tarojs/taro'
|
|||||||
import {useRouter} from '@tarojs/taro'
|
import {useRouter} from '@tarojs/taro'
|
||||||
import {Image} from '@nutui/nutui-react-taro'
|
import {Image} from '@nutui/nutui-react-taro'
|
||||||
import {InfiniteLoading} from '@nutui/nutui-react-taro'
|
import {InfiniteLoading} from '@nutui/nutui-react-taro'
|
||||||
import {getCmsNavigation} from "@/api/cms/cmsNavigation";
|
import {getCmsNavigation, pageCmsNavigation} from "@/api/cms/cmsNavigation";
|
||||||
import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
|
import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +15,7 @@ import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
|
|||||||
const Index = () => {
|
const Index = () => {
|
||||||
const {params} = useRouter();
|
const {params} = useRouter();
|
||||||
const [navigation, setNavigation] = useState<CmsNavigation>()
|
const [navigation, setNavigation] = useState<CmsNavigation>()
|
||||||
|
const [childCategory, setChildCategory] = useState<CmsNavigation[]>([])
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [list, setList] = useState<CmsArticle[]>([])
|
const [list, setList] = useState<CmsArticle[]>([])
|
||||||
@@ -22,32 +23,67 @@ const Index = () => {
|
|||||||
const categoryId = Number(params.id);
|
const categoryId = Number(params.id);
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = async () => {
|
||||||
|
console.log('初始化加载 - 分类ID:', categoryId);
|
||||||
|
// 重置状态
|
||||||
|
setPage(1);
|
||||||
|
setList([]);
|
||||||
|
setHasMore(true);
|
||||||
|
|
||||||
// 当前栏目信息
|
// 当前栏目信息
|
||||||
const navs = await getCmsNavigation(categoryId);
|
const navs = await getCmsNavigation(categoryId);
|
||||||
// 当前栏目信息
|
// 二级栏目
|
||||||
|
const childCateogry = await pageCmsNavigation({parentId: categoryId});
|
||||||
|
// 获取子级栏目
|
||||||
|
if (childCateogry) {
|
||||||
|
setChildCategory(childCateogry.list)
|
||||||
|
}
|
||||||
if (navs) {
|
if (navs) {
|
||||||
setNavigation(navs);
|
setNavigation(navs);
|
||||||
|
Taro.setNavigationBarTitle({title: `${navs.title}`})
|
||||||
}
|
}
|
||||||
// 终极新闻列表
|
|
||||||
getList()
|
// 加载第一页数据
|
||||||
|
getList(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 终极新闻列表
|
// 终极新闻列表
|
||||||
const getList = () => {
|
const getList = (currentPage = page) => {
|
||||||
pageCmsArticle({categoryId, page}).then(res => {
|
console.log('请求数据 - 页码:', currentPage, '分类ID:', categoryId);
|
||||||
|
pageCmsArticle({categoryId, page: currentPage}).then(res => {
|
||||||
|
console.log('获取到数据:', res);
|
||||||
if (res?.list && res?.list.length > 0) {
|
if (res?.list && res?.list.length > 0) {
|
||||||
const newList = list?.concat(res.list)
|
if (currentPage === 1) {
|
||||||
setList(newList);
|
// 第一页,直接设置
|
||||||
setHasMore(true)
|
setList(res.list);
|
||||||
} else {
|
} else {
|
||||||
setHasMore(false)
|
// 后续页面,追加到现有列表
|
||||||
|
setList(prevList => {
|
||||||
|
const newList = [...prevList, ...res.list];
|
||||||
|
console.log('合并后的列表长度:', newList.length);
|
||||||
|
return newList;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
setHasMore(true);
|
||||||
|
} else {
|
||||||
|
console.log('没有更多数据了');
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('获取数据失败:', error);
|
||||||
|
setHasMore(false);
|
||||||
|
}).finally(() => {
|
||||||
|
Taro.hideLoading()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const reloadMore = async () => {
|
const reloadMore = async () => {
|
||||||
setPage(page + 1)
|
const nextPage = page + 1;
|
||||||
getList();
|
console.log('加载更多 - 下一页:', nextPage);
|
||||||
|
Taro.showLoading({
|
||||||
|
title: '加载中...',
|
||||||
|
})
|
||||||
|
setPage(nextPage);
|
||||||
|
getList(nextPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,12 +111,35 @@ const Index = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className={'rounded-lg py-3 px-2'}>
|
<div className={'rounded-lg py-3 px-2'}>
|
||||||
<div className={'grid grid-cols-1 gap-3'}>
|
<div className={'grid grid-cols-1 gap-3'}>
|
||||||
|
<div className={'grid grid-cols-2 gap-3'}>
|
||||||
|
{
|
||||||
|
// 子级栏目
|
||||||
|
childCategory.map((item, index) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={'flex flex-col justify-center items-center cursor-pointer'}
|
||||||
|
onClick={() => Taro.navigateTo({url: `/${item.model}/index?id=${item.navigationId}`})}
|
||||||
|
>
|
||||||
|
{/* 图片容器 */}
|
||||||
|
<div className={'w-full mb-2 flex justify-center'}>
|
||||||
|
<img
|
||||||
|
className={'object-cover rounded-lg'}
|
||||||
|
src={item.icon}
|
||||||
|
alt={item.title || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
{
|
{
|
||||||
// 终极文章列表
|
// 终极文章列表
|
||||||
list.map((item, index) => {
|
list.map((item, index) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={item.articleId || `article-${index}`}
|
||||||
className={'flex items-center cursor-pointer'}
|
className={'flex items-center cursor-pointer'}
|
||||||
style={{
|
style={{
|
||||||
border: '3px solid #F2FE03',
|
border: '3px solid #F2FE03',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '详情',
|
navigationBarTitleText: '立功受奖光荣榜 - 详情',
|
||||||
navigationBarBackgroundColor: '#ffe0e0'
|
navigationBarBackgroundColor: '#ffe0e0'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.content{
|
.content{
|
||||||
img{
|
img{
|
||||||
margin: 12px;
|
margin: 12px auto;
|
||||||
|
max-width: 80%;
|
||||||
background-color: #F2FE03;
|
background-color: #F2FE03;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function Detail() {
|
|||||||
getCmsArticle(Number(params.id)).then(data => {
|
getCmsArticle(Number(params.id)).then(data => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setItem(data)
|
setItem(data)
|
||||||
|
// Taro.setNavigationBarTitle({title: `${data.categoryName} - 文章详情`})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const Index = () => {
|
|||||||
// 当前栏目信息
|
// 当前栏目信息
|
||||||
if (navs) {
|
if (navs) {
|
||||||
setNavigation(navs);
|
setNavigation(navs);
|
||||||
|
Taro.setNavigationBarTitle({title: `${navs.title}`})
|
||||||
}
|
}
|
||||||
// 获取子级栏目
|
// 获取子级栏目
|
||||||
if (childCateogry) {
|
if (childCateogry) {
|
||||||
|
|||||||
@@ -19,37 +19,72 @@ const List = () => {
|
|||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [list, setList] = useState<CmsArticle[]>([])
|
const [list, setList] = useState<CmsArticle[]>([])
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = async (currentPage = page) => {
|
||||||
// 获取栏目ID
|
// 获取栏目ID
|
||||||
const categoryId = Number(params.id);
|
const categoryId = Number(params.id);
|
||||||
// 当前栏目信息
|
console.log('honor/list 请求数据 - 页码:', currentPage, '分类ID:', categoryId);
|
||||||
const navs = await getCmsNavigation(categoryId);
|
|
||||||
// 终极新闻列表
|
|
||||||
const articles = await pageCmsArticle({categoryId, page});
|
|
||||||
|
|
||||||
// 当前栏目信息
|
// 当前栏目信息(只在第一页时获取)
|
||||||
|
if (currentPage === 1) {
|
||||||
|
const navs = await getCmsNavigation(categoryId);
|
||||||
if (navs) {
|
if (navs) {
|
||||||
setNavigation(navs);
|
setNavigation(navs);
|
||||||
|
Taro.setNavigationBarTitle({title: `${navs.title}`})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 终极新闻列表
|
||||||
|
const articles = await pageCmsArticle({categoryId, page: currentPage});
|
||||||
|
console.log('honor/list 获取到数据:', articles);
|
||||||
|
|
||||||
// 新闻列表
|
// 新闻列表
|
||||||
if (articles) {
|
if (articles) {
|
||||||
if (articles?.list && articles?.list.length > 0) {
|
if (articles?.list && articles?.list.length > 0) {
|
||||||
const newList = list?.concat(articles.list)
|
if (currentPage === 1) {
|
||||||
setList(newList);
|
// 第一页,直接设置(按 articleId 去重)
|
||||||
setHasMore(true)
|
const unique = articles.list.filter(
|
||||||
|
(item, idx, arr) => arr.findIndex(t => t.articleId === item.articleId) === idx
|
||||||
|
);
|
||||||
|
setList(unique);
|
||||||
} else {
|
} else {
|
||||||
setHasMore(false)
|
// 后续页面,追加到现有列表(合并后去重)
|
||||||
|
setList(prevList => {
|
||||||
|
const merged = [...prevList, ...articles.list];
|
||||||
|
const newList = merged.filter(
|
||||||
|
(item, idx, arr) => arr.findIndex(t => t.articleId === item.articleId) === idx
|
||||||
|
);
|
||||||
|
console.log('honor/list 合并后的列表长度:', newList.length);
|
||||||
|
return newList;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setHasMore(true);
|
||||||
|
} else {
|
||||||
|
console.log('honor/list 没有更多数据了');
|
||||||
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reloadMore = async () => {
|
const reloadMore = async () => {
|
||||||
setPage(page + 1)
|
const nextPage = page + 1;
|
||||||
reload().then();
|
console.log('honor/list 加载更多 - 下一页:', nextPage);
|
||||||
|
Taro.showLoading({
|
||||||
|
title: '加载中...',
|
||||||
|
})
|
||||||
|
setPage(nextPage);
|
||||||
|
reload(nextPage).then().finally(() => {
|
||||||
|
Taro.hideLoading();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload().then()
|
console.log('honor/list 初始化加载');
|
||||||
|
// 重置状态
|
||||||
|
setPage(1);
|
||||||
|
setList([]);
|
||||||
|
setHasMore(true);
|
||||||
|
// 加载第一页数据
|
||||||
|
reload(1).then();
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<meta name="format-detection" content="telephone=no,address=no">
|
<meta name="format-detection" content="telephone=no,address=no">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="white">
|
<meta name="apple-mobile-web-app-status-bar-style" content="white">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
|
||||||
<title>bszx-react</title>
|
<title></title>
|
||||||
<script><%= htmlWebpackPlugin.options.script %></script>
|
<script><%= htmlWebpackPlugin.options.script %></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {Textarea} from '@tarojs/components';
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import {Button} from '@nutui/nutui-react-taro';
|
import {Button} from '@nutui/nutui-react-taro';
|
||||||
import {User, Home} from '@nutui/icons-react-taro';
|
import {User, Home} from '@nutui/icons-react-taro';
|
||||||
import {sendAiMessage} from '@/api/ai';
|
import {sendAiMessage, stopAiMessage} from '@/api/ai';
|
||||||
import {createWebSocket} from '@/utils/websocket';
|
import {createWebSocket} from '@/utils/websocket';
|
||||||
import {getAiToken} from '@/utils/aiToken';
|
import {getAiToken} from '@/utils/aiToken';
|
||||||
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
||||||
@@ -11,7 +11,7 @@ import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|||||||
import {View, RichText} from '@tarojs/components'
|
import {View, RichText} from '@tarojs/components'
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
import {WSS_API_URL} from "@/utils/server";
|
import {WSS_API_URL} from "@/utils/server";
|
||||||
import SimpleH5TabBar from "@/components/SimpleH5TabBar";
|
import {Image} from '@nutui/nutui-react-taro';
|
||||||
|
|
||||||
// 消息类型
|
// 消息类型
|
||||||
interface Message {
|
interface Message {
|
||||||
@@ -31,9 +31,10 @@ const AiChat = () => {
|
|||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
// const [currentTaskId, setCurrentTaskId] = useState<string>('');
|
const [currentTaskId, setCurrentTaskId] = useState<string>('');
|
||||||
const [wsConnected, setWsConnected] = useState(false);
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const [isStopped, setIsStopped] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const wsRef = useRef<any>(null);
|
const wsRef = useRef<any>(null);
|
||||||
|
|
||||||
@@ -79,8 +80,15 @@ const AiChat = () => {
|
|||||||
wsRef.current = createWebSocket(WSS_API_URL + "/chat/" + userToken);
|
wsRef.current = createWebSocket(WSS_API_URL + "/chat/" + userToken);
|
||||||
|
|
||||||
wsRef.current.onMessage((data: any) => {
|
wsRef.current.onMessage((data: any) => {
|
||||||
console.log('收到WebSocket消息:', data);
|
console.log('收到WebSocket消息:', data.taskId, '停止状态:', isStopped);
|
||||||
|
|
||||||
|
// 如果已经停止,忽略所有消息
|
||||||
|
if (isStopped) {
|
||||||
|
console.log('已停止,忽略WebSocket消息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentTaskId(data.taskId);
|
||||||
if (data.answer) {
|
if (data.answer) {
|
||||||
if (data.answer === '__END__') {
|
if (data.answer === '__END__') {
|
||||||
// 消息结束,移除typing状态
|
// 消息结束,移除typing状态
|
||||||
@@ -199,6 +207,9 @@ const AiChat = () => {
|
|||||||
const handleSendMessage = async (content: string) => {
|
const handleSendMessage = async (content: string) => {
|
||||||
if (!content.trim() || isLoading) return;
|
if (!content.trim() || isLoading) return;
|
||||||
|
|
||||||
|
// 重置停止状态,允许接收新的WebSocket消息
|
||||||
|
setIsStopped(false);
|
||||||
|
|
||||||
// 检查并确保AI Token存在
|
// 检查并确保AI Token存在
|
||||||
const aiToken = checkAiToken();
|
const aiToken = checkAiToken();
|
||||||
if (!aiToken) {
|
if (!aiToken) {
|
||||||
@@ -275,20 +286,72 @@ const AiChat = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 停止AI回复
|
// 停止AI回复
|
||||||
// const handleStopMessage = async () => {
|
// @ts-ignore
|
||||||
// if (currentTaskId) {
|
const handleStop = async () => {
|
||||||
// try {
|
console.log('点击停止按钮,当前taskId:', currentTaskId);
|
||||||
// await stopAiMessage({taskId: currentTaskId});
|
|
||||||
// setIsLoading(false);
|
// 立即设置停止状态,阻止后续WebSocket消息
|
||||||
// setCurrentTaskId('');
|
setIsStopped(true);
|
||||||
// setMessages(prev => prev.map(msg =>
|
|
||||||
// msg.isTyping ? {...msg, isTyping: false} : msg
|
if (!currentTaskId) {
|
||||||
// ));
|
console.log('没有正在进行的任务,直接停止加载状态');
|
||||||
// } catch (error) {
|
setIsLoading(false);
|
||||||
// console.error('停止消息失败:', error);
|
setMessages(prev => prev.map(msg =>
|
||||||
// }
|
msg.isTyping ? {...msg, isTyping: false} : msg
|
||||||
// }
|
));
|
||||||
// };
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('发送停止请求...');
|
||||||
|
await stopAiMessage({
|
||||||
|
taskId: currentTaskId,
|
||||||
|
authCode: '1fbfa21a-a3df-445e-9ca5-2c1a9eead7f4',
|
||||||
|
user: `${Taro.getStorageSync('AI_TOKEN') || 'anonymous'}`
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('停止请求成功');
|
||||||
|
|
||||||
|
// 立即停止加载状态
|
||||||
|
setIsLoading(false);
|
||||||
|
setCurrentTaskId('');
|
||||||
|
|
||||||
|
// 停止所有正在输入的消息
|
||||||
|
setMessages(prev => prev.map(msg => {
|
||||||
|
if (msg.isTyping && msg.type === 'ai') {
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
isTyping: false,
|
||||||
|
query: (msg.query || '') + '\n\n[已停止回复]'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 显示停止成功提示
|
||||||
|
Taro.showToast({
|
||||||
|
title: '已停止回复',
|
||||||
|
icon: 'success',
|
||||||
|
duration: 1500
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('停止消息失败:', error);
|
||||||
|
|
||||||
|
// 即使API调用失败,也要停止本地状态
|
||||||
|
setIsLoading(false);
|
||||||
|
setCurrentTaskId('');
|
||||||
|
setMessages(prev => prev.map(msg =>
|
||||||
|
msg.isTyping ? {...msg, isTyping: false} : msg
|
||||||
|
));
|
||||||
|
|
||||||
|
Taro.showToast({
|
||||||
|
title: '停止失败,但已终止本地回复',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 处理快捷问题点击
|
// 处理快捷问题点击
|
||||||
const handleQuickQuestion = (question: string) => {
|
const handleQuickQuestion = (question: string) => {
|
||||||
@@ -385,7 +448,8 @@ const AiChat = () => {
|
|||||||
|
|
||||||
<View className="input-container">
|
<View className="input-container">
|
||||||
<div className="input-wrapper flex justify-between items-center">
|
<div className="input-wrapper flex justify-between items-center">
|
||||||
<Home size={26} className={'bg-white'} onClick={() => Taro.reLaunch({url: '/pages/index/index'})}/>
|
<Home size={26} className={'bg-white'}
|
||||||
|
onClick={() => Taro.reLaunch({url: '/pages/index/index'})}/>
|
||||||
<div className={'w-full mx-2'}>
|
<div className={'w-full mx-2'}>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="message-input"
|
className="message-input"
|
||||||
@@ -405,10 +469,32 @@ const AiChat = () => {
|
|||||||
className={'flex justify-center items-center pr-1'}
|
className={'flex justify-center items-center pr-1'}
|
||||||
onClick={() => handleSendMessage(inputValue)}
|
onClick={() => handleSendMessage(inputValue)}
|
||||||
>
|
>
|
||||||
<img alt={'发送'} src={'https://oss.wsdns.cn/20250709/13424d78bb004352864051d61afe9f0e.png'} width={'30px'} />
|
<Image
|
||||||
|
src={'https://oss.wsdns.cn/20250709/13424d78bb004352864051d61afe9f0e.png'}
|
||||||
|
width={'30px'}
|
||||||
|
/>
|
||||||
|
{/*{isLoading ? (*/}
|
||||||
|
{/* <Button*/}
|
||||||
|
{/* onClick={handleStop}*/}
|
||||||
|
{/* type="primary"*/}
|
||||||
|
{/* size="small"*/}
|
||||||
|
{/* style={{*/}
|
||||||
|
{/* backgroundColor: '#ff4757',*/}
|
||||||
|
{/* borderColor: '#ff4757',*/}
|
||||||
|
{/* marginTop: '8px'*/}
|
||||||
|
{/* }}*/}
|
||||||
|
{/* >*/}
|
||||||
|
{/* 停止*/}
|
||||||
|
{/* </Button>*/}
|
||||||
|
{/*) : (*/}
|
||||||
|
{/* <img*/}
|
||||||
|
{/* alt={'发送'}*/}
|
||||||
|
{/* src={'https://oss.wsdns.cn/20250709/13424d78bb004352864051d61afe9f0e.png'}*/}
|
||||||
|
{/* width={'30px'}*/}
|
||||||
|
{/* />*/}
|
||||||
|
{/*)}*/}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
import {Tag} from '@nutui/nutui-react-taro'
|
import {Tag} from '@nutui/nutui-react-taro'
|
||||||
import {useRouter} from '@tarojs/taro'
|
import {useRouter} from '@tarojs/taro'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
import {Divider} from '@nutui/nutui-react-taro'
|
import {Divider} from '@nutui/nutui-react-taro'
|
||||||
import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
||||||
import {Eye} from '@nutui/icons-react-taro'
|
import {Eye} from '@nutui/icons-react-taro'
|
||||||
@@ -22,6 +23,7 @@ function Detail() {
|
|||||||
if (data) {
|
if (data) {
|
||||||
setItem(data)
|
setItem(data)
|
||||||
setViews(data.actualViews)
|
setViews(data.actualViews)
|
||||||
|
Taro.setNavigationBarTitle({title: `${data.categoryName} - 文章详情`})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const Header = (props: any) => {
|
|||||||
success: function () {
|
success: function () {
|
||||||
if (code) {
|
if (code) {
|
||||||
Taro.request({
|
Taro.request({
|
||||||
url: 'https://server.gxwebsoft.com/api/wx-login/loginByMpWxPhone',
|
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
code,
|
code,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const Login = (props:any) => {
|
|||||||
success: function () {
|
success: function () {
|
||||||
if (code) {
|
if (code) {
|
||||||
Taro.request({
|
Taro.request({
|
||||||
url: 'https://server.gxwebsoft.com/api/wx-login/loginByMpWxPhone',
|
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
code,
|
code,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const Page = () => {
|
|||||||
navItems?.map((item, index) => (
|
navItems?.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.navigationId || index}
|
key={item.navigationId || index}
|
||||||
className={'flex flex-col justify-center items-center'}
|
className={'flex flex-col justify-start items-center'}
|
||||||
onClick={() => Taro.navigateTo({url: `/${item.model}/index?id=${item.navigationId}`})}
|
onClick={() => Taro.navigateTo({url: `/${item.model}/index?id=${item.navigationId}`})}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -40,7 +40,7 @@ const Page = () => {
|
|||||||
src={item.icon}
|
src={item.icon}
|
||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
/>
|
/>
|
||||||
<div className={'mt-2 text-gray-700'} style={{fontSize: '15px'}}>
|
<div className={'mt-2 text-gray-700'} style={{fontSize: '14px'}}>
|
||||||
{item?.title}
|
{item?.title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
48
src/pages/index/Video.tsx
Normal file
48
src/pages/index/Video.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
|
||||||
|
const videoUrl = 'https://oss.wsdns.cn/20260406/2be0376cac054f2ba86dd35a2bc52e11.mp4'
|
||||||
|
// 添加版本号解决缓存问题
|
||||||
|
const videoVersion = 'v=1.0'
|
||||||
|
const posterUrl = `${videoUrl}?x-oss-process=video/snapshot,t_1000,f_jpg,w_396,h_222,m_fast`
|
||||||
|
|
||||||
|
const MyPage = () => {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 点击播放按钮
|
||||||
|
const handlePlay = () => {
|
||||||
|
const video = document.querySelector('video')
|
||||||
|
if (video) {
|
||||||
|
video.play()
|
||||||
|
setIsPlaying(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'px-3'}>
|
||||||
|
<video
|
||||||
|
id="homeVideo"
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
webkit-playsinline="true"
|
||||||
|
x5-video-player-type="h5"
|
||||||
|
x5-video-player-fullscreen="false"
|
||||||
|
preload="metadata"
|
||||||
|
className='w-full max-w-2xl mx-auto'
|
||||||
|
poster={posterUrl}
|
||||||
|
>
|
||||||
|
<source src={`${videoUrl}?${videoVersion}`} type='video/mp4' />
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
|
||||||
|
{/* 移动端播放引导(可选的UI增强) */}
|
||||||
|
<div className={'text-center text-gray-400 text-xs mt-2'}>
|
||||||
|
如视频无法播放,请点击视频区域
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default MyPage
|
||||||
@@ -3,7 +3,7 @@ page {
|
|||||||
|
|
||||||
.mobile-container {
|
.mobile-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 90vh;
|
min-height: 100vh;
|
||||||
|
|
||||||
// PC端样式
|
// PC端样式
|
||||||
//@media screen and (min-width: 768px) {
|
//@media screen and (min-width: 768px) {
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import Taro from '@tarojs/taro';
|
|||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {getSiteInfo} from "@/api/layout";
|
import {getSiteInfo} from "@/api/layout";
|
||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
import Banner from "./Banner";
|
// import Banner from "./Banner";
|
||||||
import Menu from "./Menu";
|
import Menu from "./Menu";
|
||||||
import Image from "./Image";
|
import Image from "./Image";
|
||||||
import SimpleH5TabBar from "@/components/SimpleH5TabBar";
|
import SimpleH5TabBar from "@/components/SimpleH5TabBar";
|
||||||
|
import Video from "./Video";
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
@@ -44,7 +45,9 @@ function Home() {
|
|||||||
{!IsLogin && search ? (<Login done={handleLogin}/>) : (<>
|
{!IsLogin && search ? (<Login done={handleLogin}/>) : (<>
|
||||||
<Image/>
|
<Image/>
|
||||||
<Menu/>
|
<Menu/>
|
||||||
<Banner/>
|
{/*<Banner/>*/}
|
||||||
|
<Video />
|
||||||
|
<div className={'h-20'}></div>
|
||||||
</>)}
|
</>)}
|
||||||
{/* H5模式下显示自定义TabBar */}
|
{/* H5模式下显示自定义TabBar */}
|
||||||
{process.env.TARO_ENV === 'h5' && <SimpleH5TabBar current={0} />}
|
{process.env.TARO_ENV === 'h5' && <SimpleH5TabBar current={0} />}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {useEffect, useState} from 'react'
|
|||||||
import {useRouter} from '@tarojs/taro'
|
import {useRouter} from '@tarojs/taro'
|
||||||
import {Divider} from '@nutui/nutui-react-taro'
|
import {Divider} from '@nutui/nutui-react-taro'
|
||||||
import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
// 显示html富文本
|
// 显示html富文本
|
||||||
import {View, RichText} from '@tarojs/components'
|
import {View, RichText} from '@tarojs/components'
|
||||||
import {getCmsNavigation} from "@/api/cms/cmsNavigation";
|
import {getCmsNavigation} from "@/api/cms/cmsNavigation";
|
||||||
@@ -17,6 +18,7 @@ function Detail() {
|
|||||||
const nav = await getCmsNavigation(Number(params.id));
|
const nav = await getCmsNavigation(Number(params.id));
|
||||||
if(nav){
|
if(nav){
|
||||||
setItem(nav)
|
setItem(nav)
|
||||||
|
Taro.setNavigationBarTitle({title: `${nav.categoryName}`})
|
||||||
}
|
}
|
||||||
if(nav.design?.content){
|
if(nav.design?.content){
|
||||||
setContent(nav.design?.content)
|
setContent(nav.design?.content)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {getUserInfo, getWxOpenId} from "@/api/layout";
|
|||||||
import {TenantId} from "@/utils/config";
|
import {TenantId} from "@/utils/config";
|
||||||
import {User} from "@/api/system/user/model";
|
import {User} from "@/api/system/user/model";
|
||||||
// import News from "./News";
|
// import News from "./News";
|
||||||
import {myPageBszxBm} from "@/api/bszx/bszxBm";
|
|
||||||
import {listCmsNavigation} from "@/api/cms/cmsNavigation";
|
import {listCmsNavigation} from "@/api/cms/cmsNavigation";
|
||||||
|
|
||||||
const OrderIcon = () => {
|
const OrderIcon = () => {
|
||||||
@@ -14,7 +13,7 @@ const OrderIcon = () => {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [isLogin, setIsLogin] = useState<boolean>(false)
|
const [isLogin, setIsLogin] = useState<boolean>(false)
|
||||||
const [userInfo, setUserInfo] = useState<User>()
|
const [userInfo, setUserInfo] = useState<User>()
|
||||||
const [bmLogs, setBmLogs] = useState<any>()
|
const [bmLogs] = useState<any>()
|
||||||
const [navItems, setNavItems] = useState<any>([])
|
const [navItems, setNavItems] = useState<any>([])
|
||||||
|
|
||||||
/* 获取用户手机号 */
|
/* 获取用户手机号 */
|
||||||
@@ -24,7 +23,7 @@ const OrderIcon = () => {
|
|||||||
success: function () {
|
success: function () {
|
||||||
if (code) {
|
if (code) {
|
||||||
Taro.request({
|
Taro.request({
|
||||||
url: 'https://server.gxwebsoft.com/api/wx-login/loginByMpWxPhone',
|
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
code,
|
code,
|
||||||
@@ -114,12 +113,6 @@ const OrderIcon = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// 报名日志
|
|
||||||
myPageBszxBm({limit: 1}).then(res => {
|
|
||||||
if (res.list) {
|
|
||||||
setBmLogs(res.list);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,13 +179,13 @@ const OrderIcon = () => {
|
|||||||
<div className={'flex flex-col justify-center items-center'} onClick={() => {
|
<div className={'flex flex-col justify-center items-center'} onClick={() => {
|
||||||
onLogin(item, index)
|
onLogin(item, index)
|
||||||
}}>
|
}}>
|
||||||
<Image src={item.icon} height={28} width={28}/>
|
<Image src={item.icon}/>
|
||||||
<div className={'mt-2'} style={{fontSize: '15px'}}>{item?.title}</div>
|
<div className={'mt-2'} style={{fontSize: '15px'}}>{item?.title}</div>
|
||||||
</div>
|
</div>
|
||||||
:
|
:
|
||||||
<Button className={'text-white'} open-type="getPhoneNumber" onGetPhoneNumber={handleGetPhoneNumber}>
|
<Button className={'text-white'} open-type="getPhoneNumber" onGetPhoneNumber={handleGetPhoneNumber}>
|
||||||
<div className={'flex flex-col justify-center items-center'}>
|
<div className={'flex flex-col justify-center items-center'}>
|
||||||
<Image src={item.icon} height={28} width={28}/>
|
<Image src={item.icon}/>
|
||||||
<div className={'mt-2 text-gray-700'} style={{fontSize: '15px'}}>{item?.title}</div>
|
<div className={'mt-2 text-gray-700'} style={{fontSize: '15px'}}>{item?.title}</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
12
src/photo/detail.scss
Normal file
12
src/photo/detail.scss
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
.content{
|
||||||
|
img {
|
||||||
|
margin: 12px auto;
|
||||||
|
max-width: 80%;
|
||||||
|
background-color: #F2FE03;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 40px !important;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ import {useEffect, useState} from 'react'
|
|||||||
import {useRouter} from '@tarojs/taro'
|
import {useRouter} from '@tarojs/taro'
|
||||||
import {Divider} from '@nutui/nutui-react-taro'
|
import {Divider} from '@nutui/nutui-react-taro'
|
||||||
import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
// import {Eye} from '@nutui/icons-react-taro'
|
// import {Eye} from '@nutui/icons-react-taro'
|
||||||
// import dayjs from 'dayjs';
|
// import dayjs from 'dayjs';
|
||||||
// 显示html富文本
|
// 显示html富文本
|
||||||
import {View, RichText} from '@tarojs/components'
|
import {View, RichText} from '@tarojs/components'
|
||||||
import Line from "@/components/Gap";
|
import Line from "@/components/Gap";
|
||||||
|
import './detail.scss'
|
||||||
import {getCmsArticle} from "@/api/cms/cmsArticle";
|
import {getCmsArticle} from "@/api/cms/cmsArticle";
|
||||||
|
|
||||||
function Detail() {
|
function Detail() {
|
||||||
@@ -20,6 +22,7 @@ function Detail() {
|
|||||||
getCmsArticle(Number(params.id)).then(data => {
|
getCmsArticle(Number(params.id)).then(data => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setItem(data)
|
setItem(data)
|
||||||
|
Taro.setNavigationBarTitle({title: `${data.categoryName} - 文章详情`})
|
||||||
// setViews(data.actualViews)
|
// setViews(data.actualViews)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const Index = () => {
|
|||||||
// 当前栏目信息
|
// 当前栏目信息
|
||||||
if (navs) {
|
if (navs) {
|
||||||
setNavigation(navs);
|
setNavigation(navs);
|
||||||
|
Taro.setNavigationBarTitle({title: `${navs.title}`})
|
||||||
}
|
}
|
||||||
// 获取子级栏目
|
// 获取子级栏目
|
||||||
if (childCateogry) {
|
if (childCateogry) {
|
||||||
@@ -42,7 +43,7 @@ const Index = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload()
|
reload().then()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function Profile() {
|
|||||||
avatar: `${detail.avatarUrl}`,
|
avatar: `${detail.avatarUrl}`,
|
||||||
})
|
})
|
||||||
Taro.uploadFile({
|
Taro.uploadFile({
|
||||||
url: 'https://server.gxwebsoft.com/api/oss/upload',
|
url: 'https://server.websoft.top/api/oss/upload',
|
||||||
filePath: detail.avatarUrl,
|
filePath: detail.avatarUrl,
|
||||||
name: 'file',
|
name: 'file',
|
||||||
header: {
|
header: {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import {User} from "@/api/system/user/model";
|
|||||||
// 模版套餐ID
|
// 模版套餐ID
|
||||||
export const TEMPLATE_ID = 10556;
|
export const TEMPLATE_ID = 10556;
|
||||||
// 服务接口
|
// 服务接口
|
||||||
export const SERVER_API_URL = 'https://server.gxwebsoft.com/api';
|
export const SERVER_API_URL = 'https://server.websoft.top/api';
|
||||||
// export const SERVER_API_URL = 'http://127.0.0.1:8000/api';
|
// export const SERVER_API_URL = 'http://127.0.0.1:8000/api';
|
||||||
// 服务接口
|
// 服务接口
|
||||||
export const APP_API_URL = 'https://cms-api.websoft.top/api';
|
export const APP_API_URL = 'https://cms-api.websoft.top/api';
|
||||||
// export const APP_API_URL = 'http://127.0.0.1:9000/api';
|
// export const APP_API_URL = 'http://127.0.0.1:9000/api';
|
||||||
// WSS
|
// WSS
|
||||||
export const WSS_API_URL = 'wss://cms-api.websoft.top/api';
|
export const WSS_API_URL = 'ws://server.websoft.top/api';
|
||||||
/**
|
/**
|
||||||
* 保存用户信息到本地存储
|
* 保存用户信息到本地存储
|
||||||
* @param token
|
* @param token
|
||||||
|
|||||||
4
src/yxtwjr/detail.config.ts
Normal file
4
src/yxtwjr/detail.config.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '优秀退伍军人 - 文章详情',
|
||||||
|
navigationBarBackgroundColor: '#ffe0e0'
|
||||||
|
})
|
||||||
11
src/yxtwjr/detail.scss
Normal file
11
src/yxtwjr/detail.scss
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.content{
|
||||||
|
img{
|
||||||
|
margin: 12px auto;
|
||||||
|
max-width: 80%;
|
||||||
|
background-color: #F2FE03;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 40px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/yxtwjr/detail.tsx
Normal file
46
src/yxtwjr/detail.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {useRouter} from '@tarojs/taro'
|
||||||
|
import {Divider} from '@nutui/nutui-react-taro'
|
||||||
|
import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
||||||
|
// 显示html富文本
|
||||||
|
import {View, RichText} from '@tarojs/components'
|
||||||
|
// import Line from "@/components/Gap";
|
||||||
|
import {getCmsArticle} from "@/api/cms/cmsArticle";
|
||||||
|
import './detail.scss';
|
||||||
|
|
||||||
|
function Detail() {
|
||||||
|
const {params} = useRouter();
|
||||||
|
// 文章详情
|
||||||
|
const [item, setItem] = useState<CmsArticle>()
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
getCmsArticle(Number(params.id)).then(data => {
|
||||||
|
if (data) {
|
||||||
|
setItem(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(to bottom, #F63D3D, #C01717)',
|
||||||
|
minHeight: '100vh'
|
||||||
|
}}>
|
||||||
|
<div className={'p-3 font-bold text-center text-lg text-[#F2FE03]'}>{item?.title}</div>
|
||||||
|
<Divider/>
|
||||||
|
{/*<img src={item?.image} alt={item?.title} />*/}
|
||||||
|
<View className={'content text-[#F2FE03] text-sm'}>
|
||||||
|
<RichText
|
||||||
|
nodes={item?.content || '暂无内容'}
|
||||||
|
space="nbsp"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Detail
|
||||||
5
src/yxtwjr/index.config.ts
Normal file
5
src/yxtwjr/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '优秀退伍军人风采',
|
||||||
|
navigationBarBackgroundColor: '#d32f2f',
|
||||||
|
navigationBarTextStyle: 'white'
|
||||||
|
})
|
||||||
186
src/yxtwjr/index.scss
Normal file
186
src/yxtwjr/index.scss
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
.veteran-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #d32f2f 0%, #b71c1c 100%);
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
position: relative;
|
||||||
|
padding: 40px 20px 60px;
|
||||||
|
background: linear-gradient(135deg, #d32f2f 0%, #b71c1c 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 320px;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-decoration {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.decoration-circle {
|
||||||
|
position: absolute;
|
||||||
|
top: -50px;
|
||||||
|
right: -50px;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: rotate 20s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration-star {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 30px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 15px solid transparent;
|
||||||
|
border-right: 15px solid transparent;
|
||||||
|
border-bottom: 10px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transform: rotate(35deg);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -15px;
|
||||||
|
top: 3px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 15px solid transparent;
|
||||||
|
border-right: 15px solid transparent;
|
||||||
|
border-bottom: 10px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transform: rotate(-70deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.veteran-list {
|
||||||
|
padding: 20px 15px;
|
||||||
|
|
||||||
|
.veteran-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 2px solid #d32f2f;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.veteran-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 15px;
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 3px solid #d32f2f;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.veteran-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.veteran-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #d32f2f;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veteran-description {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #666;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
.veteran-page {
|
||||||
|
.hero-section {
|
||||||
|
padding: 30px 15px 50px;
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
.hero-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.veteran-list {
|
||||||
|
padding: 15px 10px;
|
||||||
|
|
||||||
|
.veteran-card {
|
||||||
|
padding: 15px;
|
||||||
|
|
||||||
|
.veteran-avatar {
|
||||||
|
.avatar-img {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.veteran-info {
|
||||||
|
.veteran-name {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veteran-description {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/yxtwjr/index.tsx
Normal file
155
src/yxtwjr/index.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {pageCmsArticle} from "@/api/cms/cmsArticle";
|
||||||
|
import {CmsArticle} from "@/api/cms/cmsArticle/model";
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import {useRouter} from '@tarojs/taro'
|
||||||
|
import {Image} from '@nutui/nutui-react-taro'
|
||||||
|
import {InfiniteLoading} from '@nutui/nutui-react-taro'
|
||||||
|
import {getCmsNavigation} from "@/api/cms/cmsNavigation";
|
||||||
|
import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章终极列表
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
const Index = () => {
|
||||||
|
const {params} = useRouter();
|
||||||
|
const [navigation, setNavigation] = useState<CmsNavigation>()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [list, setList] = useState<CmsArticle[]>([])
|
||||||
|
|
||||||
|
const categoryId = Number(params.id);
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
console.log('初始化加载 - 分类ID:', categoryId);
|
||||||
|
// 重置状态
|
||||||
|
setPage(1);
|
||||||
|
setList([]);
|
||||||
|
setHasMore(true);
|
||||||
|
|
||||||
|
// 当前栏目信息
|
||||||
|
const navs = await getCmsNavigation(categoryId);
|
||||||
|
if (navs) {
|
||||||
|
setNavigation(navs);
|
||||||
|
Taro.setNavigationBarTitle({title: `${navs.title}`})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载第一页数据
|
||||||
|
getList(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 终极新闻列表
|
||||||
|
const getList = (currentPage = page) => {
|
||||||
|
console.log('请求数据 - 页码:', currentPage, '分类ID:', categoryId);
|
||||||
|
pageCmsArticle({categoryId, page: currentPage}).then(res => {
|
||||||
|
console.log('获取到数据:', res);
|
||||||
|
if (res?.list && res?.list.length > 0) {
|
||||||
|
if (currentPage === 1) {
|
||||||
|
// 第一页,直接设置
|
||||||
|
setList(res.list);
|
||||||
|
} else {
|
||||||
|
// 后续页面,追加到现有列表
|
||||||
|
setList(prevList => {
|
||||||
|
const newList = [...prevList, ...res.list];
|
||||||
|
console.log('合并后的列表长度:', newList.length);
|
||||||
|
return newList;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setHasMore(true);
|
||||||
|
} else {
|
||||||
|
console.log('没有更多数据了');
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('获取数据失败:', error);
|
||||||
|
setHasMore(false);
|
||||||
|
}).finally(() => {
|
||||||
|
Taro.hideLoading()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadMore = async () => {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
console.log('加载更多 - 下一页:', nextPage);
|
||||||
|
Taro.showLoading({
|
||||||
|
title: '加载中...',
|
||||||
|
})
|
||||||
|
setPage(nextPage);
|
||||||
|
getList(nextPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload().then()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InfiniteLoading
|
||||||
|
className={'bg-red-200 h-full'}
|
||||||
|
hasMore={hasMore}
|
||||||
|
onLoadMore={reloadMore}
|
||||||
|
loadingText={
|
||||||
|
<>
|
||||||
|
加载中
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
loadMoreText={
|
||||||
|
<>
|
||||||
|
没有更多了
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<div style={{padding: navigation?.span + 'px'}}>
|
||||||
|
<Image src={navigation?.style} width={'100%'}
|
||||||
|
height={'auto'}/>
|
||||||
|
</div>
|
||||||
|
<div className={'rounded-lg py-3 px-2'}>
|
||||||
|
<div className={'grid grid-cols-1 gap-3'}>
|
||||||
|
{
|
||||||
|
// 终极文章列表
|
||||||
|
list.map((item, index) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.articleId || `article-${index}`}
|
||||||
|
className={'flex items-center cursor-pointer'}
|
||||||
|
style={{
|
||||||
|
border: '3px solid #F2FE03',
|
||||||
|
backgroundColor: '#C01717',
|
||||||
|
color: '#F2FE03',
|
||||||
|
borderRadius: '16px',
|
||||||
|
}}
|
||||||
|
onClick={() => Taro.navigateTo({url: `./detail?id=${item.articleId}`})}
|
||||||
|
>
|
||||||
|
{/* 图片容器 */}
|
||||||
|
<div className={'w-full m-3 flex justify-center'}
|
||||||
|
style={{
|
||||||
|
width: '108px',
|
||||||
|
height: '160px',
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
className={'object-cover'}
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className={'flex flex-col items-start my-3 text-sm leading-tight'} style={{
|
||||||
|
color: '#F2FE03'
|
||||||
|
}}>
|
||||||
|
<p className={'font-bold py-1'}>{item.title}</p>
|
||||||
|
<p className={'text-xs line-clamp-6'} style={{
|
||||||
|
width: '240px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{item.comments || '暂无'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</InfiniteLoading>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default Index
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
// import {Tag} from '@nutui/nutui-react-taro'
|
import Taro from '@tarojs/taro'
|
||||||
import {useRouter} from '@tarojs/taro'
|
import {useRouter} from '@tarojs/taro'
|
||||||
import {Divider} from '@nutui/nutui-react-taro'
|
import {Divider} from '@nutui/nutui-react-taro'
|
||||||
import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
import {CmsArticle} from "@/api/cms/cmsArticle/model"
|
||||||
@@ -20,6 +20,7 @@ function Detail() {
|
|||||||
getCmsArticle(Number(params.id)).then(data => {
|
getCmsArticle(Number(params.id)).then(data => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setItem(data)
|
setItem(data)
|
||||||
|
Taro.setNavigationBarTitle({title: `${data.categoryName} - 文章详情`})
|
||||||
// setViews(data.actualViews)
|
// setViews(data.actualViews)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const Index = () => {
|
|||||||
// 当前栏目信息
|
// 当前栏目信息
|
||||||
if (navs) {
|
if (navs) {
|
||||||
setNavigation(navs);
|
setNavigation(navs);
|
||||||
|
Taro.setNavigationBarTitle({title: `${navs.title}`})
|
||||||
}
|
}
|
||||||
// 新闻列表
|
// 新闻列表
|
||||||
if (articles) {
|
if (articles) {
|
||||||
@@ -36,7 +37,7 @@ const Index = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload()
|
reload().then()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,7 +53,7 @@ const Index = () => {
|
|||||||
list.map((item, index) => {
|
list.map((item, index) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={item.articleId || `article-${index}`}
|
||||||
className={'flex items-center cursor-pointer px-3'}
|
className={'flex items-center cursor-pointer px-3'}
|
||||||
style={{
|
style={{
|
||||||
border: '3px solid #F2FE03',
|
border: '3px solid #F2FE03',
|
||||||
|
|||||||
181
ssl-setup.sh
Executable file
181
ssl-setup.sh
Executable file
@@ -0,0 +1,181 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# SSL证书配置脚本
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查域名
|
||||||
|
check_domain() {
|
||||||
|
local domain=$1
|
||||||
|
if [ -z "$domain" ]; then
|
||||||
|
log_error "请提供域名"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "检查域名: $domain"
|
||||||
|
if ! nslookup $domain > /dev/null 2>&1; then
|
||||||
|
log_warning "域名解析失败,请确保域名已正确配置"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用Let's Encrypt获取证书
|
||||||
|
setup_letsencrypt() {
|
||||||
|
local domain=$1
|
||||||
|
local email=$2
|
||||||
|
|
||||||
|
log_info "使用Let's Encrypt获取SSL证书..."
|
||||||
|
|
||||||
|
# 安装certbot
|
||||||
|
if ! command -v certbot &> /dev/null; then
|
||||||
|
log_info "安装certbot..."
|
||||||
|
if command -v apt-get &> /dev/null; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y certbot
|
||||||
|
elif command -v yum &> /dev/null; then
|
||||||
|
sudo yum install -y certbot
|
||||||
|
else
|
||||||
|
log_error "无法自动安装certbot,请手动安装"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建webroot目录
|
||||||
|
mkdir -p /var/www/certbot
|
||||||
|
|
||||||
|
# 获取证书
|
||||||
|
sudo certbot certonly \
|
||||||
|
--webroot \
|
||||||
|
--webroot-path=/var/www/certbot \
|
||||||
|
--email $email \
|
||||||
|
--agree-tos \
|
||||||
|
--no-eff-email \
|
||||||
|
-d $domain
|
||||||
|
|
||||||
|
# 复制证书到项目目录
|
||||||
|
sudo cp /etc/letsencrypt/live/$domain/fullchain.pem ssl/
|
||||||
|
sudo cp /etc/letsencrypt/live/$domain/privkey.pem ssl/
|
||||||
|
sudo chown $(whoami):$(whoami) ssl/*.pem
|
||||||
|
|
||||||
|
log_info "SSL证书配置完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 生成自签名证书(开发/测试用)
|
||||||
|
setup_selfsigned() {
|
||||||
|
local domain=$1
|
||||||
|
|
||||||
|
log_info "生成自签名SSL证书..."
|
||||||
|
|
||||||
|
# 创建ssl目录
|
||||||
|
mkdir -p ssl
|
||||||
|
|
||||||
|
# 生成私钥
|
||||||
|
openssl genrsa -out ssl/privkey.pem 2048
|
||||||
|
|
||||||
|
# 生成证书
|
||||||
|
openssl req -new -x509 -key ssl/privkey.pem -out ssl/fullchain.pem -days 365 \
|
||||||
|
-subj "/C=CN/ST=State/L=City/O=Organization/CN=$domain"
|
||||||
|
|
||||||
|
log_warning "已生成自签名证书,仅用于开发/测试环境"
|
||||||
|
log_warning "生产环境请使用有效的SSL证书"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 配置证书自动续期
|
||||||
|
setup_auto_renewal() {
|
||||||
|
local domain=$1
|
||||||
|
|
||||||
|
log_info "配置证书自动续期..."
|
||||||
|
|
||||||
|
# 创建续期脚本
|
||||||
|
cat > ssl-renew.sh << EOF
|
||||||
|
#!/bin/bash
|
||||||
|
certbot renew --quiet
|
||||||
|
if [ \$? -eq 0 ]; then
|
||||||
|
cp /etc/letsencrypt/live/$domain/fullchain.pem ssl/
|
||||||
|
cp /etc/letsencrypt/live/$domain/privkey.pem ssl/
|
||||||
|
docker-compose -f docker-compose.prod.yml restart nginx-proxy
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x ssl-renew.sh
|
||||||
|
|
||||||
|
# 添加到crontab
|
||||||
|
(crontab -l 2>/dev/null; echo "0 3 * * * $(pwd)/ssl-renew.sh") | crontab -
|
||||||
|
|
||||||
|
log_info "证书自动续期配置完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主函数
|
||||||
|
main() {
|
||||||
|
echo "🔒 SSL证书配置脚本"
|
||||||
|
echo "=================="
|
||||||
|
|
||||||
|
read -p "请输入域名: " DOMAIN
|
||||||
|
check_domain $DOMAIN
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "请选择证书类型:"
|
||||||
|
echo "1) Let's Encrypt (免费,推荐生产环境)"
|
||||||
|
echo "2) 自签名证书 (开发/测试环境)"
|
||||||
|
echo ""
|
||||||
|
read -p "请输入选择 (1-2): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
read -p "请输入邮箱地址: " EMAIL
|
||||||
|
if [ -z "$EMAIL" ]; then
|
||||||
|
log_error "邮箱地址不能为空"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
setup_letsencrypt $DOMAIN $EMAIL
|
||||||
|
setup_auto_renewal $DOMAIN
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
setup_selfsigned $DOMAIN
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "无效选择"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# 更新nginx配置中的域名
|
||||||
|
if [ -f "nginx-proxy.conf" ]; then
|
||||||
|
sed -i "s/your-domain.com/$DOMAIN/g" nginx-proxy.conf
|
||||||
|
log_info "已更新nginx配置中的域名"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 更新环境配置
|
||||||
|
if [ -f ".env.production" ]; then
|
||||||
|
sed -i "s/DOMAIN=your-domain.com/DOMAIN=$DOMAIN/g" .env.production
|
||||||
|
log_info "已更新环境配置中的域名"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_info "SSL证书配置完成!"
|
||||||
|
echo "证书文件位置:"
|
||||||
|
echo " - 证书: ssl/fullchain.pem"
|
||||||
|
echo " - 私钥: ssl/privkey.pem"
|
||||||
|
echo ""
|
||||||
|
echo "现在可以使用HTTPS模式部署:"
|
||||||
|
echo " ./deploy-prod.sh https"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 运行主函数
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user