diff --git a/backend-single/deploy.py b/backend-single/deploy.py index 28a29c9..613c99b 100644 --- a/backend-single/deploy.py +++ b/backend-single/deploy.py @@ -2,14 +2,13 @@ # -*- coding: utf-8 -*- """ 情绪博物馆后端服务部署脚本 -支持本地部署和远程部署到服务器 +部署到远程服务器 101.200.208.45 使用系统自带的ssh/scp命令,无需额外依赖 """ import os import sys import subprocess -import time from pathlib import Path # 配置变量 @@ -21,11 +20,6 @@ SPRING_PROFILE = "test" # 本地路径 SCRIPT_DIR = Path(__file__).parent.absolute() JAR_PATH = SCRIPT_DIR / "target" / JAR_NAME -LOG_DIR = SCRIPT_DIR / "logs" -PID_FILE = Path(f"/tmp/{APP_NAME}.pid") - -# Java配置 -JAVA_OPTS = "-Xms512m -Xmx1024m -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError" # 远程服务器配置 REMOTE_HOST = "101.200.208.45" @@ -61,13 +55,7 @@ def run_command(cmd, cwd=None, shell=True, capture=True): """执行本地命令""" try: if capture: - result = subprocess.run( - cmd, - cwd=cwd, - shell=shell, - capture_output=True, - text=True - ) + result = subprocess.run(cmd, cwd=cwd, shell=shell, capture_output=True, text=True) return result.returncode == 0, result.stdout, result.stderr else: result = subprocess.run(cmd, cwd=cwd, shell=shell) @@ -95,23 +83,19 @@ def build_project(): """构建项目""" log_info("开始构建项目...") - # 检查Maven success, _, _ = run_command("mvn --version") if not success: log_error("未找到Maven命令,请确保已安装Maven") sys.exit(1) - # 执行Maven构建 log_info("执行: mvn clean package -DskipTests") os.chdir(SCRIPT_DIR) - # 不捕获输出,直接显示构建过程 success, _, _ = run_command("mvn clean package -DskipTests", capture=False) if not success: log_error("项目构建失败") sys.exit(1) - # 检查JAR文件 if not JAR_PATH.exists(): log_error(f"项目构建失败,未找到JAR文件: {JAR_PATH}") sys.exit(1) @@ -130,27 +114,24 @@ def check_jar(): log_info(f"JAR文件检查通过: {JAR_PATH}") -def deploy_to_remote(upload_script=None): +def deploy(upload_script=None): """ - 远程部署到服务器 + 部署到远程服务器 Args: upload_script: 可选,指定要上传的额外文件路径(如 deploy-server.sh) """ - log_info(f"开始远程部署到 {REMOTE_HOST}...") + log_info(f"开始部署到 {REMOTE_HOST}...") - # 构建项目 build_project() - - # 检查JAR文件 check_jar() - # 1. 创建远程目录 + # 创建远程目录 log_info("创建远程目录...") exec_ssh_cmd(f"mkdir -p {REMOTE_DIR}") exec_ssh_cmd(f"mkdir -p {REMOTE_LOG_DIR}") - # 2. 上传JAR文件 + # 上传JAR文件 log_info("上传JAR文件到远程服务器...") log_info(f"本地文件: {JAR_PATH}") log_info(f"远程路径: {REMOTE_USER}@{REMOTE_HOST}:{REMOTE_DIR}/{REMOTE_JAR_NAME}") @@ -160,7 +141,7 @@ def deploy_to_remote(upload_script=None): sys.exit(1) log_info("✅ JAR文件上传成功") - # 3. 验证远程文件 + # 验证远程文件 log_info("验证远程文件...") success, output, _ = exec_ssh_cmd(f"ls -lh {REMOTE_DIR}/{REMOTE_JAR_NAME}") if not success: @@ -168,26 +149,22 @@ def deploy_to_remote(upload_script=None): sys.exit(1) log_info(output) - # 4. 如果指定了额外文件,则上传 + # 上传额外文件 if upload_script: script_path = SCRIPT_DIR / upload_script if script_path.exists(): - log_info(f"上传指定文件到远程服务器: {upload_script}") + log_info(f"上传文件到远程服务器: {upload_script}") if not scp_upload(script_path, f"{REMOTE_DIR}/{upload_script}"): log_error(f"上传文件失败: {upload_script}") sys.exit(1) log_info(f"✅ 文件上传成功: {upload_script}") - - # 如果是脚本文件,设置执行权限 if upload_script.endswith('.sh'): exec_ssh_cmd(f"chmod +x {REMOTE_DIR}/{upload_script}") else: log_error(f"指定的文件不存在: {script_path}") sys.exit(1) - else: - log_info("跳过部署脚本上传(服务器已存在)") - # 5. 在远程服务器上执行部署 + # 执行远程部署 log_info("在远程服务器上执行部署...") success, output, error = exec_ssh_cmd(f"cd {REMOTE_DIR} && ./deploy-server.sh {SPRING_PROFILE}") if output: @@ -199,155 +176,24 @@ def deploy_to_remote(upload_script=None): log_error("远程部署脚本执行失败") sys.exit(1) - log_info("✅ 远程部署完成!") - show_remote_status() + log_info("✅ 部署完成!") + show_status() -def show_remote_status(): +def show_status(): """显示远程服务状态""" - log_info("=== 远程服务信息 ===") + log_info("=== 服务信息 ===") log_info(f"服务器地址: {REMOTE_HOST}") log_info(f"部署目录: {REMOTE_DIR}") log_info(f"日志目录: {REMOTE_LOG_DIR}") log_info(f"Spring Profile: {SPRING_PROFILE}") - log_info("检查远程服务状态...") + log_info("检查服务状态...") success, output, _ = exec_ssh_cmd(f"ps aux | grep {REMOTE_JAR_NAME} | grep -v grep") if output: - log_info(f"远程服务运行中:\n{output}") + log_info(f"服务运行中:\n{output}") else: - log_info("远程服务未运行") - - -def local_deploy(): - """本地部署""" - log_info(f"开始本地部署 {APP_NAME} 服务...") - - # 构建项目 - build_project() - - # 检查JAR文件 - check_jar() - - # 创建日志目录 - LOG_DIR.mkdir(parents=True, exist_ok=True) - - # 停止旧服务 - stop_local_service() - - # 启动新服务 - start_local_service() - - # 等待启动 - if wait_for_startup(): - log_info("✅ 本地部署成功!") - show_local_status() - else: - log_error("本地部署失败!") - sys.exit(1) - - -def stop_local_service(): - """停止本地服务""" - if PID_FILE.exists(): - pid = PID_FILE.read_text().strip() - success, _, _ = run_command(f"ps -p {pid}") - if success: - log_info(f"停止旧服务 (PID: {pid})") - run_command(f"kill {pid}") - - # 等待服务停止 - for _ in range(30): - success, _, _ = run_command(f"ps -p {pid}") - if not success: - log_info("服务已停止") - break - time.sleep(1) - else: - log_warn(f"强制停止服务 (PID: {pid})") - run_command(f"kill -9 {pid}") - else: - log_warn("PID文件存在但进程不存在,清理PID文件") - - PID_FILE.unlink() - else: - log_info("没有找到PID文件,服务可能未运行") - - -def start_local_service(): - """启动本地服务""" - log_info("启动本地服务...") - - startup_log = LOG_DIR / "startup.log" - app_log = LOG_DIR / "application.log" - - cmd = ( - f"nohup java {JAVA_OPTS} " - f"-Dspring.profiles.active={SPRING_PROFILE} " - f"-Dlogging.file.path={LOG_DIR} " - f"-Dlogging.file.name={app_log} " - f"-jar {JAR_PATH} " - f"> {startup_log} 2>&1 &" - ) - - # 使用subprocess启动后台进程 - subprocess.Popen( - cmd, - shell=True, - cwd=SCRIPT_DIR, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL - ) - - # 获取实际的Java进程PID - time.sleep(2) - success, output, _ = run_command(f"pgrep -f {JAR_NAME}") - if success and output: - pid = output.strip().split('\n')[0] - PID_FILE.write_text(pid) - log_info(f"服务启动中,PID: {pid}") - else: - log_warn("无法获取服务PID") - - log_info(f"启动日志: {startup_log}") - log_info(f"应用日志: {app_log}") - - -def wait_for_startup(): - """等待服务启动""" - log_info("等待服务启动...") - - for _ in range(60): - # 检查端口是否监听 - success, output, _ = run_command("netstat -tlnp 2>/dev/null | grep ':19089.*LISTEN'") - if success and output: - log_info("服务启动成功!") - return True - time.sleep(2) - - log_error(f"服务启动超时,请检查日志: {LOG_DIR}/startup.log") - return False - - -def show_local_status(): - """显示本地服务状态""" - log_info("=== 本地服务信息 ===") - log_info(f"应用名称: {APP_NAME}") - log_info(f"JAR文件: {JAR_PATH}") - log_info(f"日志目录: {LOG_DIR}") - log_info(f"PID文件: {PID_FILE}") - log_info(f"Java参数: {JAVA_OPTS}") - log_info(f"Spring Profile: {SPRING_PROFILE}") - - if PID_FILE.exists(): - pid = PID_FILE.read_text().strip() - success, _, _ = run_command(f"ps -p {pid}") - if success: - log_info(f"服务状态: 运行中 (PID: {pid})") - else: - log_info("服务状态: 未运行") - else: - log_info("服务状态: 未运行") + log_info("服务未运行") def print_usage(): @@ -356,57 +202,40 @@ def print_usage(): 用法: python deploy.py [命令] [参数] 命令: - deploy - 本地部署服务(默认) - remote - 远程部署到服务器(仅上传JAR) - remote [文件名] - 远程部署并上传指定文件(如 deploy-server.sh) - build - 构建项目 - start - 启动本地服务 - stop - 停止本地服务 - restart - 重启本地服务 - status - 查看本地服务状态 - remote-status - 查看远程服务状态 + deploy - 部署到远程服务器(默认) + deploy [文件名] - 部署并上传指定文件(如 deploy-server.sh) + build - 仅构建项目 + status - 查看远程服务状态 示例: - python deploy.py remote # 仅上传JAR并部署 - python deploy.py remote deploy-server.sh # 同时上传部署脚本 + python deploy.py # 部署到远程服务器 + python deploy.py deploy-server.sh # 同时上传部署脚本 + python deploy.py build # 仅构建项目 + python deploy.py status # 查看服务状态 """) def main(): """主函数""" - command = sys.argv[1] if len(sys.argv) > 1 else "deploy" + if len(sys.argv) < 2: + deploy() + return - if command == "deploy": - local_deploy() - elif command == "remote": - # 检查是否有额外的文件参数 - upload_script = sys.argv[2] if len(sys.argv) > 2 else None - deploy_to_remote(upload_script) - elif command == "build": + command = sys.argv[1] + + if command == "build": build_project() - elif command == "start": - build_project() - check_jar() - LOG_DIR.mkdir(parents=True, exist_ok=True) - start_local_service() - wait_for_startup() - elif command == "stop": - stop_local_service() - elif command == "restart": - stop_local_service() - time.sleep(2) - build_project() - check_jar() - LOG_DIR.mkdir(parents=True, exist_ok=True) - start_local_service() - wait_for_startup() elif command == "status": - show_local_status() - elif command == "remote-status": - show_remote_status() - else: + show_status() + elif command == "help" or command == "-h" or command == "--help": print_usage() - sys.exit(1) + elif command.endswith('.sh'): + # 如果第一个参数是文件名,则上传该文件 + deploy(command) + else: + # 其他情况视为部署命令 + upload_script = sys.argv[2] if len(sys.argv) > 2 else None + deploy(upload_script) if __name__ == "__main__": diff --git a/backend-single/deploy.sh b/backend-single/deploy.sh index 7396374..3855efb 100755 --- a/backend-single/deploy.sh +++ b/backend-single/deploy.sh @@ -1,7 +1,7 @@ #!/bin/bash # 情绪博物馆后端服务部署脚本 -# 支持本地部署和远程部署到服务器 101.200.208.45 +# 部署到远程服务器 101.200.208.45 set -e @@ -9,9 +9,6 @@ set -e APP_NAME="emotion-museum-single" JAR_NAME="backend-single-1.0.0.jar" JAR_PATH="./target/${JAR_NAME}" -LOG_DIR="./logs/" -PID_FILE="/tmp/${APP_NAME}.pid" -JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOG_DIR}/heapdump.hprof" # 远程服务器配置 REMOTE_HOST="101.200.208.45" @@ -25,9 +22,8 @@ SPRING_PROFILE="test" RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' -NC='\033[0m' # No Color +NC='\033[0m' -# 日志函数 log_info() { echo -e "${GREEN}[INFO]${NC} $1" } @@ -40,27 +36,23 @@ log_error() { echo -e "${RED}[ERROR]${NC} $1" } -# 检查并构建项目 +# 构建项目 build_project() { log_info "开始构建项目..." - # 检查 Maven 是否安装 if ! command -v mvn > /dev/null 2>&1; then log_error "未找到Maven命令,请确保已安装Maven" exit 1 fi - # 执行 Maven 构建 log_info "执行: mvn clean package -DskipTests" if ! mvn clean package -DskipTests; then log_error "项目构建失败" exit 1 fi - # 检查 JAR 文件是否生成 if [ ! -f "$JAR_PATH" ]; then log_error "项目构建失败,未找到JAR文件: $JAR_PATH" - log_error "请检查 Maven 构建输出" exit 1 fi @@ -68,313 +60,121 @@ build_project() { log_info "文件大小: $(ls -lh $JAR_PATH | awk '{print $5}')" } -# 检查 jar 文件是否存在 +# 检查JAR文件 check_jar() { if [ ! -f "$JAR_PATH" ]; then - log_error "JAR 文件不存在: $JAR_PATH" + log_error "JAR文件不存在: $JAR_PATH" log_info "请先执行打包命令: mvn clean package" exit 1 fi - log_info "JAR 文件检查通过: $JAR_PATH" + log_info "JAR文件检查通过: $JAR_PATH" } -# 创建日志目录 -create_log_dir() { - if [ ! -d "$LOG_DIR" ]; then - log_info "创建日志目录: $LOG_DIR" - mkdir -p "$LOG_DIR" - fi +# 显示服务状态 +show_status() { + log_info "=== 服务信息 ===" + log_info "服务器地址: $REMOTE_HOST" + log_info "部署目录: $REMOTE_DIR" + log_info "日志目录: $REMOTE_LOG_DIR" + log_info "Spring Profile: $SPRING_PROFILE" + + log_info "检查服务状态..." + ssh $REMOTE_USER@$REMOTE_HOST "ps aux | grep $REMOTE_JAR_NAME | grep -v grep" || log_info "服务未运行" } -# 本地部署 - 停止旧服务 -stop_local_service() { - if [ -f "$PID_FILE" ]; then - PID=$(cat "$PID_FILE") - if ps -p "$PID" > /dev/null 2>&1; then - log_info "停止旧服务 (PID: $PID)" - kill "$PID" - - # 等待服务停止 - for i in {1..30}; do - if ! ps -p "$PID" > /dev/null 2>&1; then - log_info "服务已停止" - break - fi - sleep 1 - done - - # 强制停止 - if ps -p "$PID" > /dev/null 2>&1; then - log_warn "强制停止服务 (PID: $PID)" - kill -9 "$PID" - fi - else - log_warn "PID 文件存在但进程不存在,清理 PID 文件" - fi - rm -f "$PID_FILE" - else - log_info "没有找到 PID 文件,服务可能未运行" - fi -} +# 部署到远程服务器 +# 参数: $1 - 可选,指定要上传的额外文件 +deploy() { + UPLOAD_SCRIPT="$1" + + log_info "开始部署到 $REMOTE_HOST..." -# 本地部署 - 启动新服务 -start_local_service() { - log_info "启动本地服务..." - - # 启动命令 - nohup java $JAVA_OPTS \ - -Dspring.profiles.active=$SPRING_PROFILE \ - -Dlogging.file.path=$LOG_DIR \ - -Dlogging.file.name=$LOG_DIR/application.log \ - -jar "$JAR_PATH" \ - > "$LOG_DIR/startup.log" 2>&1 & - - # 保存 PID - echo $! > "$PID_FILE" - - log_info "服务启动中,PID: $(cat $PID_FILE)" - log_info "启动日志: $LOG_DIR/startup.log" - log_info "应用日志: $LOG_DIR/application.log" -} - -# 远程部署 - 上传文件到服务器 -# 参数: $1 - 可选,指定要上传的额外文件(如 deploy-server.sh) -deploy_to_remote() { - UPLOAD_SCRIPT="$2" - - log_info "开始远程部署到 $REMOTE_HOST..." - - # 检查并构建项目 build_project - - # 检查 jar 文件 check_jar # 创建远程目录 log_info "创建远程目录..." - if ! ssh $REMOTE_USER@$REMOTE_HOST "mkdir -p $REMOTE_DIR"; then - log_error "创建远程目录失败: $REMOTE_DIR" - exit 1 - fi - if ! ssh $REMOTE_USER@$REMOTE_HOST "mkdir -p $REMOTE_LOG_DIR"; then - log_error "创建远程日志目录失败: $REMOTE_LOG_DIR" - exit 1 - fi + ssh $REMOTE_USER@$REMOTE_HOST "mkdir -p $REMOTE_DIR" || { log_error "创建远程目录失败"; exit 1; } + ssh $REMOTE_USER@$REMOTE_HOST "mkdir -p $REMOTE_LOG_DIR" || { log_error "创建远程日志目录失败"; exit 1; } - # 上传 jar 文件并重命名 - log_info "上传 JAR 文件到远程服务器..." + # 上传JAR文件 + log_info "上传JAR文件到远程服务器..." log_info "本地文件: $JAR_PATH" log_info "远程路径: $REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/$REMOTE_JAR_NAME" if ! scp "$JAR_PATH" $REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/$REMOTE_JAR_NAME; then - log_error "上传 JAR 文件失败" - log_error "请检查:" - log_error " 1. SSH 连接是否正常: ssh $REMOTE_USER@$REMOTE_HOST 'ls -la $REMOTE_DIR'" - log_error " 2. 本地文件是否存在: ls -la $JAR_PATH" - log_error " 3. 远程目录权限: ssh $REMOTE_USER@$REMOTE_HOST 'ls -la $REMOTE_DIR'" + log_error "上传JAR文件失败" exit 1 fi - log_info "✅ JAR 文件上传成功" + log_info "✅ JAR文件上传成功" # 验证远程文件 log_info "验证远程文件..." - if ! ssh $REMOTE_USER@$REMOTE_HOST "ls -lh $REMOTE_DIR/$REMOTE_JAR_NAME"; then - log_error "远程文件验证失败" - exit 1 - fi + ssh $REMOTE_USER@$REMOTE_HOST "ls -lh $REMOTE_DIR/$REMOTE_JAR_NAME" || { log_error "远程文件验证失败"; exit 1; } - # 如果指定了额外文件,则上传 + # 上传额外文件 if [ -n "$UPLOAD_SCRIPT" ] && [ -f "$UPLOAD_SCRIPT" ]; then - log_info "上传指定文件到远程服务器: $UPLOAD_SCRIPT" + log_info "上传文件到远程服务器: $UPLOAD_SCRIPT" if ! scp "$UPLOAD_SCRIPT" $REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/; then log_error "上传文件失败: $UPLOAD_SCRIPT" exit 1 fi log_info "✅ 文件上传成功: $UPLOAD_SCRIPT" - # 如果是脚本文件,设置执行权限 REMOTE_FILENAME=$(basename "$UPLOAD_SCRIPT") if [[ "$REMOTE_FILENAME" == *.sh ]]; then - log_info "设置远程脚本权限..." ssh $REMOTE_USER@$REMOTE_HOST "chmod +x $REMOTE_DIR/$REMOTE_FILENAME" fi - else - log_info "跳过部署脚本上传(服务器已存在)" fi - # 在远程服务器上执行部署 + # 执行远程部署 log_info "在远程服务器上执行部署..." if ! ssh $REMOTE_USER@$REMOTE_HOST "cd $REMOTE_DIR && ./deploy-server.sh $SPRING_PROFILE"; then log_error "远程部署脚本执行失败" exit 1 fi - log_info "✅ 远程部署完成!" - show_remote_info + log_info "✅ 部署完成!" + show_status } -# 本地部署 - 检查服务状态 -check_local_status() { - if [ -f "$PID_FILE" ]; then - PID=$(cat "$PID_FILE") - if ps -p "$PID" > /dev/null 2>&1; then - log_info "服务运行中 (PID: $PID)" - return 0 - else - log_error "PID 文件存在但进程不存在" - return 1 - fi - else - log_error "PID 文件不存在,服务未运行" - return 1 - fi +# 打印使用说明 +print_usage() { + echo "用法: $0 [命令] [参数]" + echo "" + echo "命令:" + echo " (无参数) - 部署到远程服务器(默认)" + echo " [文件名] - 部署并上传指定文件(如 deploy-server.sh)" + echo " build - 仅构建项目" + echo " status - 查看远程服务状态" + echo "" + echo "示例:" + echo " $0 # 部署到远程服务器" + echo " $0 deploy-server.sh # 同时上传部署脚本" + echo " $0 build # 仅构建项目" + echo " $0 status # 查看服务状态" } -# 本地部署 - 等待服务启动 -wait_for_local_startup() { - log_info "等待服务启动..." - for i in {1..60}; do - if check_local_status > /dev/null 2>&1; then - # 检查端口是否监听(使用 19089 端口) - if netstat -tlnp 2>/dev/null | grep -q ":19089.*LISTEN"; then - log_info "服务启动成功!" - return 0 - fi - fi - sleep 2 - done - - log_error "服务启动超时,请检查日志: $LOG_DIR/startup.log" - return 1 -} - -# 本地部署 - 显示服务信息 -show_local_info() { - log_info "=== 本地服务信息 ===" - log_info "应用名称: $APP_NAME" - log_info "JAR 文件: $JAR_PATH" - log_info "日志目录: $LOG_DIR" - log_info "PID 文件: $PID_FILE" - log_info "Java 参数: $JAVA_OPTS" - log_info "Spring Profile: $SPRING_PROFILE" - - if check_local_status > /dev/null 2>&1; then - PID=$(cat "$PID_FILE") - log_info "服务状态: 运行中 (PID: $PID)" - - # 显示内存使用情况 - if command -v jstat > /dev/null 2>&1; then - log_info "内存使用情况:" - jstat -gc "$PID" | head -2 - fi - else - log_info "服务状态: 未运行" - fi -} - -# 远程部署 - 显示服务信息 -show_remote_info() { - log_info "=== 远程服务信息 ===" - log_info "服务器地址: $REMOTE_HOST" - log_info "部署目录: $REMOTE_DIR" - log_info "日志目录: $REMOTE_LOG_DIR" - log_info "Spring Profile: $SPRING_PROFILE" - - # 检查远程服务状态 - log_info "检查远程服务状态..." - ssh $REMOTE_USER@$REMOTE_HOST "ps aux | grep $REMOTE_JAR_NAME | grep -v grep" || log_info "远程服务未运行" -} - -# 本地部署 - 主函数 -local_deploy() { - log_info "开始本地部署 $APP_NAME 服务..." - - # 检查并构建项目 - build_project - - # 检查 jar 文件 - check_jar - - # 创建日志目录 - create_log_dir - - # 停止旧服务 - stop_local_service - - # 启动新服务 - start_local_service - - # 等待启动 - if wait_for_local_startup; then - log_info "本地部署成功!" - show_local_info - else - log_error "本地部署失败!" - exit 1 - fi -} - -# 处理命令行参数 -case "${1:-deploy}" in - "deploy") - local_deploy - ;; - "remote") - deploy_to_remote "$@" +# 主逻辑 +case "${1:-}" in + "") + deploy ;; "build") build_project ;; - "start") - # 检查并构建项目 - build_project - check_jar - create_log_dir - start_local_service - wait_for_local_startup - ;; - "stop") - stop_local_service - ;; - "restart") - stop_local_service - sleep 2 - # 检查并构建项目 - build_project - check_jar - create_log_dir - start_local_service - wait_for_local_startup - ;; "status") - show_local_info + show_status ;; - "remote-status") - show_remote_info - ;; - "logs") - if [ -f "$LOG_DIR/application.log" ]; then - tail -f "$LOG_DIR/application.log" - else - log_error "日志文件不存在: $LOG_DIR/application.log" - fi + "help"|"-h"|"--help") + print_usage ;; *) - echo "用法: $0 {deploy|remote [文件名]|build|start|stop|restart|status|remote-status|logs}" - echo " deploy - 本地部署服务(默认)" - echo " remote - 远程部署到服务器(仅上传JAR)" - echo " remote [文件名] - 远程部署并上传指定文件(如 deploy-server.sh)" - echo " build - 构建项目" - echo " start - 启动本地服务" - echo " stop - 停止本地服务" - echo " restart - 重启本地服务" - echo " status - 查看本地服务状态" - echo " remote-status - 查看远程服务状态" - echo " logs - 查看本地实时日志" - echo "" - echo "示例:" - echo " $0 remote # 仅上传JAR并部署" - echo " $0 remote deploy-server.sh # 同时上传部署脚本" - exit 1 + # 如果参数是文件,则上传该文件 + if [ -f "$1" ]; then + deploy "$1" + else + deploy "$1" + fi ;; -esac \ No newline at end of file +esac diff --git a/backend-single/src/main/java/com/emotion/controller/UserProfileController.java b/backend-single/src/main/java/com/emotion/controller/UserProfileController.java index 4d50644..67ead4d 100644 --- a/backend-single/src/main/java/com/emotion/controller/UserProfileController.java +++ b/backend-single/src/main/java/com/emotion/controller/UserProfileController.java @@ -97,4 +97,15 @@ public class UserProfileController { List list = userProfileService.getProfileList(request); return Result.success(list); } + + /** + * 迁移用户档案中的生命事件数据到t_life_event表 + * 将childhood、peak、valley数据迁移到生命事件表 + * 注意:此接口为一次性数据迁移接口,迁移完成后可删除 + */ + @PostMapping("/migrateLifeEvents") + public Result migrateLifeEvents() { + int count = userProfileService.migrateLifeEventsFromProfiles(); + return Result.success(count); + } } diff --git a/backend-single/src/main/java/com/emotion/service/UserProfileService.java b/backend-single/src/main/java/com/emotion/service/UserProfileService.java index 17ae0d6..2e55ab1 100644 --- a/backend-single/src/main/java/com/emotion/service/UserProfileService.java +++ b/backend-single/src/main/java/com/emotion/service/UserProfileService.java @@ -72,4 +72,12 @@ public interface UserProfileService extends IService { * @return 是否成功 */ boolean deleteProfile(String id); + + /** + * 迁移用户档案中的生命事件数据到t_life_event表 + * 将childhood、peak、valley数据迁移到生命事件表 + * + * @return 迁移的记录数 + */ + int migrateLifeEventsFromProfiles(); } diff --git a/backend-single/src/main/java/com/emotion/service/impl/LifeEventServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/LifeEventServiceImpl.java index 685633b..0931f00 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/LifeEventServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/LifeEventServiceImpl.java @@ -34,6 +34,7 @@ public class LifeEventServiceImpl extends ServiceImpl getPageByCurrentUser(LifeEventPageRequest request) { @@ -130,16 +131,8 @@ public class LifeEventServiceImpl extends ServiceImpl queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.and(w -> w + .isNotNull(UserProfile::getChildhoodContent).ne(UserProfile::getChildhoodContent, "") + .or().isNotNull(UserProfile::getPeakContent).ne(UserProfile::getPeakContent, "") + .or().isNotNull(UserProfile::getValleyContent).ne(UserProfile::getValleyContent, "") + ); + + List profiles = list(queryWrapper); + log.info("找到 {} 个有生命事件数据的用户档案", profiles.size()); + + int migratedCount = 0; + + for (UserProfile profile : profiles) { + String userId = profile.getUserId(); + List eventsToSave = new ArrayList<>(); + + // 迁移童年记忆 + if (StringUtils.hasText(profile.getChildhoodContent())) { + if (!isEventExists(userId, "childhood")) { + LifeEvent childhoodEvent = createLifeEventFromProfile( + userId, + "童年记忆", + profile.getChildhoodContent(), + profile.getChildhoodDate(), + "childhood" + ); + eventsToSave.add(childhoodEvent); + } + } + + // 迁移高光时刻 (peak -> joy) + if (StringUtils.hasText(profile.getPeakContent())) { + if (!isEventExists(userId, "joy")) { + LifeEvent peakEvent = createLifeEventFromProfile( + userId, + "光芒闪耀的时刻", + profile.getPeakContent(), + profile.getPeakDate(), + "joy" + ); + eventsToSave.add(peakEvent); + } + } + + // 迁移低谷时期 (valley -> low) + if (StringUtils.hasText(profile.getValleyContent())) { + if (!isEventExists(userId, "low")) { + LifeEvent valleyEvent = createLifeEventFromProfile( + userId, + "在暗夜中潜行", + profile.getValleyContent(), + profile.getValleyDate(), + "low" + ); + eventsToSave.add(valleyEvent); + } + } + + // 批量保存 + if (!eventsToSave.isEmpty()) { + lifeEventService.saveBatch(eventsToSave); + migratedCount += eventsToSave.size(); + log.info("用户 {} 迁移了 {} 条生命事件", userId, eventsToSave.size()); + } + } + + log.info("生命事件数据迁移完成,共迁移 {} 条记录", migratedCount); + return migratedCount; + } + + /** + * 检查用户是否已存在指定标签的生命事件 + * + * @param userId 用户ID + * @param tag 标签 + * @return 是否存在 + */ + private boolean isEventExists(String userId, String tag) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LifeEvent::getUserId, userId) + .eq(LifeEvent::getIsDeleted, 0) + .like(LifeEvent::getTags, tag); + return lifeEventService.count(wrapper) > 0; + } + + /** + * 从用户档案数据创建生命事件 + * + * @param userId 用户ID + * @param title 事件标题 + * @param content 事件内容 + * @param eventDate 事件日期 + * @param tag 事件标签 + * @return 生命事件实体 + */ + private LifeEvent createLifeEventFromProfile(String userId, String title, String content, + LocalDate eventDate, String tag) { + LifeEvent event = new LifeEvent(); + event.setUserId(userId); + event.setTitle(title); + event.setContent(content); + event.setEventType("milestone"); + event.setTags(List.of(tag)); + + // 设置事件日期,如果没有则使用当前时间 + if (eventDate != null) { + event.setEventDate(eventDate.atStartOfDay()); + } else { + event.setEventDate(java.time.LocalDateTime.now()); + } + + return event; + } + private UserProfileResponse convertToResponse(UserProfile userProfile) { if (userProfile == null) { return null; diff --git a/life-script/src/App.jsx b/life-script/src/App.jsx index 3da062f..4c53421 100644 --- a/life-script/src/App.jsx +++ b/life-script/src/App.jsx @@ -11,13 +11,15 @@ import useStore from './store/useStore'; /** * 路由守卫组件 * 根据登录状态和注册完成状态进行路由重定向 + * - requireAuth: 需要登录才能访问 + * - requireOnboarding: 需要完成入站流程才能访问 */ const ProtectedRoute = ({ children, requireAuth = false, requireOnboarding = false }) => { const { isLoggedIn, registrationData } = useStore(); const navigate = useNavigate(); - // 检查是否完成入站流程 - const hasCompletedOnboarding = registrationData.nickname && registrationData.future?.vision; + // 检查是否完成入站流程(有昵称和未来愿景即视为已完成) + const hasCompletedOnboarding = !!(registrationData.nickname && registrationData.future?.vision); useEffect(() => { if (requireAuth && !isLoggedIn) { @@ -65,8 +67,8 @@ const AnimatedRoutes = () => { const location = useLocation(); const { isLoggedIn, registrationData } = useStore(); - // 检查是否完成入站流程 - const hasCompletedOnboarding = registrationData.nickname && registrationData.future?.vision; + // 检查是否完成入站流程(有昵称和未来愿景即视为已完成) + const hasCompletedOnboarding = !!(registrationData.nickname && registrationData.future?.vision); return ( @@ -89,15 +91,19 @@ const AnimatedRoutes = () => { } /> - {/* 入站流程页 */} + {/* 入站流程页 - 已完成入站的用户直接跳转到首页 */} + !isLoggedIn ? ( + + ) : hasCompletedOnboarding ? ( + + ) : ( - + ) } /> diff --git a/life-script/src/components/ui/GlassInput.jsx b/life-script/src/components/ui/GlassInput.jsx index 1d7921c..7ebe7d8 100644 --- a/life-script/src/components/ui/GlassInput.jsx +++ b/life-script/src/components/ui/GlassInput.jsx @@ -21,6 +21,11 @@ const GlassInput = ({ className = '', id }) => { + // 日期类型输入框的特殊样式 + const dateInputClass = type === 'date' + ? 'cursor-pointer [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-100 [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:top-0 [&::-webkit-calendar-picker-indicator]:left-0 [&::-webkit-calendar-picker-indicator]:bg-transparent' + : ''; + return (
{label && ( @@ -31,15 +36,17 @@ const GlassInput = ({ {label} )} - onChange(e.target.value)} - maxLength={maxLength} - className="glass-input w-full focus:ring-2 focus:ring-orange-200/50" - /> +
+ onChange(e.target.value)} + maxLength={maxLength} + className={`glass-input w-full focus:ring-2 focus:ring-orange-200/50 ${dateInputClass}`} + /> +
); }; diff --git a/life-script/src/index.css b/life-script/src/index.css index 302a8ca..b972ec1 100644 --- a/life-script/src/index.css +++ b/life-script/src/index.css @@ -86,6 +86,33 @@ body { color: rgba(255, 255, 255, 0.3); } +/* 日期输入框样式优化 */ +input[type="date"].glass-input { + cursor: pointer; + position: relative; +} + +input[type="date"].glass-input::-webkit-calendar-picker-indicator { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + background: transparent; +} + +input[type="date"].glass-input::-webkit-datetime-edit { + color: white; +} + +input[type="date"].glass-input::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + /* Animations */ @keyframes float { 0%, 100% { diff --git a/life-script/src/pages/LoginPage.jsx b/life-script/src/pages/LoginPage.jsx index daff757..647b18d 100644 --- a/life-script/src/pages/LoginPage.jsx +++ b/life-script/src/pages/LoginPage.jsx @@ -42,6 +42,7 @@ const LoginPage = () => { /** * 处理登录提交 + * 登录成功后根据用户档案状态决定跳转目标 */ const handleSubmit = async () => { if (phone.length !== 11) { @@ -56,9 +57,14 @@ const LoginPage = () => { setIsSubmitting(true); try { - // 尝试调用后端登录 - await login(phone, code); - navigate('/onboarding'); + // 尝试调用后端登录,返回值包含 hasProfile 标识 + const result = await login(phone, code); + // 根据用户档案状态决定跳转:已有档案直接进入首页,否则进入入站流程 + if (result.hasProfile) { + navigate('/dashboard'); + } else { + navigate('/onboarding'); + } } catch (error) { // 后端不可用时,使用本地验证 if (code === '888888') { diff --git a/life-script/src/pages/OnboardingPage.jsx b/life-script/src/pages/OnboardingPage.jsx index 70e74e8..39321db 100644 --- a/life-script/src/pages/OnboardingPage.jsx +++ b/life-script/src/pages/OnboardingPage.jsx @@ -6,6 +6,7 @@ import { PromptTagGroup } from '../components/PromptTag'; import useStore from '../store/useStore'; import { inspirationClusters } from '../utils/constants'; import * as dictionaryService from '../services/dictionary'; +import * as lifeEventService from '../services/lifeEvent'; /** * OnboardingPage 组件 @@ -83,6 +84,28 @@ const OnboardingPage = () => { updateRegistration(dataToSave); }; + /** + * 保存生命事件到后端 + * @param {Object} eventData - 事件数据 { date, text } + * @param {string} eventType - 事件类型标识 + * @param {string} title - 事件标题 + */ + const saveLifeEvent = async (eventData, eventType, title) => { + if (!eventData?.date || !eventData?.text) return; + + try { + await lifeEventService.createEvent({ + title, + time: eventData.date, + content: eventData.text, + eventType: 'milestone', + tags: [eventType] + }); + } catch (error) { + console.error(`保存${title}失败:`, error); + } + }; + const handleNext = async () => { saveStepData(); if (currentStep < 5) { @@ -90,7 +113,19 @@ const OnboardingPage = () => { } else { setIsSaving(true); try { + // 保存用户档案 await saveUserProfile(); + + // 保存生命事件(童年记忆、开心经历、低谷时期) + const eventsToSave = [ + { data: formData.childhood, type: 'childhood', title: '童年记忆' }, + { data: formData.joy, type: 'joy', title: '光芒闪耀的时刻' }, + { data: formData.low, type: 'low', title: '在暗夜中潜行' } + ]; + + await Promise.all( + eventsToSave.map(({ data, type, title }) => saveLifeEvent(data, type, title)) + ); } catch (error) { console.error('保存档案失败:', error); } finally { diff --git a/life-script/src/services/lifeEvent.js b/life-script/src/services/lifeEvent.js index 59b649b..19054ae 100644 --- a/life-script/src/services/lifeEvent.js +++ b/life-script/src/services/lifeEvent.js @@ -131,7 +131,7 @@ export const transformToFrontendFormat = (backendData) => { id, userId, title: title || '', - time: eventDate || '', + time: eventDate ? eventDate.split('T')[0] : '', content: content || '', aiFeedback: aiReply || '', eventType: eventType || 'daily_log', diff --git a/life-script/src/store/useStore.js b/life-script/src/store/useStore.js index 1a7ea67..490a02e 100644 --- a/life-script/src/store/useStore.js +++ b/life-script/src/store/useStore.js @@ -86,6 +86,7 @@ const useStore = create( * 登录 * @param {string} phone - 手机号 * @param {string} smsCode - 验证码 + * @returns {Promise} 包含 hasProfile 标识,用于判断是否需要跳转到 onboarding */ login: async (phone, smsCode) => { set({ loading: true, error: null }); @@ -97,18 +98,24 @@ const useStore = create( isLoggedIn: true, phone, userId, - view: 'onboarding', loading: false }); - // 尝试加载用户档案 + // 尝试加载用户档案,判断用户是否已完成注册 + let hasProfile = false; try { - await get().loadUserProfile(); + const profileData = await get().loadUserProfile(); + // 检查档案是否完整(有昵称和未来愿景) + hasProfile = !!(profileData && profileData.nickname && profileData.future?.vision); } catch { - // 档案不存在,继续入站流程 + // 档案不存在,需要进入入站流程 + hasProfile = false; } - return response; + // 根据档案状态设置视图 + set({ view: hasProfile ? 'dashboard' : 'onboarding' }); + + return { ...response, hasProfile }; } catch (error) { set({ loading: false, error: error.message }); throw error; diff --git a/life-script/src/views/TimelineView.jsx b/life-script/src/views/TimelineView.jsx index d4bfcf0..5eda47b 100644 --- a/life-script/src/views/TimelineView.jsx +++ b/life-script/src/views/TimelineView.jsx @@ -62,11 +62,18 @@ const TimelineView = () => { }; /** - * 按时间倒序排列事件 + * 按事件时间倒序排列(最新的在最上面) + * 空日期的事件排在最后 */ - const sortedEvents = [...lifeEvents].sort( - (a, b) => new Date(b.time) - new Date(a.time) - ); + const sortedEvents = [...lifeEvents].sort((a, b) => { + // 如果两个都没有时间,保持原顺序 + if (!a.time && !b.time) return 0; + // 没有时间的排在后面 + if (!a.time) return 1; + if (!b.time) return -1; + // 按时间倒序(最新的在前) + return new Date(b.time) - new Date(a.time); + }); return (
@@ -98,7 +105,14 @@ const TimelineView = () => { {/* 事件卡片 */}
-

{event.title}

+
+

{event.title}

+ {event.tags && event.tags.length > 0 && ( + + {event.tags[0] === 'childhood' ? '童年' : event.tags[0] === 'joy' ? '高光' : event.tags[0] === 'low' ? '低谷' : event.tags[0]} + + )} +
{event.time} @@ -107,18 +121,20 @@ const TimelineView = () => { {event.content}

- {/* AI 反馈区域 */} -
-
- - - 引路人洞察 - + {/* AI 反馈区域 - 仅在有反馈时显示 */} + {event.aiFeedback && ( +
+
+ + + 引路人洞察 + +
+

+ {event.aiFeedback} +

-

- {event.aiFeedback} -

-
+ )}
)) diff --git a/life-script/vite.config.js b/life-script/vite.config.js index 79441b1..2d34500 100644 --- a/life-script/vite.config.js +++ b/life-script/vite.config.js @@ -3,11 +3,11 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ -export default defineConfig({ +export default defineConfig(({ mode }) => ({ plugins: [react(), tailwindcss()], - // 生产环境使用 /course-of-life/ 路径 - base: '/course-of-life/', + // 生产环境使用 /course-of-life/ 路径,开发环境使用根路径 + base: mode === 'production' ? '/course-of-life/' : '/', server: { port: 3000 } -}) +})) diff --git a/web-admin/deploy.py b/web-admin/deploy.py new file mode 100644 index 0000000..d395871 --- /dev/null +++ b/web-admin/deploy.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +管理后台部署脚本 - 将构建好的管理后台文件上传到服务器 +使用方法: python deploy.py +""" + +import os +import sys +import shutil +import subprocess +from pathlib import Path + +# 配置变量 +SERVER_IP = "101.200.208.45" +USERNAME = "root" +REMOTE_PATH = "/data/www/emotion-museum-admin" + +# 本地路径 +SCRIPT_DIR = Path(__file__).parent.absolute() +DIST_DIR = SCRIPT_DIR / "dist" + + +class Colors: + """终端颜色""" + GREEN = '\033[32m' + RED = '\033[31m' + YELLOW = '\033[33m' + RESET = '\033[0m' + + +def log_info(msg): + """打印信息日志""" + print(f"{Colors.GREEN}✅{Colors.RESET} {msg}") + + +def log_error(msg): + """打印错误日志""" + print(f"{Colors.RED}❌{Colors.RESET} {msg}") + + +def log_step(msg): + """打印步骤日志""" + print(f"📦 {msg}") + + +def run_command(cmd, cwd=None, shell=True, capture=True): + """执行本地命令""" + try: + if capture: + result = subprocess.run(cmd, cwd=cwd, shell=shell, capture_output=True, text=True) + return result.returncode == 0, result.stdout, result.stderr + else: + result = subprocess.run(cmd, cwd=cwd, shell=shell) + return result.returncode == 0, "", "" + except Exception as e: + return False, "", str(e) + + +def check_npm(): + """检查npm是否安装""" + success, _, _ = run_command("npm --version") + if not success: + log_error("错误: 未找到npm命令,请先安装Node.js") + sys.exit(1) + + +def check_scp(): + """检查scp是否可用""" + success, _, _ = run_command("scp -V 2>&1 || echo ok") + # scp通常没有--version,但命令存在即可 + return True + + +def clean_dist(): + """清理旧的构建文件""" + log_step("🧹 清理旧的构建文件...") + if DIST_DIR.exists(): + shutil.rmtree(DIST_DIR) + + +def build_project(): + """构建项目""" + log_step("开始构建管理后台项目(生产环境)...") + + os.chdir(SCRIPT_DIR) + + # 设置环境变量并执行构建 + env = os.environ.copy() + env["NODE_ENV"] = "production" + + try: + result = subprocess.run( + "npm run build", + shell=True, + cwd=SCRIPT_DIR, + env=env + ) + if result.returncode != 0: + log_error("管理后台项目构建失败,请检查代码") + sys.exit(1) + except Exception as e: + log_error(f"构建失败: {e}") + sys.exit(1) + + log_info("管理后台项目构建成功") + + +def verify_dist(): + """验证dist目录是否存在""" + if not DIST_DIR.exists(): + log_error("错误: 构建后dist目录仍不存在,请检查构建配置") + sys.exit(1) + + # 检查关键文件 + index_file = DIST_DIR / "index.html" + assets_dir = DIST_DIR / "assets" + + if not index_file.exists(): + log_error("错误: dist/index.html 不存在") + sys.exit(1) + + if not assets_dir.exists(): + log_error("错误: dist/assets 目录不存在") + sys.exit(1) + + +def create_remote_dir(): + """创建远程目录""" + log_step("📁 创建远程目录...") + cmd = f'ssh {USERNAME}@{SERVER_IP} "mkdir -p {REMOTE_PATH}"' + success, _, stderr = run_command(cmd) + if not success: + log_error(f"创建远程目录失败: {stderr}") + sys.exit(1) + + +def upload_files(): + """上传文件到服务器""" + log_step("📤 上传文件到服务器...") + + print(f"正在上传文件到服务器 {SERVER_IP}...") + + # 上传 index.html + index_file = DIST_DIR / "index.html" + cmd1 = f'scp "{index_file}" {USERNAME}@{SERVER_IP}:{REMOTE_PATH}/' + success1, _, stderr1 = run_command(cmd1) + + if not success1: + log_error(f"上传 index.html 失败: {stderr1}") + return False + + # 上传 assets 目录 + assets_dir = DIST_DIR / "assets" + cmd2 = f'scp -r "{assets_dir}" {USERNAME}@{SERVER_IP}:{REMOTE_PATH}/' + success2, _, stderr2 = run_command(cmd2) + + if not success2: + log_error(f"上传 assets 目录失败: {stderr2}") + return False + + return True + + +def set_permissions(): + """设置文件权限""" + log_step("🔐 设置文件权限...") + cmd = f'ssh {USERNAME}@{SERVER_IP} "chmod -R 755 {REMOTE_PATH}"' + success, _, stderr = run_command(cmd) + if not success: + log_error(f"设置权限失败: {stderr}") + + +def deploy(): + """执行部署""" + print("开始部署管理后台应用到服务器...") + + # 检查npm + check_npm() + + # 清理旧构建 + clean_dist() + + # 构建项目 + build_project() + + # 验证构建结果 + verify_dist() + + # 创建远程目录 + create_remote_dir() + + # 上传文件 + if upload_files(): + # 设置权限 + set_permissions() + + log_info("管理后台部署完成!") + print(f"📱 访问地址: http://{SERVER_IP}/emotion-museum-admin/") + print("🔧 管理后台功能: AI配置管理、用户管理、数据统计等") + else: + log_error("部署失败,请检查:") + print("1. 服务器IP地址是否正确") + print("2. SSH密钥是否配置正确") + print("3. 服务器目录权限是否正确") + sys.exit(1) + + +def main(): + """主函数""" + deploy() + + +if __name__ == "__main__": + main() diff --git a/web-admin/src/config/menu.ts b/web-admin/src/config/menu.ts index 6779c37..6bb7479 100644 --- a/web-admin/src/config/menu.ts +++ b/web-admin/src/config/menu.ts @@ -26,5 +26,16 @@ export const menuConfig: MenuItem[] = [ path: '/aiconfig', title: 'AI配置管理', icon: 'Setting' + }, + { + path: '/tools', + title: '开发工具', + icon: 'Tools', + children: [ + { + path: '/tools/api-tester', + title: 'API接口调用' + } + ] } ] \ No newline at end of file diff --git a/web-admin/src/router/index.ts b/web-admin/src/router/index.ts index 063f215..92d552c 100644 --- a/web-admin/src/router/index.ts +++ b/web-admin/src/router/index.ts @@ -63,6 +63,20 @@ const routes: RouteRecordRaw[] = [ } ] }, + { + path: '/tools', + component: Layout, + redirect: '/tools/api-tester', + meta: { title: '开发工具', icon: 'Tools' }, + children: [ + { + path: 'api-tester', + name: 'ApiTester', + component: () => import('@/views/tools/ApiTester.vue'), + meta: { title: 'API接口调用' } + } + ] + }, { path: '/:pathMatch(.*)*', name: 'NotFound', diff --git a/web-admin/src/views/tools/ApiTester.vue b/web-admin/src/views/tools/ApiTester.vue new file mode 100644 index 0000000..7cabf50 --- /dev/null +++ b/web-admin/src/views/tools/ApiTester.vue @@ -0,0 +1,659 @@ + + + + +