Compare commits
6 Commits
23188d5690
...
43229a21c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43229a21c0 | ||
|
|
35198aa548 | ||
|
|
1f3fe8ce39 | ||
|
|
a9fee411ea | ||
|
|
433a982a20 | ||
|
|
cc210f9fda |
@@ -3,7 +3,8 @@
|
||||
"allow": [
|
||||
"Read(//Users/qiye/**)",
|
||||
"Bash(npm run lint:check)",
|
||||
"Bash(npm run build)"
|
||||
"Bash(npm run build)",
|
||||
"Bash(chmod +x /Users/qiye/Desktop/jzqy/vf_react/scripts/*.sh)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
63
.env.deploy.example
Normal file
63
.env.deploy.example
Normal file
@@ -0,0 +1,63 @@
|
||||
# 部署配置文件
|
||||
# 首次使用请复制此文件为 .env.deploy 并填写真实配置
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
# 服务器 IP 或域名
|
||||
SERVER_HOST=your-server-ip-or-domain
|
||||
|
||||
# SSH 用户名
|
||||
SERVER_USER=ubuntu
|
||||
|
||||
# SSH 端口
|
||||
SERVER_PORT=22
|
||||
|
||||
# SSH 密钥路径(留空使用默认 ~/.ssh/id_rsa)
|
||||
SSH_KEY_PATH=
|
||||
|
||||
# ==================== 路径配置 ====================
|
||||
# 服务器上的 Git 仓库路径
|
||||
REMOTE_PROJECT_PATH=/home/ubuntu/vf_react
|
||||
|
||||
# 生产环境部署路径
|
||||
PRODUCTION_PATH=/var/www/valuefrontier.cn
|
||||
|
||||
# 部署备份目录
|
||||
BACKUP_DIR=/home/ubuntu/deployments
|
||||
|
||||
# 部署日志目录
|
||||
LOG_DIR=/home/ubuntu/deploy-logs
|
||||
|
||||
# ==================== Git 配置 ====================
|
||||
# 部署分支
|
||||
DEPLOY_BRANCH=feature
|
||||
|
||||
# ==================== 备份配置 ====================
|
||||
# 保留备份数量
|
||||
KEEP_BACKUPS=5
|
||||
|
||||
# ==================== 企业微信通知配置 ====================
|
||||
# 是否启用企业微信通知 (true/false)
|
||||
ENABLE_WECHAT_NOTIFY=false
|
||||
|
||||
# 企业微信机器人 Webhook URL
|
||||
WECHAT_WEBHOOK_URL=
|
||||
|
||||
# 通知提及的用户(@all 或 手机号/userid)
|
||||
WECHAT_MENTIONED_LIST=
|
||||
|
||||
# ==================== 部署配置 ====================
|
||||
# 是否在部署前运行 npm install (true/false)
|
||||
RUN_NPM_INSTALL=true
|
||||
|
||||
# 是否在部署前运行 npm test (true/false)
|
||||
RUN_NPM_TEST=false
|
||||
|
||||
# 构建命令
|
||||
BUILD_COMMAND=npm run build
|
||||
|
||||
# ==================== 高级配置 ====================
|
||||
# SSH 连接超时时间(秒)
|
||||
SSH_TIMEOUT=30
|
||||
|
||||
# 部署超时时间(秒)
|
||||
DEPLOY_TIMEOUT=600
|
||||
@@ -97,7 +97,9 @@
|
||||
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
|
||||
"test": "craco test --env=jsdom",
|
||||
"eject": "react-scripts eject",
|
||||
"deploy": "npm run build",
|
||||
"deploy": "bash scripts/deploy-from-local.sh",
|
||||
"deploy:setup": "bash scripts/setup-deployment.sh",
|
||||
"rollback": "bash scripts/rollback-from-local.sh",
|
||||
"lint:check": "eslint . --ext=js,jsx; exit 0",
|
||||
"lint:fix": "eslint . --ext=js,jsx --fix; exit 0",
|
||||
"install:clean": "rm -rf node_modules/ && rm -rf package-lock.json && npm install && npm start"
|
||||
|
||||
392
scripts/deploy-from-local.sh
Executable file
392
scripts/deploy-from-local.sh
Executable file
@@ -0,0 +1,392 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 本地部署脚本
|
||||
# 在本地运行,通过 SSH 连接服务器并执行部署
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
###############################################################################
|
||||
# 函数:打印带颜色的消息
|
||||
###############################################################################
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
log_warning() { echo -e "${YELLOW}[⚠]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
log_step() { echo -e "${CYAN}${BOLD}[$1]${NC} $2"; }
|
||||
|
||||
###############################################################################
|
||||
# 函数:加载配置文件
|
||||
###############################################################################
|
||||
load_config() {
|
||||
if [ ! -f "$PROJECT_ROOT/.env.deploy" ]; then
|
||||
log_error "配置文件不存在: $PROJECT_ROOT/.env.deploy"
|
||||
echo ""
|
||||
echo "请先运行以下命令进行配置:"
|
||||
echo " npm run deploy:setup"
|
||||
echo ""
|
||||
echo "或者手动创建配置文件:"
|
||||
echo " cp .env.deploy.example .env.deploy"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 加载配置
|
||||
source "$PROJECT_ROOT/.env.deploy"
|
||||
|
||||
# 检查必需的配置项
|
||||
if [ -z "$SERVER_HOST" ] || [ -z "$SERVER_USER" ]; then
|
||||
log_error "配置不完整,请检查 .env.deploy 文件"
|
||||
echo "必需配置项:"
|
||||
echo " - SERVER_HOST: 服务器地址"
|
||||
echo " - SERVER_USER: SSH 用户名"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "配置加载完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:检查本地 Git 状态
|
||||
###############################################################################
|
||||
check_local_git() {
|
||||
log_step "1/8" "检查本地代码"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# 检查是否是 Git 仓库
|
||||
if [ ! -d ".git" ]; then
|
||||
log_error "当前目录不是 Git 仓库"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 获取当前分支
|
||||
local current_branch=$(git branch --show-current)
|
||||
log_info "当前分支: $current_branch"
|
||||
|
||||
# 检查是否有未提交的更改
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
log_warning "存在未提交的更改"
|
||||
echo ""
|
||||
git status --short
|
||||
echo ""
|
||||
read -p "是否继续部署? (y/n): " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
log_info "部署已取消"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 获取最新提交信息
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||
COMMIT_MESSAGE=$(git log -1 --pretty=%B | head -n 1)
|
||||
COMMIT_AUTHOR=$(git log -1 --pretty=%an)
|
||||
|
||||
log_info "最新提交: $COMMIT_HASH - $COMMIT_MESSAGE"
|
||||
log_info "提交作者: $COMMIT_AUTHOR"
|
||||
|
||||
log_success "本地代码检查完成"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:显示部署预览
|
||||
###############################################################################
|
||||
show_deploy_preview() {
|
||||
log_step "2/8" "部署预览"
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 部署预览 ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -e "${BOLD}项目信息:${NC}"
|
||||
echo " 项目名称: vf_react"
|
||||
echo " 部署环境: 生产环境"
|
||||
echo " 目标服务器: $SERVER_USER@$SERVER_HOST"
|
||||
echo ""
|
||||
echo -e "${BOLD}代码信息:${NC}"
|
||||
echo " 当前分支: $(git branch --show-current)"
|
||||
echo " 提交版本: $COMMIT_HASH"
|
||||
echo " 提交信息: $COMMIT_MESSAGE"
|
||||
echo " 提交作者: $COMMIT_AUTHOR"
|
||||
echo ""
|
||||
echo -e "${BOLD}部署路径:${NC}"
|
||||
echo " Git 仓库: $REMOTE_PROJECT_PATH"
|
||||
echo " 生产目录: $PRODUCTION_PATH"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# 询问是否继续
|
||||
read -p "确认部署到生产环境? (yes/no): " -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
|
||||
log_info "部署已取消"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:测试 SSH 连接
|
||||
###############################################################################
|
||||
test_ssh_connection() {
|
||||
log_step "3/8" "测试 SSH 连接"
|
||||
|
||||
local ssh_options="-o ConnectTimeout=${SSH_TIMEOUT:-30} -o BatchMode=yes"
|
||||
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="$ssh_options -i $SSH_KEY_PATH"
|
||||
fi
|
||||
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
# 测试连接
|
||||
if ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "echo 'SSH 连接成功'" > /dev/null 2>&1; then
|
||||
log_success "SSH 连接成功"
|
||||
else
|
||||
log_error "SSH 连接失败"
|
||||
echo ""
|
||||
echo "请检查:"
|
||||
echo " 1. 服务器地址是否正确: $SERVER_HOST"
|
||||
echo " 2. SSH 用户名是否正确: $SERVER_USER"
|
||||
echo " 3. SSH 密钥是否配置正确"
|
||||
echo " 4. 服务器端口是否正确: ${SERVER_PORT:-22}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:上传服务器端脚本
|
||||
###############################################################################
|
||||
upload_server_scripts() {
|
||||
log_step "4/8" "上传部署脚本"
|
||||
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -P $SERVER_PORT"
|
||||
fi
|
||||
|
||||
# 创建远程脚本目录
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "mkdir -p /tmp/deploy-scripts" || {
|
||||
log_error "创建远程目录失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 上传脚本
|
||||
scp $ssh_options \
|
||||
"$SCRIPT_DIR/deploy-on-server.sh" \
|
||||
"$SCRIPT_DIR/rollback-on-server.sh" \
|
||||
"$SCRIPT_DIR/notify-wechat.sh" \
|
||||
"$SERVER_USER@$SERVER_HOST":/tmp/deploy-scripts/ > /dev/null 2>&1 || {
|
||||
log_error "上传脚本失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 设置执行权限
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "chmod +x /tmp/deploy-scripts/*.sh" || {
|
||||
log_error "设置脚本权限失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_success "部署脚本上传完成"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:执行服务器端部署
|
||||
###############################################################################
|
||||
execute_remote_deployment() {
|
||||
log_step "5/8" "执行远程部署"
|
||||
echo ""
|
||||
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
# 构建环境变量
|
||||
local env_vars="REMOTE_PROJECT_PATH=$REMOTE_PROJECT_PATH "
|
||||
env_vars+="PRODUCTION_PATH=$PRODUCTION_PATH "
|
||||
env_vars+="BACKUP_DIR=$BACKUP_DIR "
|
||||
env_vars+="LOG_DIR=$LOG_DIR "
|
||||
env_vars+="DEPLOY_BRANCH=$DEPLOY_BRANCH "
|
||||
env_vars+="KEEP_BACKUPS=$KEEP_BACKUPS "
|
||||
env_vars+="RUN_NPM_INSTALL=$RUN_NPM_INSTALL"
|
||||
|
||||
# 记录开始时间
|
||||
DEPLOY_START_TIME=$(date +%s)
|
||||
|
||||
# 执行部署脚本
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "$env_vars bash /tmp/deploy-scripts/deploy-on-server.sh" || {
|
||||
log_error "远程部署失败"
|
||||
send_failure_notification "部署脚本执行失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 记录结束时间
|
||||
DEPLOY_END_TIME=$(date +%s)
|
||||
DEPLOY_DURATION=$((DEPLOY_END_TIME - DEPLOY_START_TIME))
|
||||
|
||||
echo ""
|
||||
log_success "远程部署完成"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:发送成功通知
|
||||
###############################################################################
|
||||
send_success_notification() {
|
||||
log_step "6/8" "发送部署通知"
|
||||
|
||||
if [ "$ENABLE_WECHAT_NOTIFY" = "true" ]; then
|
||||
local minutes=$((DEPLOY_DURATION / 60))
|
||||
local seconds=$((DEPLOY_DURATION % 60))
|
||||
local duration="${minutes}分${seconds}秒"
|
||||
|
||||
bash "$SCRIPT_DIR/notify-wechat.sh" success \
|
||||
"$DEPLOY_BRANCH" \
|
||||
"$COMMIT_HASH" \
|
||||
"$COMMIT_MESSAGE" \
|
||||
"$duration" \
|
||||
"$USER" || {
|
||||
log_warning "企业微信通知发送失败"
|
||||
}
|
||||
else
|
||||
log_info "企业微信通知未启用"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:发送失败通知
|
||||
###############################################################################
|
||||
send_failure_notification() {
|
||||
local error_message="$1"
|
||||
|
||||
if [ "$ENABLE_WECHAT_NOTIFY" = "true" ]; then
|
||||
bash "$SCRIPT_DIR/notify-wechat.sh" failure \
|
||||
"$DEPLOY_BRANCH" \
|
||||
"$error_message" \
|
||||
"$USER" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:清理临时文件
|
||||
###############################################################################
|
||||
cleanup() {
|
||||
log_step "7/8" "清理临时文件"
|
||||
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "rm -rf /tmp/deploy-scripts" > /dev/null 2>&1 || true
|
||||
|
||||
log_success "清理完成"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:显示部署结果
|
||||
###############################################################################
|
||||
show_deployment_result() {
|
||||
log_step "8/8" "部署完成"
|
||||
|
||||
local minutes=$((DEPLOY_DURATION / 60))
|
||||
local seconds=$((DEPLOY_DURATION % 60))
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 🎉 部署成功! ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -e "${BOLD}部署信息:${NC}"
|
||||
echo " 版本: $COMMIT_HASH"
|
||||
echo " 分支: $DEPLOY_BRANCH"
|
||||
echo " 提交: $COMMIT_MESSAGE"
|
||||
echo " 作者: $COMMIT_AUTHOR"
|
||||
echo " 时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo " 耗时: ${minutes}分${seconds}秒"
|
||||
echo ""
|
||||
echo -e "${BOLD}访问地址:${NC}"
|
||||
echo " https://valuefrontier.cn"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主函数
|
||||
###############################################################################
|
||||
main() {
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ VF React - 生产环境部署工具 ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# 加载配置
|
||||
load_config
|
||||
|
||||
# 检查本地 Git 状态
|
||||
check_local_git
|
||||
|
||||
# 显示部署预览
|
||||
show_deploy_preview
|
||||
|
||||
# 测试 SSH 连接
|
||||
test_ssh_connection
|
||||
|
||||
# 上传服务器端脚本
|
||||
upload_server_scripts
|
||||
|
||||
# 执行远程部署
|
||||
execute_remote_deployment
|
||||
|
||||
# 发送成功通知
|
||||
send_success_notification
|
||||
|
||||
# 清理临时文件
|
||||
cleanup
|
||||
|
||||
# 显示部署结果
|
||||
show_deployment_result
|
||||
}
|
||||
|
||||
# 错误处理
|
||||
trap 'log_error "部署过程中发生错误"; send_failure_notification "部署异常中断"; exit 1' ERR
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
313
scripts/deploy-on-server.sh
Executable file
313
scripts/deploy-on-server.sh
Executable file
@@ -0,0 +1,313 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 服务器端部署脚本
|
||||
# 此脚本在服务器上执行,由本地部署脚本远程调用
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
###############################################################################
|
||||
# 配置变量(通过环境变量传入)
|
||||
###############################################################################
|
||||
PROJECT_PATH="${REMOTE_PROJECT_PATH:-/home/ubuntu/vf_react}"
|
||||
PRODUCTION_PATH="${PRODUCTION_PATH:-/var/www/valuefrontier.cn}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/home/ubuntu/deployments}"
|
||||
LOG_DIR="${LOG_DIR:-/home/ubuntu/deploy-logs}"
|
||||
DEPLOY_BRANCH="${DEPLOY_BRANCH:-feature}"
|
||||
KEEP_BACKUPS="${KEEP_BACKUPS:-5}"
|
||||
RUN_NPM_INSTALL="${RUN_NPM_INSTALL:-true}"
|
||||
|
||||
###############################################################################
|
||||
# 函数:打印带颜色的消息
|
||||
###############################################################################
|
||||
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"; }
|
||||
|
||||
###############################################################################
|
||||
# 函数:创建必要的目录
|
||||
###############################################################################
|
||||
create_directories() {
|
||||
log_info "创建必要的目录..."
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
mkdir -p "$LOG_DIR"
|
||||
mkdir -p "$PRODUCTION_PATH"
|
||||
log_success "目录创建完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:检查 Git 仓库
|
||||
###############################################################################
|
||||
check_git_repo() {
|
||||
log_info "检查 Git 仓库..."
|
||||
|
||||
if [ ! -d "$PROJECT_PATH/.git" ]; then
|
||||
log_error "Git 仓库不存在: $PROJECT_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_PATH"
|
||||
log_success "Git 仓库检查通过"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:切换到目标分支
|
||||
###############################################################################
|
||||
checkout_branch() {
|
||||
log_info "切换到 $DEPLOY_BRANCH 分支..."
|
||||
|
||||
cd "$PROJECT_PATH"
|
||||
|
||||
# 获取当前分支
|
||||
current_branch=$(git branch --show-current)
|
||||
|
||||
if [ "$current_branch" != "$DEPLOY_BRANCH" ]; then
|
||||
log_warning "当前分支是 $current_branch,正在切换到 $DEPLOY_BRANCH..."
|
||||
git checkout "$DEPLOY_BRANCH" || {
|
||||
log_error "切换分支失败"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
log_success "已在 $DEPLOY_BRANCH 分支"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:拉取最新代码
|
||||
###############################################################################
|
||||
pull_latest_code() {
|
||||
log_info "拉取最新代码..."
|
||||
|
||||
cd "$PROJECT_PATH"
|
||||
|
||||
# 保存本地修改(如果有)
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
log_warning "检测到本地修改,正在暂存..."
|
||||
git stash
|
||||
fi
|
||||
|
||||
# 拉取最新代码
|
||||
git pull origin "$DEPLOY_BRANCH" || {
|
||||
log_error "拉取代码失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_success "代码更新完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:获取当前提交信息
|
||||
###############################################################################
|
||||
get_commit_info() {
|
||||
cd "$PROJECT_PATH"
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||
COMMIT_MESSAGE=$(git log -1 --pretty=%B | head -n 1)
|
||||
COMMIT_AUTHOR=$(git log -1 --pretty=%an)
|
||||
COMMIT_TIME=$(git log -1 --pretty=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
||||
|
||||
echo "提交哈希: $COMMIT_HASH"
|
||||
echo "提交信息: $COMMIT_MESSAGE"
|
||||
echo "提交作者: $COMMIT_AUTHOR"
|
||||
echo "提交时间: $COMMIT_TIME"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:安装依赖
|
||||
###############################################################################
|
||||
install_dependencies() {
|
||||
if [ "$RUN_NPM_INSTALL" = "true" ]; then
|
||||
log_info "安装依赖..."
|
||||
|
||||
cd "$PROJECT_PATH"
|
||||
|
||||
# 检查 package.json 是否变化
|
||||
if git diff HEAD@{1} HEAD --name-only | grep -q "package.json"; then
|
||||
log_info "package.json 有变化,执行 npm install..."
|
||||
npm install || {
|
||||
log_error "依赖安装失败"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log_info "package.json 无变化,跳过 npm install"
|
||||
fi
|
||||
|
||||
log_success "依赖检查完成"
|
||||
else
|
||||
log_info "跳过依赖安装 (RUN_NPM_INSTALL=false)"
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:构建项目
|
||||
###############################################################################
|
||||
build_project() {
|
||||
log_info "构建项目..."
|
||||
|
||||
cd "$PROJECT_PATH"
|
||||
|
||||
# 执行构建
|
||||
npm run build || {
|
||||
log_error "构建失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 检查构建产物
|
||||
if [ ! -d "$PROJECT_PATH/build" ]; then
|
||||
log_error "构建产物不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "构建完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:备份当前版本
|
||||
###############################################################################
|
||||
backup_current_version() {
|
||||
log_info "备份当前版本..."
|
||||
|
||||
local timestamp=$(date +%Y%m%d-%H%M%S)
|
||||
local backup_path="$BACKUP_DIR/backup-$timestamp"
|
||||
|
||||
if [ -d "$PRODUCTION_PATH" ] && [ "$(ls -A $PRODUCTION_PATH)" ]; then
|
||||
mkdir -p "$backup_path"
|
||||
cp -r "$PRODUCTION_PATH"/* "$backup_path/" || {
|
||||
log_error "备份失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 创建符号链接指向当前版本
|
||||
ln -snf "$backup_path" "$BACKUP_DIR/current"
|
||||
|
||||
log_success "备份完成: $backup_path"
|
||||
echo "$backup_path"
|
||||
else
|
||||
log_warning "生产目录为空,跳过备份"
|
||||
echo "no-backup"
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:清理旧备份
|
||||
###############################################################################
|
||||
cleanup_old_backups() {
|
||||
log_info "清理旧备份..."
|
||||
|
||||
cd "$BACKUP_DIR"
|
||||
|
||||
# 获取所有备份目录(排除 current 符号链接)
|
||||
local backup_count=$(find . -maxdepth 1 -type d -name "backup-*" | wc -l)
|
||||
|
||||
if [ "$backup_count" -gt "$KEEP_BACKUPS" ]; then
|
||||
local to_delete=$((backup_count - KEEP_BACKUPS))
|
||||
log_info "当前有 $backup_count 个备份,保留 $KEEP_BACKUPS 个,删除 $to_delete 个"
|
||||
|
||||
find . -maxdepth 1 -type d -name "backup-*" | sort | head -n "$to_delete" | while read dir; do
|
||||
log_info "删除旧备份: $dir"
|
||||
rm -rf "$dir"
|
||||
done
|
||||
|
||||
log_success "旧备份清理完成"
|
||||
else
|
||||
log_info "当前有 $backup_count 个备份,无需清理"
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:部署到生产环境
|
||||
###############################################################################
|
||||
deploy_to_production() {
|
||||
log_info "部署到生产环境..."
|
||||
|
||||
# 清空生产目录
|
||||
log_info "清空生产目录: $PRODUCTION_PATH"
|
||||
rm -rf "$PRODUCTION_PATH"/*
|
||||
|
||||
# 复制构建产物
|
||||
log_info "复制构建产物..."
|
||||
cp -r "$PROJECT_PATH/build"/* "$PRODUCTION_PATH/" || {
|
||||
log_error "复制文件失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 设置权限
|
||||
chmod -R 755 "$PRODUCTION_PATH"
|
||||
|
||||
log_success "部署完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主函数
|
||||
###############################################################################
|
||||
main() {
|
||||
local start_time=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 服务器端部署脚本"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# 创建目录
|
||||
create_directories
|
||||
|
||||
# 检查 Git 仓库
|
||||
check_git_repo
|
||||
|
||||
# 切换分支
|
||||
checkout_branch
|
||||
|
||||
# 拉取最新代码
|
||||
pull_latest_code
|
||||
|
||||
# 获取提交信息
|
||||
get_commit_info
|
||||
|
||||
# 安装依赖
|
||||
install_dependencies
|
||||
|
||||
# 构建项目
|
||||
build_project
|
||||
|
||||
# 备份当前版本
|
||||
backup_path=$(backup_current_version)
|
||||
|
||||
# 部署到生产环境
|
||||
deploy_to_production
|
||||
|
||||
# 清理旧备份
|
||||
cleanup_old_backups
|
||||
|
||||
# 计算耗时
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
local minutes=$((duration / 60))
|
||||
local seconds=$((duration % 60))
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 部署成功!"
|
||||
echo "========================================"
|
||||
echo "提交: $COMMIT_HASH - $COMMIT_MESSAGE"
|
||||
echo "备份: $backup_path"
|
||||
echo "耗时: ${minutes}分${seconds}秒"
|
||||
echo ""
|
||||
|
||||
# 输出结果供本地脚本解析
|
||||
echo "DEPLOY_SUCCESS=true"
|
||||
echo "COMMIT_HASH=$COMMIT_HASH"
|
||||
echo "COMMIT_MESSAGE=$COMMIT_MESSAGE"
|
||||
echo "DEPLOY_DURATION=${minutes}分${seconds}秒"
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
234
scripts/notify-wechat.sh
Executable file
234
scripts/notify-wechat.sh
Executable file
@@ -0,0 +1,234 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 企业微信通知脚本
|
||||
# 用于发送部署成功/失败通知到企业微信群
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# 加载配置文件
|
||||
if [ -f "$PROJECT_ROOT/.env.deploy" ]; then
|
||||
source "$PROJECT_ROOT/.env.deploy"
|
||||
else
|
||||
echo -e "${YELLOW}警告: 配置文件 .env.deploy 不存在,跳过通知${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查是否启用通知
|
||||
if [ "$ENABLE_WECHAT_NOTIFY" != "true" ]; then
|
||||
echo "企业微信通知未启用"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查 Webhook URL
|
||||
if [ -z "$WECHAT_WEBHOOK_URL" ]; then
|
||||
echo -e "${YELLOW}警告: 未配置企业微信 Webhook URL${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
# 函数:发送文本消息
|
||||
###############################################################################
|
||||
send_text_message() {
|
||||
local content="$1"
|
||||
local mentioned_list="${2:-[]}"
|
||||
|
||||
local json_data=$(cat <<EOF
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "$content",
|
||||
"mentioned_list": $mentioned_list
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# 发送 HTTP 请求
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$json_data" \
|
||||
"$WECHAT_WEBHOOK_URL")
|
||||
|
||||
# 提取 HTTP 状态码和响应体
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -eq 200 ]; then
|
||||
echo -e "${GREEN}✓ 企业微信通知发送成功${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗ 企业微信通知发送失败 (HTTP $http_code)${NC}"
|
||||
echo "响应: $body"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:发送 Markdown 消息
|
||||
###############################################################################
|
||||
send_markdown_message() {
|
||||
local content="$1"
|
||||
|
||||
local json_data=$(cat <<EOF
|
||||
{
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": "$content"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# 发送 HTTP 请求
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$json_data" \
|
||||
"$WECHAT_WEBHOOK_URL")
|
||||
|
||||
# 提取 HTTP 状态码和响应体
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -eq 200 ]; then
|
||||
echo -e "${GREEN}✓ 企业微信通知发送成功${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗ 企业微信通知发送失败 (HTTP $http_code)${NC}"
|
||||
echo "响应: $body"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:部署成功通知
|
||||
###############################################################################
|
||||
notify_deploy_success() {
|
||||
local branch="$1"
|
||||
local commit="$2"
|
||||
local message="$3"
|
||||
local duration="$4"
|
||||
local operator="${5:-unknown}"
|
||||
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
local content="【生产环境部署成功】
|
||||
项目:vf_react
|
||||
环境:生产环境
|
||||
分支:$branch
|
||||
版本:$commit
|
||||
提交信息:$message
|
||||
部署时间:$timestamp
|
||||
部署耗时:$duration
|
||||
操作人:$operator
|
||||
访问地址:https://valuefrontier.cn"
|
||||
|
||||
# 处理 mentioned_list
|
||||
local mentioned_list="[]"
|
||||
if [ -n "$WECHAT_MENTIONED_LIST" ]; then
|
||||
if [ "$WECHAT_MENTIONED_LIST" = "@all" ]; then
|
||||
mentioned_list='["@all"]'
|
||||
else
|
||||
# 假设是逗号分隔的手机号或 userid
|
||||
mentioned_list="[\"$WECHAT_MENTIONED_LIST\"]"
|
||||
fi
|
||||
fi
|
||||
|
||||
send_text_message "$content" "$mentioned_list"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:部署失败通知
|
||||
###############################################################################
|
||||
notify_deploy_failure() {
|
||||
local branch="$1"
|
||||
local error_message="$2"
|
||||
local operator="${3:-unknown}"
|
||||
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
local content="【⚠️ 生产环境部署失败】
|
||||
项目:vf_react
|
||||
环境:生产环境
|
||||
分支:$branch
|
||||
失败原因:$error_message
|
||||
失败时间:$timestamp
|
||||
操作人:$operator
|
||||
已自动回滚到上一版本"
|
||||
|
||||
# 处理 mentioned_list
|
||||
local mentioned_list="[]"
|
||||
if [ -n "$WECHAT_MENTIONED_LIST" ]; then
|
||||
if [ "$WECHAT_MENTIONED_LIST" = "@all" ]; then
|
||||
mentioned_list='["@all"]'
|
||||
else
|
||||
mentioned_list="[\"$WECHAT_MENTIONED_LIST\"]"
|
||||
fi
|
||||
fi
|
||||
|
||||
send_text_message "$content" "$mentioned_list"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:回滚成功通知
|
||||
###############################################################################
|
||||
notify_rollback_success() {
|
||||
local version="$1"
|
||||
local operator="${2:-unknown}"
|
||||
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
local content="【版本回滚成功】
|
||||
项目:vf_react
|
||||
环境:生产环境
|
||||
回滚版本:$version
|
||||
回滚时间:$timestamp
|
||||
操作人:$operator"
|
||||
|
||||
send_text_message "$content"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主程序
|
||||
###############################################################################
|
||||
main() {
|
||||
local action="${1:-}"
|
||||
|
||||
case "$action" in
|
||||
success)
|
||||
notify_deploy_success "$2" "$3" "$4" "$5" "$6"
|
||||
;;
|
||||
failure)
|
||||
notify_deploy_failure "$2" "$3" "$4"
|
||||
;;
|
||||
rollback)
|
||||
notify_rollback_success "$2" "$3"
|
||||
;;
|
||||
test)
|
||||
send_text_message "企业微信通知测试消息\n发送时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {success|failure|rollback|test} [参数...]"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 success feature abc123 'feat: 新功能' '2分15秒' ubuntu"
|
||||
echo " $0 failure feature '构建失败' ubuntu"
|
||||
echo " $0 rollback backup-20250121-143020 ubuntu"
|
||||
echo " $0 test"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
187
scripts/rollback-from-local.sh
Executable file
187
scripts/rollback-from-local.sh
Executable file
@@ -0,0 +1,187 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 本地回滚脚本
|
||||
# 在本地运行,通过 SSH 连接服务器并执行回滚
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
###############################################################################
|
||||
# 函数:打印带颜色的消息
|
||||
###############################################################################
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
log_warning() { echo -e "${YELLOW}[⚠]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
|
||||
###############################################################################
|
||||
# 函数:加载配置文件
|
||||
###############################################################################
|
||||
load_config() {
|
||||
if [ ! -f "$PROJECT_ROOT/.env.deploy" ]; then
|
||||
log_error "配置文件不存在: $PROJECT_ROOT/.env.deploy"
|
||||
echo ""
|
||||
echo "请先运行以下命令进行配置:"
|
||||
echo " npm run deploy:setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$PROJECT_ROOT/.env.deploy"
|
||||
|
||||
if [ -z "$SERVER_HOST" ] || [ -z "$SERVER_USER" ]; then
|
||||
log_error "配置不完整,请检查 .env.deploy 文件"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "配置加载完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:列出可回滚的版本
|
||||
###############################################################################
|
||||
list_backup_versions() {
|
||||
echo ""
|
||||
echo "正在获取可用的备份版本..."
|
||||
echo ""
|
||||
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
# 上传回滚脚本
|
||||
scp $ssh_options -q \
|
||||
"$SCRIPT_DIR/rollback-on-server.sh" \
|
||||
"$SERVER_USER@$SERVER_HOST":/tmp/ || {
|
||||
log_error "上传回滚脚本失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 执行列表命令
|
||||
local env_vars="PRODUCTION_PATH=$PRODUCTION_PATH BACKUP_DIR=$BACKUP_DIR"
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "$env_vars bash /tmp/rollback-on-server.sh list"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:执行回滚
|
||||
###############################################################################
|
||||
execute_rollback() {
|
||||
local version_index="${1:-1}"
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 版本回滚工具 ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# 列出可用版本
|
||||
list_backup_versions
|
||||
|
||||
# 询问确认
|
||||
echo ""
|
||||
read -p "确认回滚到版本 #$version_index? (yes/no): " -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
|
||||
log_info "回滚已取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# SSH 选项
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
log_info "正在执行回滚..."
|
||||
echo ""
|
||||
|
||||
# 执行回滚命令
|
||||
local env_vars="PRODUCTION_PATH=$PRODUCTION_PATH BACKUP_DIR=$BACKUP_DIR"
|
||||
local rollback_output=$(ssh $ssh_options "$SERVER_USER@$SERVER_HOST" \
|
||||
"$env_vars bash /tmp/rollback-on-server.sh rollback $version_index" 2>&1)
|
||||
|
||||
if echo "$rollback_output" | grep -q "ROLLBACK_SUCCESS=true"; then
|
||||
# 提取回滚版本
|
||||
local rollback_version=$(echo "$rollback_output" | grep "ROLLBACK_VERSION=" | cut -d= -f2)
|
||||
|
||||
# 发送通知
|
||||
if [ "$ENABLE_WECHAT_NOTIFY" = "true" ]; then
|
||||
bash "$SCRIPT_DIR/notify-wechat.sh" rollback \
|
||||
"$rollback_version" \
|
||||
"$USER" || {
|
||||
log_warning "企业微信通知发送失败"
|
||||
}
|
||||
fi
|
||||
|
||||
# 显示结果
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 🎉 回滚成功! ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -e "${BOLD}回滚信息:${NC}"
|
||||
echo " 目标版本: $rollback_version"
|
||||
echo " 回滚时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
echo -e "${BOLD}访问地址:${NC}"
|
||||
echo " https://valuefrontier.cn"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
log_success "回滚完成"
|
||||
else
|
||||
log_error "回滚失败"
|
||||
echo "$rollback_output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 清理临时文件
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "rm -f /tmp/rollback-on-server.sh" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主函数
|
||||
###############################################################################
|
||||
main() {
|
||||
local action="${1:-rollback}"
|
||||
local version_index="${2:-1}"
|
||||
|
||||
# 加载配置
|
||||
load_config
|
||||
|
||||
case "$action" in
|
||||
list)
|
||||
list_backup_versions
|
||||
;;
|
||||
rollback|*)
|
||||
execute_rollback "$version_index"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 错误处理
|
||||
trap 'log_error "回滚过程中发生错误"; exit 1' ERR
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
176
scripts/rollback-on-server.sh
Executable file
176
scripts/rollback-on-server.sh
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 服务器端回滚脚本
|
||||
# 此脚本在服务器上执行,由本地回滚脚本远程调用
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
###############################################################################
|
||||
# 配置变量(通过环境变量传入)
|
||||
###############################################################################
|
||||
PRODUCTION_PATH="${PRODUCTION_PATH:-/var/www/valuefrontier.cn}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/home/ubuntu/deployments}"
|
||||
|
||||
###############################################################################
|
||||
# 函数:打印带颜色的消息
|
||||
###############################################################################
|
||||
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"; }
|
||||
|
||||
###############################################################################
|
||||
# 函数:列出可用的备份版本
|
||||
###############################################################################
|
||||
list_backups() {
|
||||
log_info "可用的备份版本:"
|
||||
echo ""
|
||||
|
||||
if [ ! -d "$BACKUP_DIR" ]; then
|
||||
log_error "备份目录不存在: $BACKUP_DIR"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd "$BACKUP_DIR"
|
||||
|
||||
# 获取所有备份目录,按时间倒序
|
||||
local backups=($(find . -maxdepth 1 -type d -name "backup-*" | sort -r))
|
||||
|
||||
if [ ${#backups[@]} -eq 0 ]; then
|
||||
log_warning "没有可用的备份版本"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local index=1
|
||||
for backup in "${backups[@]}"; do
|
||||
local backup_name=$(basename "$backup")
|
||||
local backup_time=$(echo "$backup_name" | sed 's/backup-//' | sed 's/-/ /')
|
||||
local is_current=""
|
||||
|
||||
# 检查是否是当前版本
|
||||
if [ -L "$BACKUP_DIR/current" ]; then
|
||||
local current_link=$(readlink "$BACKUP_DIR/current")
|
||||
if [ "$current_link" = "$backup" ] || [ "$current_link" = "$BACKUP_DIR/$backup_name" ]; then
|
||||
is_current=" ${GREEN}[当前版本]${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e " $index. $backup_name ($backup_time)$is_current"
|
||||
((index++))
|
||||
done
|
||||
|
||||
echo ""
|
||||
return 0
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:回滚到指定版本
|
||||
###############################################################################
|
||||
rollback_to_version() {
|
||||
local version_index="${1:-1}"
|
||||
|
||||
log_info "开始回滚到版本 #$version_index..."
|
||||
|
||||
if [ ! -d "$BACKUP_DIR" ]; then
|
||||
log_error "备份目录不存在: $BACKUP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$BACKUP_DIR"
|
||||
|
||||
# 获取所有备份目录,按时间倒序
|
||||
local backups=($(find . -maxdepth 1 -type d -name "backup-*" | sort -r))
|
||||
|
||||
if [ ${#backups[@]} -eq 0 ]; then
|
||||
log_error "没有可用的备份版本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查索引是否有效
|
||||
if [ "$version_index" -lt 1 ] || [ "$version_index" -gt "${#backups[@]}" ]; then
|
||||
log_error "无效的版本索引: $version_index (可用范围: 1-${#backups[@]})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 获取目标备份
|
||||
local target_index=$((version_index - 1))
|
||||
local target_backup="${backups[$target_index]}"
|
||||
local backup_name=$(basename "$target_backup")
|
||||
|
||||
log_info "目标版本: $backup_name"
|
||||
|
||||
# 检查备份是否存在
|
||||
if [ ! -d "$target_backup" ]; then
|
||||
log_error "备份版本不存在: $target_backup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 清空生产目录
|
||||
log_info "清空生产目录: $PRODUCTION_PATH"
|
||||
rm -rf "$PRODUCTION_PATH"/*
|
||||
|
||||
# 恢复备份
|
||||
log_info "恢复备份文件..."
|
||||
cp -r "$target_backup"/* "$PRODUCTION_PATH/" || {
|
||||
log_error "恢复备份失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 设置权限
|
||||
chmod -R 755 "$PRODUCTION_PATH"
|
||||
|
||||
# 更新 current 符号链接
|
||||
ln -snf "$target_backup" "$BACKUP_DIR/current"
|
||||
|
||||
log_success "回滚完成"
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 回滚成功!"
|
||||
echo "========================================"
|
||||
echo "目标版本: $backup_name"
|
||||
echo ""
|
||||
|
||||
# 输出结果供本地脚本解析
|
||||
echo "ROLLBACK_SUCCESS=true"
|
||||
echo "ROLLBACK_VERSION=$backup_name"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主函数
|
||||
###############################################################################
|
||||
main() {
|
||||
local action="${1:-list}"
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 服务器端回滚脚本"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
case "$action" in
|
||||
list)
|
||||
list_backups
|
||||
;;
|
||||
rollback)
|
||||
local version_index="${2:-1}"
|
||||
rollback_to_version "$version_index"
|
||||
;;
|
||||
*)
|
||||
log_error "未知操作: $action"
|
||||
echo "用法: $0 {list|rollback} [version_index]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
407
scripts/setup-deployment.sh
Executable file
407
scripts/setup-deployment.sh
Executable file
@@ -0,0 +1,407 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 部署配置向导
|
||||
# 首次使用时运行,引导用户完成部署配置
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
CONFIG_FILE="$PROJECT_ROOT/.env.deploy"
|
||||
EXAMPLE_FILE="$PROJECT_ROOT/.env.deploy.example"
|
||||
|
||||
###############################################################################
|
||||
# 函数:打印带颜色的消息
|
||||
###############################################################################
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
log_warning() { echo -e "${YELLOW}[⚠]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
log_step() { echo -e "${CYAN}${BOLD}[$1]${NC} $2"; }
|
||||
|
||||
###############################################################################
|
||||
# 函数:读取用户输入(带默认值)
|
||||
###############################################################################
|
||||
read_input() {
|
||||
local prompt="$1"
|
||||
local default="$2"
|
||||
local result
|
||||
|
||||
if [ -n "$default" ]; then
|
||||
read -p "$prompt [$default]: " result
|
||||
echo "${result:-$default}"
|
||||
else
|
||||
read -p "$prompt: " result
|
||||
echo "$result"
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:读取密码(隐藏输入)
|
||||
###############################################################################
|
||||
read_password() {
|
||||
local prompt="$1"
|
||||
local result
|
||||
|
||||
read -sp "$prompt: " result
|
||||
echo ""
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:测试 SSH 连接
|
||||
###############################################################################
|
||||
test_ssh() {
|
||||
local host="$1"
|
||||
local user="$2"
|
||||
local port="$3"
|
||||
local key_path="$4"
|
||||
|
||||
local ssh_options="-o ConnectTimeout=10 -o BatchMode=yes"
|
||||
|
||||
if [ -n "$key_path" ]; then
|
||||
ssh_options="$ssh_options -i $key_path"
|
||||
fi
|
||||
|
||||
if [ -n "$port" ] && [ "$port" != "22" ]; then
|
||||
ssh_options="$ssh_options -p $port"
|
||||
fi
|
||||
|
||||
ssh $ssh_options "$user@$host" "echo 'SSH 连接测试成功'" 2>/dev/null
|
||||
return $?
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:测试企业微信 Webhook
|
||||
###############################################################################
|
||||
test_wechat_webhook() {
|
||||
local webhook_url="$1"
|
||||
|
||||
local test_message='{"msgtype":"text","text":{"content":"企业微信通知测试\n发送时间: '$(date +"%Y-%m-%d %H:%M:%S")'"}}'
|
||||
|
||||
local response=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$test_message" \
|
||||
"$webhook_url")
|
||||
|
||||
local http_code=$(echo "$response" | tail -n1)
|
||||
|
||||
if [ "$http_code" -eq 200 ]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:显示欢迎信息
|
||||
###############################################################################
|
||||
show_welcome() {
|
||||
clear
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ VF React 部署配置向导 ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "本向导将帮助您完成以下配置:"
|
||||
echo " 1. 服务器连接配置 (SSH)"
|
||||
echo " 2. 部署路径配置"
|
||||
echo " 3. 企业微信通知配置 (可选)"
|
||||
echo " 4. 初始化服务器环境"
|
||||
echo ""
|
||||
read -p "按 Enter 键继续..."
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:配置服务器信息
|
||||
###############################################################################
|
||||
configure_server() {
|
||||
log_step "1/4" "服务器配置"
|
||||
echo ""
|
||||
|
||||
# 服务器地址
|
||||
SERVER_HOST=$(read_input "请输入服务器 IP 或域名")
|
||||
while [ -z "$SERVER_HOST" ]; do
|
||||
log_error "服务器地址不能为空"
|
||||
SERVER_HOST=$(read_input "请输入服务器 IP 或域名")
|
||||
done
|
||||
|
||||
# SSH 用户名
|
||||
SERVER_USER=$(read_input "请输入 SSH 用户名" "ubuntu")
|
||||
|
||||
# SSH 端口
|
||||
SERVER_PORT=$(read_input "请输入 SSH 端口" "22")
|
||||
|
||||
# SSH 密钥路径
|
||||
local default_key="$HOME/.ssh/id_rsa"
|
||||
if [ -f "$default_key" ]; then
|
||||
log_info "检测到 SSH 密钥: $default_key"
|
||||
local use_default=$(read_input "是否使用该密钥? (y/n)" "y")
|
||||
if [ "$use_default" = "y" ] || [ "$use_default" = "Y" ]; then
|
||||
SSH_KEY_PATH="$default_key"
|
||||
else
|
||||
SSH_KEY_PATH=$(read_input "请输入 SSH 密钥路径")
|
||||
fi
|
||||
else
|
||||
SSH_KEY_PATH=$(read_input "请输入 SSH 密钥路径 (留空使用默认)")
|
||||
fi
|
||||
|
||||
# 测试 SSH 连接
|
||||
echo ""
|
||||
log_info "正在测试 SSH 连接..."
|
||||
if test_ssh "$SERVER_HOST" "$SERVER_USER" "$SERVER_PORT" "$SSH_KEY_PATH"; then
|
||||
log_success "SSH 连接测试成功"
|
||||
else
|
||||
log_error "SSH 连接测试失败"
|
||||
echo ""
|
||||
echo "请检查:"
|
||||
echo " 1. 服务器地址是否正确"
|
||||
echo " 2. SSH 用户名和端口是否正确"
|
||||
echo " 3. SSH 密钥是否配置正确"
|
||||
echo ""
|
||||
read -p "是否继续配置? (y/n): " continue_setup
|
||||
if [ "$continue_setup" != "y" ] && [ "$continue_setup" != "Y" ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:配置部署路径
|
||||
###############################################################################
|
||||
configure_paths() {
|
||||
log_step "2/4" "部署路径配置"
|
||||
echo ""
|
||||
|
||||
# Git 仓库路径
|
||||
REMOTE_PROJECT_PATH=$(read_input "Git 仓库路径" "/home/ubuntu/vf_react")
|
||||
|
||||
# 生产环境路径
|
||||
PRODUCTION_PATH=$(read_input "生产环境路径" "/var/www/valuefrontier.cn")
|
||||
|
||||
# 备份目录
|
||||
BACKUP_DIR=$(read_input "备份目录" "/home/ubuntu/deployments")
|
||||
|
||||
# 日志目录
|
||||
LOG_DIR=$(read_input "日志目录" "/home/ubuntu/deploy-logs")
|
||||
|
||||
# 部署分支
|
||||
DEPLOY_BRANCH=$(read_input "部署分支" "feature")
|
||||
|
||||
# 保留备份数量
|
||||
KEEP_BACKUPS=$(read_input "保留备份数量" "5")
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:配置企业微信通知
|
||||
###############################################################################
|
||||
configure_wechat() {
|
||||
log_step "3/4" "企业微信通知配置"
|
||||
echo ""
|
||||
|
||||
local enable_notify=$(read_input "是否启用企业微信通知? (y/n)" "n")
|
||||
|
||||
if [ "$enable_notify" = "y" ] || [ "$enable_notify" = "Y" ]; then
|
||||
ENABLE_WECHAT_NOTIFY="true"
|
||||
|
||||
echo ""
|
||||
echo "请按以下步骤获取企业微信 Webhook URL:"
|
||||
echo " 1. 打开企业微信群聊"
|
||||
echo " 2. 点击群设置 -> 群机器人 -> 添加机器人"
|
||||
echo " 3. 复制 Webhook URL"
|
||||
echo ""
|
||||
|
||||
WECHAT_WEBHOOK_URL=$(read_input "请输入企业微信 Webhook URL")
|
||||
|
||||
if [ -n "$WECHAT_WEBHOOK_URL" ]; then
|
||||
log_info "正在测试企业微信通知..."
|
||||
if test_wechat_webhook "$WECHAT_WEBHOOK_URL"; then
|
||||
log_success "企业微信通知测试成功"
|
||||
else
|
||||
log_warning "企业微信通知测试失败,请检查 Webhook URL"
|
||||
fi
|
||||
fi
|
||||
|
||||
WECHAT_MENTIONED_LIST=$(read_input "提及用户 (手机号/userid,留空不提及)" "")
|
||||
else
|
||||
ENABLE_WECHAT_NOTIFY="false"
|
||||
WECHAT_WEBHOOK_URL=""
|
||||
WECHAT_MENTIONED_LIST=""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:初始化服务器环境
|
||||
###############################################################################
|
||||
initialize_server() {
|
||||
log_step "4/4" "初始化服务器环境"
|
||||
echo ""
|
||||
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ] && [ "$SERVER_PORT" != "22" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
log_info "正在创建服务器目录..."
|
||||
|
||||
# 创建必要的目录
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "
|
||||
mkdir -p $BACKUP_DIR
|
||||
mkdir -p $LOG_DIR
|
||||
mkdir -p $PRODUCTION_PATH
|
||||
" || {
|
||||
log_error "创建目录失败"
|
||||
return 1
|
||||
}
|
||||
|
||||
log_success "服务器目录创建完成"
|
||||
|
||||
# 设置脚本执行权限
|
||||
log_info "设置脚本执行权限..."
|
||||
chmod +x "$SCRIPT_DIR"/*.sh
|
||||
|
||||
log_success "服务器环境初始化完成"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:保存配置文件
|
||||
###############################################################################
|
||||
save_config() {
|
||||
log_info "保存配置文件..."
|
||||
|
||||
# 如果配置文件已存在,先备份
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
local backup_file="$CONFIG_FILE.backup.$(date +%Y%m%d%H%M%S)"
|
||||
cp "$CONFIG_FILE" "$backup_file"
|
||||
log_info "已备份原配置文件: $backup_file"
|
||||
fi
|
||||
|
||||
# 从示例文件复制
|
||||
if [ -f "$EXAMPLE_FILE" ]; then
|
||||
cp "$EXAMPLE_FILE" "$CONFIG_FILE"
|
||||
else
|
||||
touch "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# 写入配置
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
# 部署配置文件
|
||||
# 由 setup-deployment.sh 自动生成
|
||||
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
SERVER_HOST=$SERVER_HOST
|
||||
SERVER_USER=$SERVER_USER
|
||||
SERVER_PORT=$SERVER_PORT
|
||||
SSH_KEY_PATH=$SSH_KEY_PATH
|
||||
|
||||
# ==================== 路径配置 ====================
|
||||
REMOTE_PROJECT_PATH=$REMOTE_PROJECT_PATH
|
||||
PRODUCTION_PATH=$PRODUCTION_PATH
|
||||
BACKUP_DIR=$BACKUP_DIR
|
||||
LOG_DIR=$LOG_DIR
|
||||
|
||||
# ==================== Git 配置 ====================
|
||||
DEPLOY_BRANCH=$DEPLOY_BRANCH
|
||||
|
||||
# ==================== 备份配置 ====================
|
||||
KEEP_BACKUPS=$KEEP_BACKUPS
|
||||
|
||||
# ==================== 企业微信通知配置 ====================
|
||||
ENABLE_WECHAT_NOTIFY=$ENABLE_WECHAT_NOTIFY
|
||||
WECHAT_WEBHOOK_URL=$WECHAT_WEBHOOK_URL
|
||||
WECHAT_MENTIONED_LIST=$WECHAT_MENTIONED_LIST
|
||||
|
||||
# ==================== 部署配置 ====================
|
||||
RUN_NPM_INSTALL=true
|
||||
RUN_NPM_TEST=false
|
||||
BUILD_COMMAND=npm run build
|
||||
|
||||
# ==================== 高级配置 ====================
|
||||
SSH_TIMEOUT=30
|
||||
DEPLOY_TIMEOUT=600
|
||||
EOF
|
||||
|
||||
log_success "配置文件已保存: $CONFIG_FILE"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:显示完成信息
|
||||
###############################################################################
|
||||
show_completion() {
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ✓ 配置完成! ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -e "${BOLD}配置信息:${NC}"
|
||||
echo " 服务器: $SERVER_USER@$SERVER_HOST:$SERVER_PORT"
|
||||
echo " Git 仓库: $REMOTE_PROJECT_PATH"
|
||||
echo " 生产环境: $PRODUCTION_PATH"
|
||||
echo " 部署分支: $DEPLOY_BRANCH"
|
||||
echo " 企业微信通知: $([ "$ENABLE_WECHAT_NOTIFY" = "true" ] && echo "已启用" || echo "未启用")"
|
||||
echo ""
|
||||
echo -e "${BOLD}接下来您可以:${NC}"
|
||||
echo " • 部署到生产环境: ${GREEN}npm run deploy${NC}"
|
||||
echo " • 查看备份版本: ${GREEN}npm run rollback -- list${NC}"
|
||||
echo " • 回滚到上一版本: ${GREEN}npm run rollback${NC}"
|
||||
echo " • 修改配置文件: ${GREEN}.env.deploy${NC}"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主函数
|
||||
###############################################################################
|
||||
main() {
|
||||
# 显示欢迎信息
|
||||
show_welcome
|
||||
|
||||
# 配置服务器
|
||||
configure_server
|
||||
|
||||
# 配置路径
|
||||
configure_paths
|
||||
|
||||
# 配置企业微信
|
||||
configure_wechat
|
||||
|
||||
# 初始化服务器环境
|
||||
initialize_server
|
||||
|
||||
# 保存配置
|
||||
save_config
|
||||
|
||||
# 显示完成信息
|
||||
show_completion
|
||||
}
|
||||
|
||||
# 错误处理
|
||||
trap 'log_error "配置过程中发生错误"; exit 1' ERR
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
77
src/App.js
77
src/App.js
@@ -47,6 +47,7 @@ import { NotificationProvider, useNotification } from "contexts/NotificationCont
|
||||
|
||||
// Components
|
||||
import ProtectedRoute from "components/ProtectedRoute";
|
||||
import ProtectedRouteRedirect from "components/ProtectedRouteRedirect";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import AuthModalManager from "components/Auth/AuthModalManager";
|
||||
import NotificationContainer from "components/NotificationContainer";
|
||||
@@ -183,15 +184,66 @@ function AppContent() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 事件详情独立页面路由 (不经 Admin 布局) */}
|
||||
<Route path="event-detail/:eventId" element={<EventDetail />} />
|
||||
{/* 事件详情独立页面路由 - 需要登录(跳转模式) */}
|
||||
<Route
|
||||
path="event-detail/:eventId"
|
||||
element={
|
||||
<ProtectedRouteRedirect>
|
||||
<EventDetail />
|
||||
</ProtectedRouteRedirect>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 公司相关页面 */}
|
||||
<Route path="forecast-report" element={<ForecastReport />} />
|
||||
<Route path="Financial" element={<FinancialPanorama />} />
|
||||
<Route path="company" element={<CompanyIndex />} />
|
||||
<Route path="company/:code" element={<CompanyIndex />} />
|
||||
<Route path="market-data" element={<MarketDataView />} />
|
||||
{/* 财报预测 - 需要登录(跳转模式) */}
|
||||
<Route
|
||||
path="forecast-report"
|
||||
element={
|
||||
<ProtectedRouteRedirect>
|
||||
<ForecastReport />
|
||||
</ProtectedRouteRedirect>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 财务全景 - 需要登录(弹窗模式) */}
|
||||
<Route
|
||||
path="Financial"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FinancialPanorama />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 公司页面 - 需要登录(弹窗模式) */}
|
||||
<Route
|
||||
path="company"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CompanyIndex />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 公司详情 - 需要登录(跳转模式) */}
|
||||
<Route
|
||||
path="company/:code"
|
||||
element={
|
||||
<ProtectedRouteRedirect>
|
||||
<CompanyIndex />
|
||||
</ProtectedRouteRedirect>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 市场数据 - 需要登录(弹窗模式) */}
|
||||
<Route
|
||||
path="market-data"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MarketDataView />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* 认证页面路由 - 不使用 MainLayout */}
|
||||
@@ -238,7 +290,16 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChakraProvider theme={theme}>
|
||||
<ChakraProvider
|
||||
theme={theme}
|
||||
toastOptions={{
|
||||
defaultOptions: {
|
||||
position: 'top',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<AuthModalProvider>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { FaLock, FaWeixin } from "react-icons/fa";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useNotification } from "../../contexts/NotificationContext";
|
||||
import { authService } from "../../services/authService";
|
||||
import AuthHeader from './AuthHeader';
|
||||
import VerificationCodeInput from './VerificationCodeInput';
|
||||
@@ -66,6 +67,7 @@ export default function AuthFormContent() {
|
||||
const navigate = useNavigate();
|
||||
const { checkSession } = useAuth();
|
||||
const { handleLoginSuccess } = useAuthModal();
|
||||
const { showWelcomeGuide } = useNotification();
|
||||
|
||||
// 使用统一配置
|
||||
const config = AUTH_CONFIG;
|
||||
@@ -85,7 +87,7 @@ export default function AuthFormContent() {
|
||||
// 响应式布局配置
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
|
||||
const stackSpacing = useBreakpointValue({ base: 4, md: 8 });
|
||||
const stackSpacing = useBreakpointValue({ base: 4, md: 2 }); // ✅ 桌面端从32px减至8px,更紧凑
|
||||
|
||||
// 表单数据
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -202,10 +204,23 @@ export default function AuthFormContent() {
|
||||
throw new Error(data.error || '发送验证码失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// ❌ 移除错误 toast,仅 console 输出
|
||||
logger.api.error('POST', '/api/auth/send-verification-code', error, {
|
||||
credential: credential.substring(0, 3) + '****' + credential.substring(7)
|
||||
});
|
||||
|
||||
// ✅ 显示错误提示给用户
|
||||
toast({
|
||||
id: 'send-code-error',
|
||||
title: "发送验证码失败",
|
||||
description: error.message || "请稍后重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
containerStyle: {
|
||||
zIndex: 10000,
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setSendingCode(false);
|
||||
@@ -219,7 +234,7 @@ export default function AuthFormContent() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { phone, verificationCode, nickname } = formData;
|
||||
const { phone, verificationCode } = formData;
|
||||
|
||||
// 表单验证
|
||||
if (!phone || !verificationCode) {
|
||||
@@ -321,16 +336,37 @@ export default function AuthFormContent() {
|
||||
handleLoginSuccess({ phone });
|
||||
}, config.features.successDelay);
|
||||
}
|
||||
|
||||
// ⚡ 延迟 10 秒显示权限引导(温和、非侵入)
|
||||
setTimeout(() => {
|
||||
if (showWelcomeGuide) {
|
||||
logger.info('AuthFormContent', '显示欢迎引导');
|
||||
showWelcomeGuide();
|
||||
}
|
||||
}, 10000);
|
||||
} else {
|
||||
throw new Error(data.error || `${config.errorTitle}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// ❌ 移除错误 toast,仅 console 输出
|
||||
const { phone, verificationCode } = formData;
|
||||
logger.error('AuthFormContent', 'handleSubmit', error, {
|
||||
phone: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
|
||||
hasVerificationCode: !!verificationCode
|
||||
});
|
||||
|
||||
// ✅ 显示错误提示给用户
|
||||
toast({
|
||||
id: 'auth-verification-error',
|
||||
title: config.errorTitle,
|
||||
description: error.message || "请检查验证码是否正确",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
containerStyle: {
|
||||
zIndex: 10000,
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
@@ -465,8 +501,8 @@ export default function AuthFormContent() {
|
||||
|
||||
{/* 桌面端:右侧二维码扫描 */}
|
||||
{!isMobile && (
|
||||
<Box flex="1">
|
||||
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
|
||||
<Box flex={{ base: "1", md: "0 0 auto" }}> {/* ✅ 桌面端让右侧自适应宽度 */}
|
||||
<Center width="100%"> {/* ✅ 移除bg和p,WechatRegister自带白色背景和padding */}
|
||||
<WechatRegister />
|
||||
</Center>
|
||||
</Box>
|
||||
|
||||
@@ -3,14 +3,18 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Center,
|
||||
Text,
|
||||
Heading,
|
||||
Icon,
|
||||
useToast,
|
||||
Spinner
|
||||
} from "@chakra-ui/react";
|
||||
import { FaQrcode } from "react-icons/fa";
|
||||
import { FiAlertCircle } from "react-icons/fi";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { authService, WECHAT_STATUS } from "../../services/authService";
|
||||
import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/authService";
|
||||
import { logger } from "../../utils/logger";
|
||||
|
||||
// 配置常量
|
||||
@@ -18,6 +22,28 @@ const POLL_INTERVAL = 2000; // 轮询间隔:2秒
|
||||
const BACKUP_POLL_INTERVAL = 3000; // 备用轮询间隔:3秒
|
||||
const QR_CODE_TIMEOUT = 300000; // 二维码超时:5分钟
|
||||
|
||||
/**
|
||||
* 获取状态文字颜色
|
||||
*/
|
||||
const getStatusColor = (status) => {
|
||||
switch(status) {
|
||||
case WECHAT_STATUS.WAITING: return "gray.600"; // ✅ 灰色文字
|
||||
case WECHAT_STATUS.SCANNED: return "green.600"; // ✅ 绿色文字
|
||||
case WECHAT_STATUS.AUTHORIZED: return "green.600"; // ✅ 绿色文字
|
||||
case WECHAT_STATUS.EXPIRED: return "orange.600"; // ✅ 橙色文字
|
||||
case WECHAT_STATUS.LOGIN_SUCCESS: return "green.600"; // ✅ 绿色文字
|
||||
case WECHAT_STATUS.REGISTER_SUCCESS: return "green.600";
|
||||
default: return "gray.600";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态文字
|
||||
*/
|
||||
const getStatusText = (status) => {
|
||||
return STATUS_MESSAGES[status] || "点击按钮获取二维码";
|
||||
};
|
||||
|
||||
export default function WechatRegister() {
|
||||
// 状态管理
|
||||
const [wechatAuthUrl, setWechatAuthUrl] = useState("");
|
||||
@@ -141,6 +167,18 @@ export default function WechatRegister() {
|
||||
// 处理成功状态
|
||||
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
|
||||
clearTimers(); // 停止轮询
|
||||
|
||||
// 显示"扫码成功,登录中"提示
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "扫码成功",
|
||||
description: "正在登录,请稍候...",
|
||||
status: "info",
|
||||
duration: 2000,
|
||||
isClosable: false,
|
||||
});
|
||||
}
|
||||
|
||||
await handleLoginSuccess(wechatSessionId, status);
|
||||
}
|
||||
// 处理过期状态
|
||||
@@ -296,9 +334,9 @@ export default function WechatRegister() {
|
||||
* 测量容器尺寸并计算缩放比例
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
// 微信授权页面的原始尺寸
|
||||
const ORIGINAL_WIDTH = 600;
|
||||
const ORIGINAL_HEIGHT = 800;
|
||||
// 微信授权页面的原始尺寸(需要与iframe实际尺寸匹配)
|
||||
const ORIGINAL_WIDTH = 300; // ✅ 修正:与iframe width匹配
|
||||
const ORIGINAL_HEIGHT = 350; // ✅ 修正:与iframe height匹配
|
||||
|
||||
const calculateScale = () => {
|
||||
if (containerRef.current) {
|
||||
@@ -345,116 +383,145 @@ export default function WechatRegister() {
|
||||
// };
|
||||
|
||||
return (
|
||||
<VStack spacing={2} display="flex" alignItems="center" justifyContent="center">
|
||||
{wechatStatus === WECHAT_STATUS.WAITING ? (
|
||||
<>
|
||||
<Text fontSize="lg" fontWeight="bold" color="gray.700" whiteSpace="nowrap">
|
||||
微信扫码
|
||||
</Text>
|
||||
<VStack
|
||||
spacing={0} // ✅ 手动控制间距
|
||||
alignItems="stretch" // ✅ 拉伸对齐
|
||||
justifyContent="flex-start" // ✅ 顶部对齐(标题对齐关键)
|
||||
width="auto" // ✅ 自适应宽度
|
||||
>
|
||||
{/* ========== 标题区域 ========== */}
|
||||
<Heading
|
||||
size="md" // ✅ 16px,与左侧"登陆/注册"一致
|
||||
fontWeight="600"
|
||||
color="gray.800"
|
||||
textAlign="center"
|
||||
mb={3} // 12px底部间距
|
||||
>
|
||||
微信扫码
|
||||
</Heading>
|
||||
|
||||
{/* ========== 二维码区域 ========== */}
|
||||
<Box
|
||||
ref={containerRef}
|
||||
position="relative"
|
||||
width="230px" // ✅ 升级尺寸
|
||||
height="230px"
|
||||
mx="auto"
|
||||
overflow="hidden"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="gray.200"
|
||||
bg="gray.50"
|
||||
boxShadow="sm" // ✅ 添加轻微阴影
|
||||
>
|
||||
{wechatStatus === WECHAT_STATUS.WAITING ? (
|
||||
/* 已获取二维码:显示iframe */
|
||||
<iframe
|
||||
src={wechatAuthUrl}
|
||||
title="微信扫码登录"
|
||||
width="300"
|
||||
height="350"
|
||||
style={{
|
||||
border: 'none',
|
||||
transform: 'scale(0.77) translateY(-20px)', // ✅ 裁剪顶部logo
|
||||
transformOrigin: 'top left',
|
||||
marginLeft: '-5px'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
/* 未获取:显示占位符 */
|
||||
<Center width="100%" height="100%" flexDirection="column">
|
||||
<Icon as={FaQrcode} w={16} h={16} color="gray.300" mb={4} />
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
onClick={handleGetQRCodeClick}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED ? "刷新二维码" : "获取二维码"}
|
||||
</Button>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* ========== 过期蒙层 ========== */}
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED && (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
position="relative"
|
||||
width="150px"
|
||||
height="100px"
|
||||
maxWidth="100%"
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bg="rgba(0,0,0,0.6)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
backdropFilter="blur(4px)"
|
||||
>
|
||||
<iframe
|
||||
src={wechatAuthUrl}
|
||||
title="微信扫码登录"
|
||||
width="300"
|
||||
height="350"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center center'
|
||||
}}
|
||||
/>
|
||||
<VStack spacing={2}>
|
||||
<Icon as={FiAlertCircle} w={8} h={8} color="white" />
|
||||
<Text color="white" fontSize="sm">二维码已过期</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="whiteAlpha"
|
||||
onClick={handleGetQRCodeClick}
|
||||
>
|
||||
点击刷新
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
{/* {renderStatusText()} */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fontSize="lg" fontWeight="bold" color="gray.700" whiteSpace="nowrap">
|
||||
微信扫码
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ========== 状态指示器 ========== */}
|
||||
{wechatStatus !== WECHAT_STATUS.NONE && (
|
||||
<Text
|
||||
mt={3}
|
||||
fontSize="sm"
|
||||
fontWeight="500" // ✅ 半粗体
|
||||
textAlign="center"
|
||||
color={getStatusColor(wechatStatus)} // ✅ 根据状态显示不同颜色
|
||||
>
|
||||
{getStatusText(wechatStatus)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* ========== Mock 模式控制按钮(仅开发环境) ========== */}
|
||||
{process.env.REACT_APP_ENABLE_MOCK === 'true' && wechatStatus === WECHAT_STATUS.WAITING && wechatSessionId && (
|
||||
<Box mt={3} pt={3} borderTop="1px solid" borderColor="gray.200">
|
||||
<Button
|
||||
size="xs"
|
||||
width="100%"
|
||||
colorScheme="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (window.mockWechatScan) {
|
||||
const success = window.mockWechatScan(wechatSessionId);
|
||||
if (success) {
|
||||
toast({
|
||||
title: "Mock 模拟触发成功",
|
||||
description: "正在模拟扫码登录...",
|
||||
status: "info",
|
||||
duration: 2000,
|
||||
isClosable: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "Mock API 未加载",
|
||||
description: "请刷新页面重试",
|
||||
status: "warning",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
leftIcon={<Text fontSize="lg">🧪</Text>}
|
||||
>
|
||||
模拟扫码成功(测试)
|
||||
</Button>
|
||||
<Text fontSize="xs" color="gray.400" textAlign="center" mt={1}>
|
||||
开发模式 | 自动登录: 5秒
|
||||
</Text>
|
||||
|
||||
<Box
|
||||
position="relative"
|
||||
width="150px"
|
||||
height="100px"
|
||||
maxWidth="100%"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 灰色二维码底图 - 始终显示 */}
|
||||
<Icon as={FaQrcode} w={24} h={24} color="gray.300" />
|
||||
|
||||
{/* 加载动画 */}
|
||||
{isLoading && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Spinner
|
||||
size="lg"
|
||||
color="green.500"
|
||||
thickness="4px"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 显示获取/刷新二维码按钮 */}
|
||||
{(wechatStatus === WECHAT_STATUS.NONE || wechatStatus === WECHAT_STATUS.EXPIRED) && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="rgba(255, 255, 255, 0.3)"
|
||||
backdropFilter="blur(2px)"
|
||||
>
|
||||
<VStack spacing={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="green"
|
||||
size="sm"
|
||||
onClick={handleGetQRCodeClick}
|
||||
isLoading={isLoading}
|
||||
leftIcon={<Icon as={FaQrcode} />}
|
||||
_hover={{ bg: "green.50" }}
|
||||
>
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED ? "点击刷新" : "获取二维码"}
|
||||
</Button>
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
二维码已过期
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 扫码状态提示 */}
|
||||
{/* {renderStatusText()} */}
|
||||
</>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
|
||||
38
src/components/ProtectedRouteRedirect.js
Normal file
38
src/components/ProtectedRouteRedirect.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/components/ProtectedRouteRedirect.js - 跳转版本
|
||||
// 未登录时跳转到首页,用于三级页面(详情页)
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Box, VStack, Spinner, Text } from '@chakra-ui/react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const ProtectedRouteRedirect = ({ children }) => {
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
|
||||
// 显示加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
height="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="gray.50"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text fontSize="lg" color="gray.600">正在验证登录状态...</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 未登录,直接跳转到首页
|
||||
if (!isAuthenticated || !user) {
|
||||
return <Navigate to="/home" replace />;
|
||||
}
|
||||
|
||||
// 已登录,正常渲染子组件
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRouteRedirect;
|
||||
@@ -12,7 +12,8 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useToast, Box, HStack, Text, Button, CloseButton } from '@chakra-ui/react';
|
||||
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
|
||||
import { BellIcon } from '@chakra-ui/icons';
|
||||
import { logger } from '../utils/logger';
|
||||
import socket, { SOCKET_TYPE } from '../services/socket';
|
||||
import notificationSound from '../assets/sounds/notification.wav';
|
||||
@@ -20,6 +21,7 @@ import { browserNotificationService } from '../services/browserNotificationServi
|
||||
import { notificationMetricsService } from '../services/notificationMetricsService';
|
||||
import { notificationHistoryService } from '../services/notificationHistoryService';
|
||||
import { PRIORITY_LEVELS, NOTIFICATION_CONFIG, NOTIFICATION_TYPES } from '../constants/notificationTypes';
|
||||
import { usePermissionGuide, GUIDE_TYPES } from '../hooks/usePermissionGuide';
|
||||
|
||||
// 连接状态枚举
|
||||
const CONNECTION_STATUS = {
|
||||
@@ -59,6 +61,9 @@ export const NotificationProvider = ({ children }) => {
|
||||
const audioRef = useRef(null);
|
||||
const reconnectedTimerRef = useRef(null); // 用于自动消失 RECONNECTED 状态
|
||||
|
||||
// ⚡ 使用权限引导管理 Hook
|
||||
const { shouldShowGuide, markGuideAsShown } = usePermissionGuide();
|
||||
|
||||
// 初始化音频
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -160,6 +165,127 @@ export const NotificationProvider = ({ children }) => {
|
||||
return permission;
|
||||
}, [toast]);
|
||||
|
||||
/**
|
||||
* ⚡ 显示权限引导(通用方法)
|
||||
* @param {string} guideType - 引导类型
|
||||
* @param {object} options - 引导选项
|
||||
*/
|
||||
const showPermissionGuide = useCallback((guideType, options = {}) => {
|
||||
// 检查是否应该显示引导
|
||||
if (!shouldShowGuide(guideType)) {
|
||||
logger.debug('NotificationContext', 'Guide already shown, skipping', { guideType });
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查权限状态:只在未授权时显示引导
|
||||
if (browserPermission === 'granted') {
|
||||
logger.debug('NotificationContext', 'Permission already granted, skipping guide', { guideType });
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认选项
|
||||
const {
|
||||
title = '开启桌面通知',
|
||||
description = '及时接收重要事件和股票提醒',
|
||||
icon = true,
|
||||
duration = 10000,
|
||||
} = options;
|
||||
|
||||
// 显示引导 Toast
|
||||
const toastId = `permission-guide-${guideType}`;
|
||||
if (!toast.isActive(toastId)) {
|
||||
toast({
|
||||
id: toastId,
|
||||
duration,
|
||||
render: ({ onClose }) => (
|
||||
<Box
|
||||
p={4}
|
||||
bg="blue.500"
|
||||
color="white"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
maxW="400px"
|
||||
>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{icon && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={BellIcon} boxSize={5} />
|
||||
<Text fontWeight="bold" fontSize="md">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<Text fontSize="sm" opacity={0.9}>
|
||||
{description}
|
||||
</Text>
|
||||
<HStack spacing={2} justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="whiteAlpha"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
markGuideAsShown(guideType);
|
||||
}}
|
||||
>
|
||||
稍后再说
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="whiteAlpha"
|
||||
bg="whiteAlpha.300"
|
||||
_hover={{ bg: 'whiteAlpha.400' }}
|
||||
onClick={async () => {
|
||||
onClose();
|
||||
markGuideAsShown(guideType);
|
||||
await requestBrowserPermission();
|
||||
}}
|
||||
>
|
||||
立即开启
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
|
||||
logger.info('NotificationContext', 'Permission guide shown', { guideType });
|
||||
}
|
||||
}, [toast, shouldShowGuide, markGuideAsShown, browserPermission, requestBrowserPermission]);
|
||||
|
||||
/**
|
||||
* ⚡ 显示欢迎引导(登录后)
|
||||
*/
|
||||
const showWelcomeGuide = useCallback(() => {
|
||||
showPermissionGuide(GUIDE_TYPES.WELCOME, {
|
||||
title: '🎉 欢迎使用价值前沿',
|
||||
description: '开启桌面通知,第一时间接收重要投资事件和股票提醒',
|
||||
duration: 12000,
|
||||
});
|
||||
}, [showPermissionGuide]);
|
||||
|
||||
/**
|
||||
* ⚡ 显示社区功能引导
|
||||
*/
|
||||
const showCommunityGuide = useCallback(() => {
|
||||
showPermissionGuide(GUIDE_TYPES.COMMUNITY, {
|
||||
title: '关注感兴趣的事件',
|
||||
description: '开启通知后,您关注的事件有新动态时会第一时间提醒您',
|
||||
duration: 10000,
|
||||
});
|
||||
}, [showPermissionGuide]);
|
||||
|
||||
/**
|
||||
* ⚡ 显示首次关注引导
|
||||
*/
|
||||
const showFirstFollowGuide = useCallback(() => {
|
||||
showPermissionGuide(GUIDE_TYPES.FIRST_FOLLOW, {
|
||||
title: '关注成功',
|
||||
description: '开启桌面通知,事件有更新时我们会及时提醒您',
|
||||
duration: 8000,
|
||||
});
|
||||
}, [showPermissionGuide]);
|
||||
|
||||
/**
|
||||
* 发送浏览器通知
|
||||
*/
|
||||
@@ -626,6 +752,10 @@ export const NotificationProvider = ({ children }) => {
|
||||
requestBrowserPermission,
|
||||
trackNotificationClick,
|
||||
retryConnection,
|
||||
// ⚡ 新增:权限引导方法
|
||||
showWelcomeGuide,
|
||||
showCommunityGuide,
|
||||
showFirstFollowGuide,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
170
src/hooks/usePermissionGuide.js
Normal file
170
src/hooks/usePermissionGuide.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// src/hooks/usePermissionGuide.js
|
||||
/**
|
||||
* 通知权限引导管理 Hook
|
||||
*
|
||||
* 功能:
|
||||
* - 管理多个引导场景的显示状态
|
||||
* - 使用 localStorage 持久化记录
|
||||
* - 支持定期提醒策略
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// 引导场景类型
|
||||
export const GUIDE_TYPES = {
|
||||
WELCOME: 'welcome', // 首次登录欢迎引导
|
||||
COMMUNITY: 'community', // 社区功能引导
|
||||
FIRST_FOLLOW: 'first_follow', // 首次关注事件引导
|
||||
PERIODIC: 'periodic', // 定期提醒
|
||||
};
|
||||
|
||||
// localStorage 键名
|
||||
const STORAGE_KEYS = {
|
||||
SHOWN_GUIDES: 'notification_guides_shown',
|
||||
LAST_PERIODIC: 'notification_last_periodic_prompt',
|
||||
TOTAL_PROMPTS: 'notification_total_prompts',
|
||||
};
|
||||
|
||||
// 定期提醒间隔(毫秒)
|
||||
const PERIODIC_INTERVAL = 3 * 24 * 60 * 60 * 1000; // 3 天
|
||||
const MAX_PERIODIC_PROMPTS = 3; // 最多提醒 3 次
|
||||
|
||||
/**
|
||||
* 权限引导管理 Hook
|
||||
*/
|
||||
export function usePermissionGuide() {
|
||||
const [shownGuides, setShownGuides] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.SHOWN_GUIDES);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
logger.error('usePermissionGuide', 'Failed to load shown guides', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 检查是否应该显示某个引导
|
||||
* @param {string} guideType - 引导类型
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const shouldShowGuide = useCallback((guideType) => {
|
||||
// 已经显示过的引导不再显示
|
||||
if (shownGuides.includes(guideType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 特殊逻辑:定期提醒
|
||||
if (guideType === GUIDE_TYPES.PERIODIC) {
|
||||
try {
|
||||
const lastPrompt = localStorage.getItem(STORAGE_KEYS.LAST_PERIODIC);
|
||||
const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10);
|
||||
|
||||
// 超过最大提醒次数
|
||||
if (totalPrompts >= MAX_PERIODIC_PROMPTS) {
|
||||
logger.debug('usePermissionGuide', 'Periodic prompts limit reached', { totalPrompts });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 未到提醒间隔
|
||||
if (lastPrompt) {
|
||||
const elapsed = Date.now() - parseInt(lastPrompt, 10);
|
||||
if (elapsed < PERIODIC_INTERVAL) {
|
||||
logger.debug('usePermissionGuide', 'Periodic interval not reached', {
|
||||
elapsed: Math.round(elapsed / 1000 / 60 / 60), // 小时
|
||||
required: Math.round(PERIODIC_INTERVAL / 1000 / 60 / 60)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('usePermissionGuide', 'Failed to check periodic guide', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [shownGuides]);
|
||||
|
||||
/**
|
||||
* 标记引导已显示
|
||||
* @param {string} guideType - 引导类型
|
||||
*/
|
||||
const markGuideAsShown = useCallback((guideType) => {
|
||||
try {
|
||||
// 更新状态
|
||||
setShownGuides(prev => {
|
||||
if (prev.includes(guideType)) {
|
||||
return prev;
|
||||
}
|
||||
const updated = [...prev, guideType];
|
||||
// 持久化
|
||||
localStorage.setItem(STORAGE_KEYS.SHOWN_GUIDES, JSON.stringify(updated));
|
||||
logger.info('usePermissionGuide', 'Guide marked as shown', { guideType });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// 特殊处理:定期提醒
|
||||
if (guideType === GUIDE_TYPES.PERIODIC) {
|
||||
localStorage.setItem(STORAGE_KEYS.LAST_PERIODIC, String(Date.now()));
|
||||
|
||||
const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10);
|
||||
localStorage.setItem(STORAGE_KEYS.TOTAL_PROMPTS, String(totalPrompts + 1));
|
||||
|
||||
logger.info('usePermissionGuide', 'Periodic prompt recorded', {
|
||||
totalPrompts: totalPrompts + 1
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('usePermissionGuide', 'Failed to mark guide as shown', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 重置所有引导(用于测试或用户主动重置)
|
||||
*/
|
||||
const resetAllGuides = useCallback(() => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEYS.SHOWN_GUIDES);
|
||||
localStorage.removeItem(STORAGE_KEYS.LAST_PERIODIC);
|
||||
localStorage.removeItem(STORAGE_KEYS.TOTAL_PROMPTS);
|
||||
setShownGuides([]);
|
||||
logger.info('usePermissionGuide', 'All guides reset');
|
||||
} catch (error) {
|
||||
logger.error('usePermissionGuide', 'Failed to reset guides', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 获取定期提醒的统计信息(用于调试)
|
||||
*/
|
||||
const getPeriodicStats = useCallback(() => {
|
||||
try {
|
||||
const lastPrompt = localStorage.getItem(STORAGE_KEYS.LAST_PERIODIC);
|
||||
const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10);
|
||||
|
||||
return {
|
||||
lastPromptTime: lastPrompt ? new Date(parseInt(lastPrompt, 10)) : null,
|
||||
totalPrompts,
|
||||
remainingPrompts: MAX_PERIODIC_PROMPTS - totalPrompts,
|
||||
nextPromptTime: lastPrompt
|
||||
? new Date(parseInt(lastPrompt, 10) + PERIODIC_INTERVAL)
|
||||
: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('usePermissionGuide', 'Failed to get periodic stats', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
shouldShowGuide,
|
||||
markGuideAsShown,
|
||||
resetAllGuides,
|
||||
getPeriodicStats,
|
||||
shownGuides,
|
||||
};
|
||||
}
|
||||
@@ -140,14 +140,14 @@ export const authHandlers = [
|
||||
|
||||
console.log('[Mock] 生成微信二维码:', { sessionId, authUrl });
|
||||
|
||||
// 10秒后自动模拟扫码(方便测试)
|
||||
// 3秒后自动模拟扫码(方便测试,已缩短延迟)
|
||||
setTimeout(() => {
|
||||
const session = mockWechatSessions.get(sessionId);
|
||||
if (session && session.status === 'waiting') {
|
||||
session.status = 'scanned';
|
||||
console.log(`[Mock] 模拟用户扫码: ${sessionId}`);
|
||||
|
||||
// 再过5秒自动确认登录
|
||||
// 再过2秒自动确认登录
|
||||
setTimeout(() => {
|
||||
const session2 = mockWechatSessions.get(sessionId);
|
||||
if (session2 && session2.status === 'scanned') {
|
||||
@@ -160,13 +160,19 @@ export const authHandlers = [
|
||||
phone: null,
|
||||
email: null,
|
||||
has_wechat: true,
|
||||
created_at: new Date().toISOString()
|
||||
created_at: new Date().toISOString(),
|
||||
// 添加默认订阅信息
|
||||
subscription_type: 'free',
|
||||
subscription_status: 'active',
|
||||
subscription_end_date: null,
|
||||
is_subscription_active: true,
|
||||
subscription_days_left: 0
|
||||
};
|
||||
console.log(`[Mock] 模拟用户确认登录: ${sessionId}`, session2.user);
|
||||
}
|
||||
}, 5000);
|
||||
}, 2000);
|
||||
}
|
||||
}, 10000);
|
||||
}, 3000);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
@@ -334,3 +340,90 @@ export const authHandlers = [
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
// ==================== Mock 调试工具(仅开发环境) ====================
|
||||
|
||||
/**
|
||||
* 暴露全局API,方便手动触发微信扫码模拟
|
||||
* 使用方式:
|
||||
* 1. 浏览器控制台输入:window.mockWechatScan()
|
||||
* 2. 或者在组件中调用:window.mockWechatScan(sessionId)
|
||||
*/
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
window.mockWechatScan = (sessionId) => {
|
||||
// 如果没有传入sessionId,尝试获取最新的session
|
||||
let targetSessionId = sessionId;
|
||||
|
||||
if (!targetSessionId) {
|
||||
// 获取最新创建的session
|
||||
const sessions = Array.from(mockWechatSessions.entries());
|
||||
if (sessions.length === 0) {
|
||||
console.warn('[Mock API] 没有活跃的微信session,请先获取二维码');
|
||||
return false;
|
||||
}
|
||||
// 按创建时间排序,获取最新的
|
||||
const latestSession = sessions.sort((a, b) => b[1].createdAt - a[1].createdAt)[0];
|
||||
targetSessionId = latestSession[0];
|
||||
}
|
||||
|
||||
const session = mockWechatSessions.get(targetSessionId);
|
||||
|
||||
if (!session) {
|
||||
console.error('[Mock API] Session不存在:', targetSessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.status !== 'waiting') {
|
||||
console.warn('[Mock API] Session状态不是waiting,当前状态:', session.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 立即触发扫码
|
||||
session.status = 'scanned';
|
||||
console.log(`[Mock API] ✅ 模拟扫码成功: ${targetSessionId}`);
|
||||
|
||||
// 1秒后自动确认登录
|
||||
setTimeout(() => {
|
||||
const session2 = mockWechatSessions.get(targetSessionId);
|
||||
if (session2 && session2.status === 'scanned') {
|
||||
session2.status = 'confirmed';
|
||||
session2.user = {
|
||||
id: 999,
|
||||
nickname: '微信测试用户',
|
||||
wechat_openid: 'mock_openid_' + targetSessionId,
|
||||
avatar_url: 'https://i.pravatar.cc/150?img=99',
|
||||
phone: null,
|
||||
email: null,
|
||||
has_wechat: true,
|
||||
created_at: new Date().toISOString(),
|
||||
subscription_type: 'free',
|
||||
subscription_status: 'active',
|
||||
subscription_end_date: null,
|
||||
is_subscription_active: true,
|
||||
subscription_days_left: 0
|
||||
};
|
||||
console.log(`[Mock API] ✅ 模拟确认登录: ${targetSessionId}`, session2.user);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 暴露获取当前sessions的方法(调试用)
|
||||
window.getMockWechatSessions = () => {
|
||||
const sessions = Array.from(mockWechatSessions.entries()).map(([id, session]) => ({
|
||||
sessionId: id,
|
||||
status: session.status,
|
||||
createdAt: new Date(session.createdAt).toLocaleString(),
|
||||
hasUser: !!session.user
|
||||
}));
|
||||
console.table(sessions);
|
||||
return sessions;
|
||||
};
|
||||
|
||||
console.log('%c[Mock API] 微信登录调试工具已加载', 'color: #00D084; font-weight: bold');
|
||||
console.log('%c使用方法:', 'color: #666');
|
||||
console.log(' window.mockWechatScan() - 触发最新session的扫码');
|
||||
console.log(' window.mockWechatScan(sessionId) - 触发指定session的扫码');
|
||||
console.log(' window.getMockWechatSessions() - 查看所有活跃的sessions');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { authHandlers } from './auth';
|
||||
import { accountHandlers } from './account';
|
||||
import { simulationHandlers } from './simulation';
|
||||
import { eventHandlers } from './event';
|
||||
import { paymentHandlers } from './payment';
|
||||
|
||||
// 可以在这里添加更多的 handlers
|
||||
// import { userHandlers } from './user';
|
||||
@@ -14,5 +15,6 @@ export const handlers = [
|
||||
...accountHandlers,
|
||||
...simulationHandlers,
|
||||
...eventHandlers,
|
||||
...paymentHandlers,
|
||||
// ...userHandlers,
|
||||
];
|
||||
|
||||
233
src/mocks/handlers/payment.js
Normal file
233
src/mocks/handlers/payment.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// src/mocks/handlers/payment.js
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
import { getCurrentUser } from '../data/users';
|
||||
|
||||
// 模拟网络延迟(毫秒)
|
||||
const NETWORK_DELAY = 500;
|
||||
|
||||
// 模拟订单数据存储
|
||||
const mockOrders = new Map();
|
||||
let orderIdCounter = 1000;
|
||||
|
||||
export const paymentHandlers = [
|
||||
// ==================== 支付订单管理 ====================
|
||||
|
||||
// 1. 创建支付订单
|
||||
http.post('/api/payment/create-order', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { plan_name, billing_cycle } = body;
|
||||
|
||||
console.log('[Mock] 创建支付订单:', { plan_name, billing_cycle, user: currentUser.id });
|
||||
|
||||
if (!plan_name || !billing_cycle) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '参数不完整'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// 模拟价格
|
||||
const prices = {
|
||||
pro: { monthly: 0.01, yearly: 0.08 },
|
||||
max: { monthly: 0.1, yearly: 0.8 }
|
||||
};
|
||||
|
||||
const amount = prices[plan_name]?.[billing_cycle] || 0.01;
|
||||
|
||||
// 创建订单
|
||||
const orderId = `ORDER_${orderIdCounter++}_${Date.now()}`;
|
||||
const order = {
|
||||
id: orderId,
|
||||
user_id: currentUser.id,
|
||||
plan_name,
|
||||
billing_cycle,
|
||||
amount,
|
||||
status: 'pending',
|
||||
qr_code_url: `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=weixin://wxpay/bizpayurl?pr=mock_${orderId}`,
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30分钟后过期
|
||||
};
|
||||
|
||||
mockOrders.set(orderId, order);
|
||||
|
||||
console.log('[Mock] 订单创建成功:', order);
|
||||
|
||||
// 模拟5秒后自动支付成功(方便测试)
|
||||
setTimeout(() => {
|
||||
const existingOrder = mockOrders.get(orderId);
|
||||
if (existingOrder && existingOrder.status === 'pending') {
|
||||
existingOrder.status = 'paid';
|
||||
existingOrder.paid_at = new Date().toISOString();
|
||||
console.log(`[Mock] 订单自动支付成功: ${orderId}`);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: order
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 查询订单状态
|
||||
http.get('/api/payment/order-status/:orderId', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const { orderId } = params;
|
||||
const order = mockOrders.get(orderId);
|
||||
|
||||
console.log('[Mock] 查询订单状态:', { orderId, found: !!order });
|
||||
|
||||
if (!order) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '订单不存在'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
if (order.user_id !== currentUser.id) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '无权访问此订单'
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: order
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 获取用户订单列表
|
||||
http.get('/api/payment/orders', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const userOrders = Array.from(mockOrders.values())
|
||||
.filter(order => order.user_id === currentUser.id)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
console.log('[Mock] 获取用户订单列表:', { count: userOrders.length });
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: userOrders
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 取消订单
|
||||
http.post('/api/payment/cancel-order', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { order_id } = body;
|
||||
|
||||
const order = mockOrders.get(order_id);
|
||||
|
||||
if (!order) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '订单不存在'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
if (order.user_id !== currentUser.id) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '无权操作此订单'
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
if (order.status !== 'pending') {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '只能取消待支付的订单'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
order.status = 'cancelled';
|
||||
order.cancelled_at = new Date().toISOString();
|
||||
|
||||
console.log('[Mock] 订单已取消:', order_id);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: '订单已取消'
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
// ==================== Mock 调试工具(仅开发环境) ====================
|
||||
|
||||
/**
|
||||
* 暴露全局API,方便手动触发支付成功
|
||||
* 使用方式:window.mockPaymentSuccess(orderId)
|
||||
*/
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
window.mockPaymentSuccess = (orderId) => {
|
||||
const order = mockOrders.get(orderId);
|
||||
if (!order) {
|
||||
console.error('[Mock Payment] 订单不存在:', orderId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order.status !== 'pending') {
|
||||
console.warn('[Mock Payment] 订单状态不是待支付:', order.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
order.status = 'paid';
|
||||
order.paid_at = new Date().toISOString();
|
||||
console.log('[Mock Payment] ✅ 支付成功:', orderId);
|
||||
return true;
|
||||
};
|
||||
|
||||
window.getMockOrders = () => {
|
||||
const orders = Array.from(mockOrders.entries()).map(([id, order]) => ({
|
||||
orderId: id,
|
||||
status: order.status,
|
||||
amount: order.amount,
|
||||
plan: `${order.plan_name} - ${order.billing_cycle}`,
|
||||
createdAt: new Date(order.created_at).toLocaleString()
|
||||
}));
|
||||
console.table(orders);
|
||||
return orders;
|
||||
};
|
||||
|
||||
console.log('%c[Mock Payment] 支付调试工具已加载', 'color: #00D084; font-weight: bold');
|
||||
console.log('%c使用方法:', 'color: #666');
|
||||
console.log(' window.mockPaymentSuccess(orderId) - 手动触发订单支付成功');
|
||||
console.log(' window.getMockOrders() - 查看所有模拟订单');
|
||||
}
|
||||
@@ -71,6 +71,7 @@ import ImportanceLegend from './components/ImportanceLegend';
|
||||
import InvestmentCalendar from './components/InvestmentCalendar';
|
||||
import { eventService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
@@ -91,11 +92,14 @@ const Community = () => {
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
|
||||
// Modal/Drawer控制
|
||||
const { isOpen: isEventModalOpen, onOpen: onEventModalOpen, onClose: onEventModalClose } = useDisclosure();
|
||||
const { isOpen: isStockDrawerOpen, onOpen: onStockDrawerOpen, onClose: onStockDrawerClose } = useDisclosure();
|
||||
|
||||
// ⚡ 通知权限引导
|
||||
const { showCommunityGuide } = useNotification();
|
||||
|
||||
// 状态管理
|
||||
const [events, setEvents] = useState([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
@@ -271,7 +275,17 @@ const Community = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]); // 只监听 URL 参数变化
|
||||
|
||||
// ⚡ 首次访问社区时,延迟显示权限引导
|
||||
useEffect(() => {
|
||||
if (showCommunityGuide) {
|
||||
const timer = setTimeout(() => {
|
||||
logger.info('Community', '显示社区权限引导');
|
||||
showCommunityGuide();
|
||||
}, 5000); // 延迟 5 秒,让用户先浏览页面
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [showCommunityGuide]); // 只在组件挂载时执行一次
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
|
||||
Reference in New Issue
Block a user