commit 8ea58fe48054fd5446580e3098671974d99fa7b2 Author: zuowei1216 <12206728+zuowei1216@user.noreply.gitee.com> Date: Fri Dec 19 21:27:17 2025 +0800 Initial commit - DesignerCEP Project with Caddy deployment diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea657c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,84 @@ +# ==================== Node.js ==================== +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +yarn.lock + +# ==================== Python ==================== +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +*.egg-info/ +dist/ +build/ +*.egg + +# ==================== 构建输出 ==================== +Designer/dist/ +Designer/dist_core/ +Designer/build/ +*.tsbuildinfo + +# ==================== 缓存和临时文件 ==================== +.cache/ +.temp/ +tmp/ +*.tmp +*.log +*.swp +*.swo +*~ + +# ==================== 数据库 ==================== +*.db +*.sqlite +*.sqlite3 +Server/designercep.db +tempdemo/test_api.db +tempdemo/test_auth.db + +# ==================== 敏感信息 ==================== +.env +.env.local +.env.production.local +deploy_config.json +AdminTool/deploy_config.json + +# ==================== IDE ==================== +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.DS_Store + +# ==================== 归档文件 ==================== +Server/archives/*.zip +archives/ +tempdemo/serveradmin/archives/ +tempdemo/serveradmin/tmp/ + +# ==================== 上传文件 ==================== +Server/uploads/ +upload/ +tempdemo/upload/result.zip + +# ==================== CEP 开发文件 ==================== +Designer/dist/Designer-dev/ +.debug + +# ==================== 其他 ==================== +*.zip +!templates/*.zip +*.rar +*.7z + diff --git a/AdminTool/README.md b/AdminTool/README.md new file mode 100644 index 0000000..a8eabbc --- /dev/null +++ b/AdminTool/README.md @@ -0,0 +1,330 @@ +# DesignerCEP AdminTool 使用说明 + +## 📦 安装依赖 + +```bash +cd AdminTool +pip install -r requirements.txt +``` + +--- + +## 🚀 一、自动化部署脚本 (auto_deploy_core.py) + +### 功能说明 + +自动化完成以下所有步骤: +1. ✅ 构建前端(Shell + Core) +2. ✅ 打包 Shell.zip(供 CEP 扩展下载) +3. ✅ 上传到服务器(SSH/SFTP) +4. ✅ 更新 MySQL 数据库版本号 +5. ✅ 清除本地缓存(测试用) + +### 使用方法 + +#### 1. 首次使用 - 配置服务器 + +```bash +python auto_deploy_core.py --version 1.0.6 --setup +``` + +会提示你输入: +- 服务器地址 +- SSH 端口、用户名、密码 +- 远程路径 +- MySQL 地址、端口、用户名、密码、数据库名 + +配置会保存到 `deploy_config.json`,下次直接使用。 + +#### 2. 仅构建(不部署) + +```bash +python auto_deploy_core.py --version 1.0.6 +``` + +只在本地构建 Shell 和 Core,不上传到服务器。 + +#### 3. 构建并部署 + +```bash +python auto_deploy_core.py --version 1.0.6 --deploy +``` + +构建并自动上传到服务器: +- Shell 在线登录页 → `/static/shell/` +- Core 核心应用 → `/static/core/1.0.6/` +- Shell.zip 下载包 → `/static/downloads/shell-1.0.6.zip` + +#### 4. 构建、部署并更新数据库 + +```bash +python auto_deploy_core.py --version 1.0.6 --deploy --update-db +``` + +完整流程:构建 → 上传 → 更新 MySQL 数据库版本号。 + +**数据库更新 SQL:** +```sql +UPDATE plugin_groups SET current_version = '1.0.6' WHERE id = 1; +``` + +#### 5. 其他选项 + +```bash +# 跳过清除缓存 +python auto_deploy_core.py --version 1.0.6 --deploy --skip-clean + +# 重新配置服务器 +python auto_deploy_core.py --version 1.0.6 --setup +``` + +### 配置文件示例 + +`deploy_config.json`: + +```json +{ + "host": "your-server.com", + "port": "22", + "username": "root", + "password": "your-password", + "remote_path": "/var/www/DesignerCEP/Server/static", + "mysql": { + "host": "localhost", + "port": "3306", + "username": "root", + "password": "your-mysql-password", + "database": "designer_cep", + "table": "plugin_groups" + } +} +``` + +--- + +## 🎨 二、图形化管理工具 (admin_gui.py) + +### 功能说明 + +提供可视化界面管理: +- 用户组管理 +- 用户权限管理 +- 版本上传和分配 +- **自动化部署**(新增) + +### 使用方法 + +```bash +python admin_gui.py +``` + +### 主要功能 + +#### 1. 组与用户管理 + +- 创建用户组 +- 修改组备注 +- 查看组内用户 +- 移动用户到其他组 +- 修改用户权限 + +#### 2. 发布与上传 + +- 上传 ZIP 版本文件 +- 查看历史版本 +- 分配版本给用户组 + +#### 3. 自动化部署 ⭐ (新增) + +完全图形化的部署流程: + +**3.1 服务器配置** +- 服务器地址、SSH 端口 +- 用户名、密码 +- 远程路径 +- 保存/加载配置 +- 测试 SSH 连接 + +**3.2 本地构建配置** +- 项目根目录(自动检测) +- 版本号 + +**3.3 部署选项** +- ☑️ 部署 Shell(在线登录页) +- ☑️ 部署 Core(核心应用) +- ☑️ 打包 Shell 为 .zip +- ☑️ 构建前端(npm run build) + +**3.4 一键部署** +- 点击 "🚀 开始部署" 按钮 +- 实时查看部署日志 +- 进度条显示当前进度 +- 部署完成后显示访问地址 + +--- + +## 📖 三、部署流程说明 + +### 完整部署流程 + +``` +1. 构建前端 + └─> npm run build (Designer/) + └─> 生成 dist/Shell/ 和 dist/Designer/ + +2. 打包 Shell.zip + └─> 压缩 dist/Shell/ → shell-{version}.zip + +3. 连接服务器 (SSH) + └─> 使用配置的服务器信息 + +4. 创建远程目录 + └─> /static/shell/ + └─> /static/core/{version}/ + └─> /static/downloads/ + +5. 上传文件 + └─> Shell → /static/shell/ (在线登录页) + └─> Core → /static/core/{version}/ (核心应用) + └─> shell-{version}.zip → /static/downloads/ (CEP 扩展下载) + +6. 更新数据库 (MySQL) + └─> UPDATE plugin_groups SET current_version = '{version}' + +7. 完成! + └─> 显示访问地址 +``` + +### 服务器目录结构 + +``` +/var/www/DesignerCEP/Server/static/ +├── shell/ # Shell 在线登录页 +│ ├── index.html +│ └── assets/ +├── downloads/ # 下载文件 +│ └── shell-1.0.6.zip +└── core/ # Core 核心应用 + ├── 1.0.5/ + └── 1.0.6/ + ├── index.html + └── assets/ +``` + +--- + +## 🔧 四、常见问题 + +### Q1: SSH 连接失败 + +**A**: 检查以下几点: +1. 服务器地址和端口是否正确 +2. 用户名密码是否正确 +3. 服务器防火墙是否开放 SSH 端口 +4. 本地网络是否正常 + +### Q2: MySQL 连接失败 + +**A**: 检查以下几点: +1. MySQL 地址和端口是否正确 +2. 用户名密码是否正确 +3. 数据库是否存在 +4. MySQL 是否允许远程连接 + +### Q3: 构建失败 + +**A**: +1. 检查是否安装了 Node.js 和 npm +2. 检查项目目录是否正确 +3. 运行 `npm install` 安装依赖 +4. 查看错误日志 + +### Q4: 上传速度慢 + +**A**: +1. 检查网络带宽 +2. 考虑使用国内服务器 +3. 可以先在本地构建,然后手动上传 + +### Q5: 如何回滚版本? + +**A**: +1. 在数据库中修改版本号为旧版本 +2. 或删除 `/static/core/{new_version}/` 目录 + +--- + +## 📝 五、配置文件说明 + +### deploy_config.json + +| 字段 | 说明 | 示例 | +|------|------|------| +| `host` | 服务器地址 | `your-server.com` | +| `port` | SSH 端口 | `22` | +| `username` | SSH 用户名 | `root` | +| `password` | SSH 密码 | `your-password` | +| `remote_path` | 远程静态文件路径 | `/var/www/DesignerCEP/Server/static` | +| `mysql.host` | MySQL 地址 | `localhost` | +| `mysql.port` | MySQL 端口 | `3306` | +| `mysql.username` | MySQL 用户名 | `root` | +| `mysql.password` | MySQL 密码 | `your-mysql-password` | +| `mysql.database` | 数据库名 | `designer_cep` | +| `mysql.table` | 表名 | `plugin_groups` | + +### deploy_config.txt (admin_gui.py 使用) + +纯文本格式,一行一个配置: +``` +host=your-server.com +port=22 +user=root +password=your-password +remote_path=/var/www/DesignerCEP/Server/static +project_root=D:\main\DesignerCEP +version=1.0.6 +``` + +--- + +## 🎉 六、快速开始 + +### 方法一:命令行部署(推荐) + +```bash +# 1. 首次配置 +python auto_deploy_core.py --version 1.0.6 --setup + +# 2. 部署到服务器 +python auto_deploy_core.py --version 1.0.6 --deploy --update-db + +# 完成! +``` + +### 方法二:图形化部署 + +```bash +# 1. 启动图形界面 +python admin_gui.py + +# 2. 切换到"自动化部署"标签 +# 3. 配置服务器信息 +# 4. 点击"开始部署"按钮 + +# 完成! +``` + +--- + +## 📞 技术支持 + +如遇到问题,请检查: +1. 部署日志输出 +2. 服务器 SSH 日志 +3. MySQL 连接状态 +4. 网络连接情况 + +--- + +**最后更新**: 2024-12-17 + diff --git a/AdminTool/admin_gui.py b/AdminTool/admin_gui.py new file mode 100644 index 0000000..8b1eaec --- /dev/null +++ b/AdminTool/admin_gui.py @@ -0,0 +1,994 @@ +import sys +import os +import requests +import subprocess +import paramiko +from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QLabel, QLineEdit, QPushButton, + QTabWidget, QTableWidget, QTableWidgetItem, + QHeaderView, QMessageBox, QFileDialog, QGroupBox, + QComboBox, QDialog, QFormLayout, QDialogButtonBox, + QSplitter, QFrame, QListWidget, QListWidgetItem, + QInputDialog, QGridLayout, QTextEdit, QCheckBox, + QProgressBar) +from PyQt5.QtCore import Qt, QThread, pyqtSignal + +# Configuration +DEFAULT_API_URL = " https://backend.aidg168.uk/api/v1" +DEFAULT_ADMIN_TOKEN = "admin-secret-token" + +import shutil +import zipfile +from datetime import datetime + +class ApiClient: + def __init__(self, base_url, token): + self.base_url = base_url.rstrip("/") + self.token = token + + def get_headers(self): + return {"x-admin-token": self.token} + + def list_groups(self): + try: + resp = requests.get(f"{self.base_url}/admin/groups", headers=self.get_headers()) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise Exception(f"获取组列表失败: {str(e)}") + + def create_group(self, name, comment): + try: + payload = {"name": name, "comment": comment} + resp = requests.post(f"{self.base_url}/admin/groups", json=payload, headers=self.get_headers()) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise Exception(f"创建组失败: {str(e)}") + + def upload_version(self, file_path): + try: + filename = os.path.basename(file_path) + files = {'file': (filename, open(file_path, 'rb'))} + data = {'token': self.token} + resp = requests.post(f"{self.base_url}/admin/upload_version", files=files, data=data) + resp.raise_for_status() + return resp.json().get("filename") + except Exception as e: + raise Exception(f"上传失败: {str(e)}") + + def list_archives(self): + try: + resp = requests.get(f"{self.base_url}/admin/archives", headers=self.get_headers()) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise Exception(f"获取归档列表失败: {str(e)}") + + def update_group_version(self, group_id, filename): + try: + payload = {"current_version_file": filename} + resp = requests.put(f"{self.base_url}/admin/groups/{group_id}", json=payload, headers=self.get_headers()) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise Exception(f"更新组版本失败: {str(e)}") + + def update_group_comment(self, group_id, comment): + try: + payload = {"comment": comment} + resp = requests.put(f"{self.base_url}/admin/groups/{group_id}", json=payload, headers=self.get_headers()) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise Exception(f"更新组备注失败: {str(e)}") + + def list_users(self): + try: + resp = requests.get(f"{self.base_url}/admin/users", headers=self.get_headers()) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise Exception(f"获取用户列表失败: {str(e)}") + + def update_user_group(self, user_id, group_id): + try: + # group_id is query param in backend + resp = requests.put(f"{self.base_url}/admin/users/{user_id}/group?group_id={group_id}", headers=self.get_headers()) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise Exception(f"更新用户组失败: {str(e)}") + + def update_user_permissions(self, user_id, permissions): + try: + data = {"permissions": permissions} + resp = requests.put(f"{self.base_url}/admin/users/{user_id}/permissions", data=data, headers=self.get_headers()) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise Exception(f"更新用户权限失败: {str(e)}") + +class CreateGroupDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("创建新组") + self.layout = QFormLayout(self) + + self.name_edit = QLineEdit() + self.comment_edit = QLineEdit() + + self.layout.addRow("组名称:", self.name_edit) + self.layout.addRow("备注:", self.comment_edit) + + self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + self.layout.addRow(self.buttons) + + def get_data(self): + return self.name_edit.text(), self.comment_edit.text() + +class AdminWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("DesignerCEP 管理工具") + self.resize(1000, 700) + + self.api_client = None + self.groups_data = [] # Cache groups + self.users_data = [] # Cache users + + # Main Layout + central_widget = QWidget() + self.setCentralWidget(central_widget) + main_layout = QVBoxLayout(central_widget) + + # 1. Connection Bar + conn_group = QGroupBox("服务器连接") + conn_layout = QHBoxLayout() + + self.url_edit = QLineEdit(DEFAULT_API_URL) + self.token_edit = QLineEdit(DEFAULT_ADMIN_TOKEN) + self.token_edit.setEchoMode(QLineEdit.Password) + self.connect_btn = QPushButton("连接 / 刷新") + self.connect_btn.clicked.connect(self.init_connection) + + conn_layout.addWidget(QLabel("API 地址:")) + conn_layout.addWidget(self.url_edit, 2) + conn_layout.addWidget(QLabel("Token:")) + conn_layout.addWidget(self.token_edit, 1) + conn_layout.addWidget(self.connect_btn) + + conn_group.setLayout(conn_layout) + main_layout.addWidget(conn_group) + + # 2. Main Tab Widget + self.tabs = QTabWidget() + self.tabs.setEnabled(False) + main_layout.addWidget(self.tabs) + + # Tab 1: Group & User Management (Unified View) + self.manage_tab = QWidget() + self.setup_manage_tab() + self.tabs.addTab(self.manage_tab, "组与用户管理") + + # Tab 2: Release & Upload + self.release_tab = QWidget() + self.setup_release_tab() + self.tabs.addTab(self.release_tab, "发布与上传") + + # Tab 3: Auto Deploy + self.deploy_tab = QWidget() + self.setup_deploy_tab() + self.tabs.addTab(self.deploy_tab, "自动化部署") + + # Initial Auto-Connect + self.init_connection() + + def setup_manage_tab(self): + layout = QHBoxLayout(self.manage_tab) + + # Left Panel: Group List + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + left_layout.addWidget(QLabel("用户组列表")) + + self.group_list_widget = QListWidget() + self.group_list_widget.currentItemChanged.connect(self.on_group_selected) + left_layout.addWidget(self.group_list_widget) + + self.add_group_btn = QPushButton("新建组") + self.add_group_btn.clicked.connect(self.create_group) + left_layout.addWidget(self.add_group_btn) + + layout.addWidget(left_panel, 1) + + # Right Panel: Group Details & Users + right_panel = QWidget() + right_layout = QVBoxLayout(right_panel) + + # Group Details Header + self.group_info_group = QGroupBox("组信息") + info_layout = QGridLayout() + + self.lbl_group_name = QLabel("-") + self.lbl_group_version = QLabel("-") + self.lbl_group_comment = QLabel("-") + + self.btn_edit_comment = QPushButton("修改备注") + self.btn_edit_comment.clicked.connect(self.edit_group_comment) + + info_layout.addWidget(QLabel("名称:"), 0, 0) + info_layout.addWidget(self.lbl_group_name, 0, 1) + info_layout.addWidget(QLabel("当前版本:"), 1, 0) + info_layout.addWidget(self.lbl_group_version, 1, 1) + info_layout.addWidget(QLabel("备注:"), 2, 0) + info_layout.addWidget(self.lbl_group_comment, 2, 1) + info_layout.addWidget(self.btn_edit_comment, 2, 2) + + self.group_info_group.setLayout(info_layout) + right_layout.addWidget(self.group_info_group) + + # User List + right_layout.addWidget(QLabel("组内用户")) + self.user_table = QTableWidget() + self.user_table.setColumnCount(4) + self.user_table.setHorizontalHeaderLabels(["ID", "用户名", "权限", "操作"]) + self.user_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + right_layout.addWidget(self.user_table) + + # Batch Actions + action_layout = QHBoxLayout() + self.btn_move_user = QPushButton("移动用户到其他组...") + self.btn_move_user.clicked.connect(self.move_selected_user) + self.btn_change_perm = QPushButton("修改用户权限...") + self.btn_change_perm.clicked.connect(self.change_user_perm) + + action_layout.addWidget(self.btn_move_user) + action_layout.addWidget(self.btn_change_perm) + action_layout.addStretch() + right_layout.addLayout(action_layout) + + layout.addWidget(right_panel, 3) + + def setup_release_tab(self): + layout = QVBoxLayout(self.release_tab) + + # Section 1: Upload + upload_group = QGroupBox("1. 上传新版本 (ZIP)") + upload_layout = QHBoxLayout() + + self.file_path_edit = QLineEdit() + self.file_path_edit.setReadOnly(True) + self.browse_btn = QPushButton("浏览...") + self.browse_btn.clicked.connect(self.browse_file) + self.upload_btn = QPushButton("上传") + self.upload_btn.clicked.connect(self.upload_file) + + upload_layout.addWidget(self.file_path_edit) + upload_layout.addWidget(self.browse_btn) + upload_layout.addWidget(self.upload_btn) + upload_group.setLayout(upload_layout) + layout.addWidget(upload_group) + + # Section 2: Archives List (New) + archives_group = QGroupBox("2. 历史版本列表") + archives_layout = QVBoxLayout() + + self.archives_list_widget = QListWidget() + self.archives_list_widget.currentItemChanged.connect(self.on_archive_selected) + archives_layout.addWidget(self.archives_list_widget) + + archives_group.setLayout(archives_layout) + layout.addWidget(archives_group) + + # Section 3: Assign + assign_group = QGroupBox("3. 分配版本给用户组") + assign_layout = QFormLayout() + + self.uploaded_filename_label = QLabel("无") + self.uploaded_filename_label.setStyleSheet("font-weight: bold; color: blue;") + + self.target_group_combo = QComboBox() + self.assign_btn = QPushButton("更新组版本") + self.assign_btn.clicked.connect(self.assign_version) + + assign_layout.addRow("选中版本:", self.uploaded_filename_label) + assign_layout.addRow("目标组:", self.target_group_combo) + assign_layout.addRow("", self.assign_btn) + + assign_group.setLayout(assign_layout) + layout.addWidget(assign_group) + + #layout.addStretch() # remove stretch to let list expand + + def setup_deploy_tab(self): + layout = QVBoxLayout(self.deploy_tab) + + # Section 1: 服务器配置 + server_group = QGroupBox("服务器配置") + server_layout = QGridLayout() + + server_layout.addWidget(QLabel("服务器地址:"), 0, 0) + self.deploy_host = QLineEdit("your-server.com") + server_layout.addWidget(self.deploy_host, 0, 1) + + server_layout.addWidget(QLabel("SSH 端口:"), 0, 2) + self.deploy_port = QLineEdit("22") + server_layout.addWidget(self.deploy_port, 0, 3) + + server_layout.addWidget(QLabel("用户名:"), 1, 0) + self.deploy_user = QLineEdit("root") + server_layout.addWidget(self.deploy_user, 1, 1) + + server_layout.addWidget(QLabel("密码:"), 1, 2) + self.deploy_password = QLineEdit() + self.deploy_password.setEchoMode(QLineEdit.Password) + server_layout.addWidget(self.deploy_password, 1, 3) + + server_layout.addWidget(QLabel("服务器路径:"), 2, 0) + self.deploy_remote_path = QLineEdit("/var/www/DesignerCEP/Server/static") + server_layout.addWidget(self.deploy_remote_path, 2, 1, 1, 3) + + btn_test_conn = QPushButton("测试连接") + btn_test_conn.clicked.connect(self.test_ssh_connection) + server_layout.addWidget(btn_test_conn, 3, 0) + + btn_save_config = QPushButton("保存配置") + btn_save_config.clicked.connect(self.save_deploy_config) + server_layout.addWidget(btn_save_config, 3, 1) + + btn_load_config = QPushButton("加载配置") + btn_load_config.clicked.connect(self.load_deploy_config) + server_layout.addWidget(btn_load_config, 3, 2) + + server_group.setLayout(server_layout) + layout.addWidget(server_group) + + # Section 2: 本地构建配置 + build_group = QGroupBox("本地构建配置") + build_layout = QGridLayout() + + build_layout.addWidget(QLabel("项目根目录:"), 0, 0) + self.deploy_project_root = QLineEdit(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + build_layout.addWidget(self.deploy_project_root, 0, 1) + + btn_browse_project = QPushButton("浏览...") + btn_browse_project.clicked.connect(self.browse_project_root) + build_layout.addWidget(btn_browse_project, 0, 2) + + build_layout.addWidget(QLabel("版本号:"), 1, 0) + self.deploy_version = QLineEdit("1.0.0") + build_layout.addWidget(self.deploy_version, 1, 1) + + build_group.setLayout(build_layout) + layout.addWidget(build_group) + + # Section 3: 部署选项 + options_group = QGroupBox("部署选项") + options_layout = QVBoxLayout() + + self.deploy_shell_check = QCheckBox("部署 Shell(在线登录页)") + self.deploy_shell_check.setChecked(True) + options_layout.addWidget(self.deploy_shell_check) + + self.deploy_core_check = QCheckBox("部署 Core(核心应用)") + self.deploy_core_check.setChecked(True) + options_layout.addWidget(self.deploy_core_check) + + self.deploy_shell_zip_check = QCheckBox("打包 Shell 为 .zip(供 CEP 扩展下载)") + self.deploy_shell_zip_check.setChecked(True) + options_layout.addWidget(self.deploy_shell_zip_check) + + self.deploy_build_check = QCheckBox("构建前端(npm run build)") + self.deploy_build_check.setChecked(True) + options_layout.addWidget(self.deploy_build_check) + + options_group.setLayout(options_layout) + layout.addWidget(options_group) + + # Section 4: 部署按钮 + btn_layout = QHBoxLayout() + + self.btn_deploy_start = QPushButton("🚀 开始部署") + self.btn_deploy_start.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; padding: 10px;") + self.btn_deploy_start.clicked.connect(self.start_deploy) + btn_layout.addWidget(self.btn_deploy_start) + + self.btn_deploy_stop = QPushButton("⏹ 停止") + self.btn_deploy_stop.setEnabled(False) + btn_layout.addWidget(self.btn_deploy_stop) + + layout.addLayout(btn_layout) + + # Section 5: 进度条 + self.deploy_progress = QProgressBar() + layout.addWidget(self.deploy_progress) + + # Section 6: 日志输出 + log_label = QLabel("部署日志:") + layout.addWidget(log_label) + + self.deploy_log = QTextEdit() + self.deploy_log.setReadOnly(True) + self.deploy_log.setStyleSheet("background-color: #1E1E1E; color: #D4D4D4; font-family: Consolas, monospace;") + layout.addWidget(self.deploy_log) + + # 加载配置 + self.load_deploy_config() + + def init_connection(self): + url = self.url_edit.text() + token = self.token_edit.text() + self.api_client = ApiClient(url, token) + + try: + self.refresh_all_data() + self.tabs.setEnabled(True) + self.statusBar().showMessage("连接成功", 3000) + except Exception as e: + QMessageBox.critical(self, "连接错误", str(e)) + self.tabs.setEnabled(False) + + def refresh_all_data(self): + if not self.api_client: return + try: + self.groups_data = self.api_client.list_groups() + self.users_data = self.api_client.list_users() + self.archives_data = self.api_client.list_archives() + self.update_ui_with_data() + except Exception as e: + QMessageBox.critical(self, "错误", f"刷新数据失败: {e}") + + def update_ui_with_data(self): + # Update Group List + self.group_list_widget.clear() + self.target_group_combo.clear() + + # 1. Add "Unassigned" pseudo-group + unassigned_group = { + 'id': None, + 'name': '未分配组 (Unassigned)', + 'current_version_file': '-', + 'comment': '新注册或未分配的用户' + } + item_unassigned = QListWidgetItem(unassigned_group['name']) + item_unassigned.setData(Qt.UserRole, unassigned_group) + self.group_list_widget.addItem(item_unassigned) + + # 2. Add real groups + for g in self.groups_data: + item = QListWidgetItem(g['name']) + item.setData(Qt.UserRole, g) + self.group_list_widget.addItem(item) + + # Update Combo in Release Tab + self.target_group_combo.addItem(g['name'], g['id']) + + # If there's a selection, refresh the user view, otherwise select first + if self.group_list_widget.count() > 0: + self.group_list_widget.setCurrentRow(0) + + # Update Archives List + self.archives_list_widget.clear() + for filename in self.archives_data: + self.archives_list_widget.addItem(filename) + + def on_group_selected(self, current, previous): + if not current: return + group = current.data(Qt.UserRole) + + # Update Header + self.lbl_group_name.setText(group['name']) + self.lbl_group_version.setText(group['current_version_file'] or "无") + self.lbl_group_comment.setText(group['comment'] or "无") + + # Filter Users + if group['id'] is None: + # Special handling for unassigned users + group_users = [u for u in self.users_data if u['group_id'] is None] + else: + group_users = [u for u in self.users_data if u['group_id'] == group['id']] + + self.user_table.setRowCount(len(group_users)) + + for i, u in enumerate(group_users): + self.user_table.setItem(i, 0, QTableWidgetItem(str(u['id']))) + self.user_table.setItem(i, 1, QTableWidgetItem(u['username'])) + self.user_table.setItem(i, 2, QTableWidgetItem(u['permissions'] or "")) + # Store user object in first item + self.user_table.item(i, 0).setData(Qt.UserRole, u) + + def on_archive_selected(self, current, previous): + if not current: return + filename = current.text() + self.uploaded_filename_label.setText(filename) + + def create_group(self): + dialog = CreateGroupDialog(self) + if dialog.exec() == QDialog.Accepted: + name, comment = dialog.get_data() + if not name: + QMessageBox.warning(self, "警告", "组名称必填") + return + try: + self.api_client.create_group(name, comment) + self.refresh_all_data() + QMessageBox.information(self, "成功", f"组 '{name}' 创建成功!") + except Exception as e: + QMessageBox.critical(self, "错误", str(e)) + + def edit_group_comment(self): + item = self.group_list_widget.currentItem() + if not item: return + group = item.data(Qt.UserRole) + + text, ok = QInputDialog.getText(self, "修改备注", "请输入新备注:", text=group['comment']) + if ok: + try: + self.api_client.update_group_comment(group['id'], text) + self.refresh_all_data() + except Exception as e: + QMessageBox.critical(self, "错误", str(e)) + + def move_selected_user(self): + row = self.user_table.currentRow() + if row < 0: + QMessageBox.warning(self, "警告", "请先在列表中选择一个用户") + return + + user = self.user_table.item(row, 0).data(Qt.UserRole) + + # Create a simple dialog with combo box + dialog = QDialog(self) + dialog.setWindowTitle("移动用户") + layout = QVBoxLayout(dialog) + combo = QComboBox() + for g in self.groups_data: + if g['id'] != user['group_id']: + combo.addItem(g['name'], g['id']) + + layout.addWidget(QLabel(f"将用户 {user['username']} 移动到:")) + layout.addWidget(combo) + btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + btns.accepted.connect(dialog.accept) + btns.rejected.connect(dialog.reject) + layout.addWidget(btns) + + if dialog.exec() == QDialog.Accepted: + new_group_id = combo.currentData() + if new_group_id: + try: + self.api_client.update_user_group(user['id'], new_group_id) + self.refresh_all_data() + QMessageBox.information(self, "成功", "用户移动成功") + except Exception as e: + QMessageBox.critical(self, "错误", str(e)) + + def change_user_perm(self): + row = self.user_table.currentRow() + if row < 0: + QMessageBox.warning(self, "警告", "请先在列表中选择一个用户") + return + + user = self.user_table.item(row, 0).data(Qt.UserRole) + text, ok = QInputDialog.getText(self, "修改权限", "输入权限 (逗号分隔):", text=user['permissions'] or "") + if ok: + try: + self.api_client.update_user_permissions(user['id'], text) + self.refresh_all_data() + except Exception as e: + QMessageBox.critical(self, "错误", str(e)) + + def browse_file(self): + # Allow selecting either file or directory + # QFileDialog doesn't easily support both at once, so we'll use directory for now as requested + # Or we can add a second button. + # User asked to "change to folder", implies preferring folder selection. + + # Let's support both but via directory selector if user wants to zip folder, + # or maybe we can just use getExistingDirectory. + + # Better approach: Add a choice or just use getExistingDirectory since user asked for folder support. + # But if they select a zip file, they can't. + # Let's add a "Browse Folder..." logic or modify existing. + + # User said: "Change to folder... and auto zip". + # I will change the dialog to getExistingDirectory. + + path = QFileDialog.getExistingDirectory(self, "选择发布文件夹 (自动压缩)") + if path: + self.file_path_edit.setText(path) + + def upload_file(self): + path = self.file_path_edit.text() + if not path or not os.path.exists(path): + QMessageBox.warning(self, "警告", "请先选择有效的路径。") + return + + try: + self.upload_btn.setEnabled(False) + self.upload_btn.setText("处理中...") + QApplication.processEvents() + + final_file_path = path + + # If directory, zip it first + if os.path.isdir(path): + self.upload_btn.setText("正在压缩...") + QApplication.processEvents() + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + base_name = os.path.basename(os.path.abspath(path)) + zip_filename = f"{base_name}_{timestamp}.zip" + # Create zip in temp or current dir? Current dir is safer/easier to debug + # Or better, use a temporary file to avoid clutter + + # Exclude patterns + def zip_directory(source_dir, output_filename): + with zipfile.ZipFile(output_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: + for root, dirs, files in os.walk(source_dir): + dirs[:] = [d for d in dirs if d not in ['.git', 'node_modules', 'dist', 'archives', '__pycache__']] + for file in files: + if file == output_filename or file.endswith('.zip') or file.endswith('.pyc'): + continue + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, source_dir) + zipf.write(file_path, arcname) + + zip_directory(path, zip_filename) + final_file_path = zip_filename + + self.upload_btn.setText("正在上传...") + QApplication.processEvents() + + filename = self.api_client.upload_version(final_file_path) + + # If we created a zip, maybe delete it or keep it? + # User might want to keep local backup. I'll keep it for now but log it. + + QMessageBox.information(self, "成功", f"上传成功: {filename}") + self.refresh_all_data() + except Exception as e: + QMessageBox.critical(self, "上传错误", str(e)) + finally: + self.upload_btn.setEnabled(True) + self.upload_btn.setText("上传") + + def assign_version(self): + filename = self.uploaded_filename_label.text() + if filename == "无" or not filename: + QMessageBox.warning(self, "警告", "请先上传文件。") + return + + group_id = self.target_group_combo.currentData() + if group_id is None: + QMessageBox.warning(self, "警告", "请选择目标组。") + return + + try: + self.api_client.update_group_version(group_id, filename) + self.refresh_all_data() + QMessageBox.information(self, "成功", f"组版本已更新为 {filename}") + except Exception as e: + QMessageBox.critical(self, "错误", str(e)) + + # ==================== 部署相关方法 ==================== + + def save_deploy_config(self): + """保存部署配置到本地文件""" + try: + config_file = os.path.join(os.path.dirname(__file__), 'deploy_config.txt') + with open(config_file, 'w', encoding='utf-8') as f: + f.write(f"host={self.deploy_host.text()}\n") + f.write(f"port={self.deploy_port.text()}\n") + f.write(f"user={self.deploy_user.text()}\n") + f.write(f"password={self.deploy_password.text()}\n") + f.write(f"remote_path={self.deploy_remote_path.text()}\n") + f.write(f"project_root={self.deploy_project_root.text()}\n") + f.write(f"version={self.deploy_version.text()}\n") + QMessageBox.information(self, "成功", "配置已保存") + except Exception as e: + QMessageBox.critical(self, "错误", f"保存配置失败: {e}") + + def load_deploy_config(self): + """从本地文件加载部署配置""" + try: + config_file = os.path.join(os.path.dirname(__file__), 'deploy_config.txt') + if not os.path.exists(config_file): + return + + with open(config_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if '=' in line: + key, value = line.split('=', 1) + if key == 'host': + self.deploy_host.setText(value) + elif key == 'port': + self.deploy_port.setText(value) + elif key == 'user': + self.deploy_user.setText(value) + elif key == 'password': + self.deploy_password.setText(value) + elif key == 'remote_path': + self.deploy_remote_path.setText(value) + elif key == 'project_root': + self.deploy_project_root.setText(value) + elif key == 'version': + self.deploy_version.setText(value) + except Exception as e: + self.log_deploy(f"加载配置失败: {e}") + + def browse_project_root(self): + """浏览项目根目录""" + path = QFileDialog.getExistingDirectory(self, "选择项目根目录") + if path: + self.deploy_project_root.setText(path) + + def test_ssh_connection(self): + """测试 SSH 连接""" + try: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + self.log_deploy("正在测试 SSH 连接...") + ssh.connect( + hostname=self.deploy_host.text(), + port=int(self.deploy_port.text()), + username=self.deploy_user.text(), + password=self.deploy_password.text(), + timeout=10 + ) + + stdin, stdout, stderr = ssh.exec_command('pwd') + remote_pwd = stdout.read().decode().strip() + + ssh.close() + + QMessageBox.information(self, "连接成功", f"SSH 连接测试成功!\n当前目录: {remote_pwd}") + self.log_deploy(f"✅ SSH 连接成功,当前目录: {remote_pwd}") + except Exception as e: + QMessageBox.critical(self, "连接失败", f"SSH 连接测试失败:\n{str(e)}") + self.log_deploy(f"❌ SSH 连接失败: {e}") + + def log_deploy(self, message): + """添加部署日志""" + self.deploy_log.append(message) + QApplication.processEvents() + + def start_deploy(self): + """开始部署流程""" + # 验证配置 + if not self.deploy_host.text() or not self.deploy_user.text(): + QMessageBox.warning(self, "配置不完整", "请先配置服务器信息") + return + + if not os.path.exists(self.deploy_project_root.text()): + QMessageBox.warning(self, "路径错误", "项目根目录不存在") + return + + # 确认部署 + reply = QMessageBox.question( + self, + '确认部署', + f"即将部署到服务器: {self.deploy_host.text()}\n\n" + f"部署项目:\n" + f"{'✅ Shell(在线登录页)' if self.deploy_shell_check.isChecked() else '⬜ Shell'}\n" + f"{'✅ Core(核心应用)' if self.deploy_core_check.isChecked() else '⬜ Core'}\n" + f"{'✅ Shell.zip(下载包)' if self.deploy_shell_zip_check.isChecked() else '⬜ Shell.zip'}\n\n" + f"是否继续?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + + if reply == QMessageBox.No: + return + + # 禁用按钮 + self.btn_deploy_start.setEnabled(False) + self.btn_deploy_stop.setEnabled(True) + self.deploy_progress.setValue(0) + self.deploy_log.clear() + + try: + self.run_deploy() + except Exception as e: + QMessageBox.critical(self, "部署失败", str(e)) + self.log_deploy(f"\n❌ 部署失败: {e}") + finally: + self.btn_deploy_start.setEnabled(True) + self.btn_deploy_stop.setEnabled(False) + self.deploy_progress.setValue(100) + + def run_deploy(self): + """执行部署流程""" + project_root = self.deploy_project_root.text() + designer_path = os.path.join(project_root, 'Designer') + version = self.deploy_version.text() + + self.log_deploy("="*60) + self.log_deploy("🚀 开始自动化部署流程") + self.log_deploy("="*60) + + # Step 1: 构建前端 + if self.deploy_build_check.isChecked(): + self.log_deploy("\n📦 步骤 1/5: 构建前端...") + self.deploy_progress.setValue(10) + + try: + self.log_deploy(f"执行: cd {designer_path} && npm run build") + result = subprocess.run( + 'npm run build', + cwd=designer_path, + shell=True, + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode == 0: + self.log_deploy("✅ 前端构建成功") + else: + self.log_deploy(f"❌ 构建失败: {result.stderr}") + raise Exception("前端构建失败") + except subprocess.TimeoutExpired: + raise Exception("构建超时(5分钟)") + else: + self.log_deploy("\n⏭ 跳过前端构建") + self.deploy_progress.setValue(10) + + # Step 2: 连接 SSH + self.log_deploy("\n🔐 步骤 2/5: 连接服务器...") + self.deploy_progress.setValue(30) + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + ssh.connect( + hostname=self.deploy_host.text(), + port=int(self.deploy_port.text()), + username=self.deploy_user.text(), + password=self.deploy_password.text(), + timeout=30 + ) + self.log_deploy("✅ SSH 连接成功") + except Exception as e: + raise Exception(f"SSH 连接失败: {e}") + + # Step 3: 创建服务器目录 + self.log_deploy("\n📁 步骤 3/5: 创建服务器目录...") + self.deploy_progress.setValue(40) + + remote_path = self.deploy_remote_path.text() + commands = [ + f"mkdir -p {remote_path}/shell", + f"mkdir -p {remote_path}/core/{version}", + f"mkdir -p {remote_path}/downloads" + ] + + for cmd in commands: + stdin, stdout, stderr = ssh.exec_command(cmd) + stdout.channel.recv_exit_status() + + self.log_deploy("✅ 目录创建完成") + + # Step 4: 上传文件 + self.log_deploy("\n📤 步骤 4/5: 上传文件到服务器...") + self.deploy_progress.setValue(50) + + sftp = ssh.open_sftp() + dist_path = os.path.join(designer_path, 'dist') + + try: + # 上传 Shell + if self.deploy_shell_check.isChecked(): + self.log_deploy(" 上传 Shell(在线登录页)...") + shell_src = os.path.join(dist_path, 'Shell') + shell_dst = f"{remote_path}/shell" + self._upload_directory(sftp, shell_src, shell_dst) + self.log_deploy(" ✅ Shell 上传完成") + + self.deploy_progress.setValue(60) + + # 上传 Core + if self.deploy_core_check.isChecked(): + self.log_deploy(" 上传 Core(核心应用)...") + core_src = os.path.join(dist_path, 'Designer') + core_dst = f"{remote_path}/core/{version}" + self._upload_directory(sftp, core_src, core_dst) + self.log_deploy(" ✅ Core 上传完成") + + self.deploy_progress.setValue(80) + + # 打包并上传 Shell.zip + if self.deploy_shell_zip_check.isChecked(): + self.log_deploy(" 打包 Shell.zip(供 CEP 扩展下载)...") + shell_src = os.path.join(dist_path, 'Shell') + zip_filename = f"shell-{version}.zip" + zip_path = os.path.join(dist_path, zip_filename) + + # 创建 ZIP + import zipfile + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for root, dirs, files in os.walk(shell_src): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, shell_src) + zipf.write(file_path, arcname) + + # 上传 ZIP + remote_zip = f"{remote_path}/downloads/{zip_filename}" + sftp.put(zip_path, remote_zip) + self.log_deploy(f" ✅ Shell.zip 上传完成: {zip_filename}") + + # 删除本地 ZIP + os.remove(zip_path) + + finally: + sftp.close() + + self.deploy_progress.setValue(90) + + # Step 5: 验证部署 + self.log_deploy("\n✅ 步骤 5/5: 验证部署...") + + commands_verify = [] + if self.deploy_shell_check.isChecked(): + commands_verify.append(f"ls {remote_path}/shell/index.html") + if self.deploy_core_check.isChecked(): + commands_verify.append(f"ls {remote_path}/core/{version}/index.html") + if self.deploy_shell_zip_check.isChecked(): + commands_verify.append(f"ls {remote_path}/downloads/shell-{version}.zip") + + for cmd in commands_verify: + stdin, stdout, stderr = ssh.exec_command(cmd) + exit_code = stdout.channel.recv_exit_status() + if exit_code != 0: + self.log_deploy(f" ⚠️ 验证失败: {cmd}") + + ssh.close() + + self.deploy_progress.setValue(100) + self.log_deploy("\n" + "="*60) + self.log_deploy("🎉 部署完成!") + self.log_deploy("="*60) + self.log_deploy(f"\n访问地址:") + if self.deploy_shell_check.isChecked(): + self.log_deploy(f" Shell: https://{self.deploy_host.text()}/shell/") + if self.deploy_core_check.isChecked(): + self.log_deploy(f" Core: https://{self.deploy_host.text()}/core/{version}/") + if self.deploy_shell_zip_check.isChecked(): + self.log_deploy(f" 下载: https://{self.deploy_host.text()}/downloads/shell-{version}.zip") + + QMessageBox.information(self, "部署成功", f"部署完成!\n版本: {version}") + + def _upload_directory(self, sftp, local_dir, remote_dir): + """递归上传目录""" + if not os.path.exists(local_dir): + raise Exception(f"本地目录不存在: {local_dir}") + + # 创建远程目录 + try: + sftp.stat(remote_dir) + except IOError: + sftp.mkdir(remote_dir) + + # 递归上传文件 + for item in os.listdir(local_dir): + local_path = os.path.join(local_dir, item) + remote_path = remote_dir + '/' + item + + if os.path.isfile(local_path): + self.log_deploy(f" → {item}") + sftp.put(local_path, remote_path) + elif os.path.isdir(local_path): + self._upload_directory(sftp, local_path, remote_path) + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = AdminWindow() + window.show() + sys.exit(app.exec()) diff --git a/AdminTool/auto_deploy_core.py b/AdminTool/auto_deploy_core.py new file mode 100644 index 0000000..a2f55bb --- /dev/null +++ b/AdminTool/auto_deploy_core.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Core 应用自动发布脚本 +完全自动化构建、打包、上传服务器、更新数据库 + +使用方法: + python auto_deploy_core.py --version v1.0.6 + python auto_deploy_core.py --version v1.0.6 --deploy # 部署到服务器 + python auto_deploy_core.py --version v1.0.6 --deploy --update-db # 部署并更新数据库 +""" + +import os +import sys +import shutil +import subprocess +import argparse +import zipfile +import json +from pathlib import Path +from datetime import datetime +import paramiko +import pymysql + +# ==================== 配置区域 ==================== +PROJECT_ROOT = Path(__file__).parent.parent.absolute() # 上一级目录 +DESIGNER_DIR = PROJECT_ROOT / "Designer" +DIST_CORE_DIR = DESIGNER_DIR / "dist" / "Designer" # Core 构建输出目录 +DIST_SHELL_DIR = DESIGNER_DIR / "dist" / "Shell" # Shell 构建输出目录 +SERVER_DIR = PROJECT_ROOT / "Server" +ARCHIVES_DIR = SERVER_DIR / "archives" +CONFIG_FILE = Path(__file__).parent / "deploy_config.json" + +# 颜色输出(Windows 兼容) +try: + import colorama + colorama.init() + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + BLUE = '\033[94m' + RESET = '\033[0m' +except ImportError: + GREEN = YELLOW = RED = BLUE = RESET = '' + +# ==================== 工具函数 ==================== + +def print_step(step_num, total_steps, message): + """打印步骤信息""" + print(f"\n{BLUE}{'='*60}{RESET}") + print(f"{GREEN}[步骤 {step_num}/{total_steps}] {message}{RESET}") + print(f"{BLUE}{'='*60}{RESET}\n") + +def print_success(message): + """打印成功信息""" + print(f"{GREEN}✓ {message}{RESET}") + +def print_warning(message): + """打印警告信息""" + print(f"{YELLOW}⚠ {message}{RESET}") + +def print_error(message): + """打印错误信息""" + print(f"{RED}✗ {message}{RESET}") + +def run_command(command, cwd=None, shell=True, check=True): + """运行命令并返回结果""" + print(f" 执行: {command}") + try: + result = subprocess.run( + command, + cwd=cwd, + shell=shell, + check=check, + capture_output=True, + text=True, + encoding='utf-8', + errors='replace' + ) + if result.stdout: + print(f" 输出: {result.stdout.strip()}") + return result + except subprocess.CalledProcessError as e: + print_error(f"命令执行失败: {e}") + if e.stderr: + print(f" 错误: {e.stderr}") + if check: + sys.exit(1) + return None + +# ==================== 发布步骤 ==================== + +def load_config(): + """加载部署配置""" + if not CONFIG_FILE.exists(): + return None + + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print_warning(f"加载配置文件失败: {e}") + return None + +def save_config(config): + """保存部署配置""" + try: + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + print_success(f"配置已保存: {CONFIG_FILE}") + except Exception as e: + print_error(f"保存配置失败: {e}") + +def step1_build_frontend(): + """步骤 1: 构建前端(Shell + Core)""" + print_step(1, 8, "构建前端(Shell + Core)") + + # 检查 package.json + package_json = DESIGNER_DIR / "package.json" + if not package_json.exists(): + print_error(f"未找到 package.json: {package_json}") + sys.exit(1) + + # 运行构建命令 + os.chdir(DESIGNER_DIR) + print(" 正在构建 Core...") + run_command("npm run build:core", cwd=DESIGNER_DIR) + print(" 正在构建 Shell...") + run_command("npm run build", cwd=DESIGNER_DIR) + + # 验证输出目录 + if not DIST_CORE_DIR.exists(): + print_error(f"Core 构建输出目录不存在: {DIST_CORE_DIR}") + sys.exit(1) + + if not DIST_SHELL_DIR.exists(): + print_error(f"Shell 构建输出目录不存在: {DIST_SHELL_DIR}") + sys.exit(1) + + print_success(f"构建完成") + print(f" Core: {DIST_CORE_DIR}") + print(f" Shell: {DIST_SHELL_DIR}") + +def step2_package_shell_zip(version): + """步骤 2: 打包 Shell 为 ZIP(供 CEP 扩展下载)""" + print_step(2, 8, "打包 Shell 为 ZIP") + + # 生成 ZIP 文件名 + zip_filename = f"shell-{version}.zip" + zip_path = DESIGNER_DIR / "dist" / zip_filename + + # 如果文件已存在,先删除 + if zip_path.exists(): + print_warning(f"ZIP 文件已存在,删除: {zip_path}") + zip_path.unlink() + + # 创建 ZIP + print(f" 创建 ZIP: {zip_path}") + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for root, dirs, files in os.walk(DIST_SHELL_DIR): + for file in files: + file_path = Path(root) / file + arcname = file_path.relative_to(DIST_SHELL_DIR) + zipf.write(file_path, arcname) + + # 显示文件大小 + size_mb = zip_path.stat().st_size / (1024 * 1024) + print_success(f"Shell 打包完成: {zip_path}") + print(f" 文件大小: {size_mb:.2f} MB") + + return zip_path + +def step3_upload_to_server(version, config): + """步骤 3: 上传到服务器""" + print_step(3, 8, "上传到服务器") + + if not config: + print_warning("未配置服务器信息,跳过上传") + return + + # 连接 SSH + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + print(f" 连接服务器: {config['host']}") + ssh.connect( + hostname=config['host'], + port=int(config.get('port', 22)), + username=config['username'], + password=config.get('password', ''), + timeout=30 + ) + print_success("SSH 连接成功") + + sftp = ssh.open_sftp() + remote_path = config['remote_path'] + + # 1. 创建远程目录 + print(" 创建远程目录...") + commands = [ + f"mkdir -p {remote_path}/shell", + f"mkdir -p {remote_path}/core/{version}", + f"mkdir -p {remote_path}/downloads" + ] + for cmd in commands: + stdin, stdout, stderr = ssh.exec_command(cmd) + stdout.channel.recv_exit_status() + print_success("目录创建完成") + + # 2. 上传 Shell(在线登录页) + print(" 上传 Shell(在线登录页)...") + upload_directory(sftp, DIST_SHELL_DIR, f"{remote_path}/shell") + print_success("Shell 上传完成") + + # 3. 上传 Core + print(" 上传 Core(核心应用)...") + upload_directory(sftp, DIST_CORE_DIR, f"{remote_path}/core/{version}") + print_success("Core 上传完成") + + # 4. 上传 Shell.zip + print(" 上传 Shell.zip(CEP 扩展下载)...") + shell_zip = DESIGNER_DIR / "dist" / f"shell-{version}.zip" + if shell_zip.exists(): + remote_zip = f"{remote_path}/downloads/shell-{version}.zip" + sftp.put(str(shell_zip), remote_zip) + print_success(f"Shell.zip 上传完成: {remote_zip}") + + sftp.close() + ssh.close() + + print_success("所有文件上传完成") + print(f"\n访问地址:") + print(f" Shell: https://{config['host']}/shell/") + print(f" Core: https://{config['host']}/core/{version}/") + print(f" 下载: https://{config['host']}/downloads/shell-{version}.zip") + + except Exception as e: + print_error(f"上传失败: {e}") + raise + finally: + ssh.close() + +def upload_directory(sftp, local_dir, remote_dir): + """递归上传目录""" + if not os.path.exists(local_dir): + raise Exception(f"本地目录不存在: {local_dir}") + + # 创建远程目录 + try: + sftp.stat(remote_dir) + except IOError: + sftp.mkdir(remote_dir) + + # 递归上传文件 + for item in os.listdir(local_dir): + local_path = os.path.join(local_dir, item) + remote_path = remote_dir + '/' + item + + if os.path.isfile(local_path): + print(f" → {item}") + sftp.put(local_path, remote_path) + elif os.path.isdir(local_path): + upload_directory(sftp, local_path, remote_path) + +def step4_update_mysql(version, config): + """步骤 4: 更新 MySQL 数据库 (通过 SSH 执行 Docker 命令)""" + print_step(4, 8, "更新 MySQL 数据库") + + # 注意:我们不再直接连接 MySQL,而是通过 SSH 发送 docker exec 命令 + # 这样用户不需要暴露 MySQL 端口,更安全 + + if not config: + print_warning("未配置服务器信息,跳过数据库更新") + return + + try: + # 连接 SSH + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + print(f" 连接服务器: {config['host']}") + ssh.connect( + hostname=config['host'], + port=int(config.get('port', 22)), + username=config['username'], + password=config.get('password', ''), + timeout=30 + ) + print_success("SSH 连接成功") + + # 构造 SQL 语句 + # 更新 'default' 分组为最新版本 (更健壮,不依赖 ID=1) + sql = f"UPDATE plugin_groups SET current_version_file = 'core-v{version}.zip' WHERE name = 'default';" + + # 构造 Docker 命令 + # 假设容器名为 designercep_db,用户 designer_user,密码 DesignerPass123!,库名 designer_db + # 这些应该与 docker-compose.yml 保持一致 + db_user = "designer_user" + db_pass = "DesignerPass123!" + db_name = "designer_db" + container_name = "designercep_db" + + print(f" 执行远程 Docker SQL 命令...") + print(f" SQL: {sql}") + + docker_cmd = f'docker exec -i {container_name} mysql -u{db_user} -p{db_pass} {db_name} -e "{sql}"' + + stdin, stdout, stderr = ssh.exec_command(docker_cmd) + exit_status = stdout.channel.recv_exit_status() + + if exit_status == 0: + print_success("数据库更新命令执行成功") + print(f" 输出: {stdout.read().decode().strip()}") + else: + print_error(f"数据库更新失败 (Exit code: {exit_status})") + print(f" 错误: {stderr.read().decode().strip()}") + raise Exception("Docker exec failed") + + ssh.close() + + except Exception as e: + print_error(f"数据库更新失败: {e}") + print_warning("您需要手动执行 SQL (进入容器执行):") + print(f" {sql}") + # 不抛出异常,以免打断流程,因为这步失败通常不影响文件上传 + + +def step5_clean_cache(): + """步骤 5: 清除客户端缓存(测试用)""" + print_step(5, 8, "清除客户端缓存(可选)") + + cache_dir = Path.home() / "AppData" / "Roaming" / "DesignerCache" + + if not cache_dir.exists(): + print_warning(f"缓存目录不存在: {cache_dir}") + return + + try: + shutil.rmtree(cache_dir) + print_success(f"缓存已清除: {cache_dir}") + except Exception as e: + print_warning(f"清除缓存失败: {e}") + print(" 您可以手动删除缓存目录") + +def step6_setup_config(): + """步骤 6: 配置服务器信息(首次运行)""" + print_step(6, 8, "配置服务器信息") + + config = load_config() + if config: + print_warning("配置文件已存在,跳过配置") + print(f" 配置文件: {CONFIG_FILE}") + return config + + print("请输入服务器配置信息:") + print() + + config = {} + + # SSH 配置 + config['host'] = input(" 服务器地址: ").strip() + config['port'] = input(" SSH 端口 [22]: ").strip() or "22" + config['username'] = input(" SSH 用户名: ").strip() + config['password'] = input(" SSH 密码: ").strip() + config['remote_path'] = input(" 远程路径 [/var/www/DesignerCEP/Server/static]: ").strip() \ + or "/var/www/DesignerCEP/Server/static" + + print() + + # MySQL 配置 + use_mysql = input(" 是否配置 MySQL? (y/n) [y]: ").strip().lower() + if use_mysql != 'n': + config['mysql'] = {} + config['mysql']['host'] = input(" MySQL 地址: ").strip() + config['mysql']['port'] = input(" MySQL 端口 [3306]: ").strip() or "3306" + config['mysql']['username'] = input(" MySQL 用户名: ").strip() + config['mysql']['password'] = input(" MySQL 密码: ").strip() + config['mysql']['database'] = input(" 数据库名: ").strip() + config['mysql']['table'] = input(" 表名 [plugin_groups]: ").strip() or "plugin_groups" + + # 保存配置 + save_config(config) + + return config + +def step7_summary(version, deployed): + """步骤 7: 发布总结""" + print_step(7, 8, "发布总结") + + print(f""" +{GREEN}{'='*60} + DesignerCEP 发布完成! +{'='*60}{RESET} + +[发布信息] + 版本号: {version} + 构建时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} + 部署状态: {'已部署到服务器' if deployed else '仅本地构建'} + +[构建产物] + Core: {DIST_CORE_DIR} + Shell: {DIST_SHELL_DIR} + Shell.zip: {DESIGNER_DIR / 'dist' / f'shell-{version}.zip'} + +[后续步骤] + 1. {'✓' if deployed else '○'} 验证服务器文件 + 2. {'✓' if deployed else '○'} 确认数据库版本 + 3. ○ 测试客户端更新功能 + 4. ○ 通知用户更新 + +[测试方法] + 1. 删除客户端缓存(已自动执行) + 2. 重启 Photoshop 插件 + 3. 登录账号,检查是否下载新版本 + 4. 验证功能是否正常 + +{GREEN}{'='*60}{RESET} +""") + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser( + description='DesignerCEP 自动发布脚本(Shell + Core)', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + # 仅构建(不部署) + python auto_deploy_core.py --version 1.0.6 + + # 构建并部署到服务器 + python auto_deploy_core.py --version 1.0.6 --deploy + + # 构建、部署并更新数据库 + python auto_deploy_core.py --version 1.0.6 --deploy --update-db + + # 首次运行,配置服务器信息 + python auto_deploy_core.py --version 1.0.6 --setup + """ + ) + + parser.add_argument( + '--version', '-v', + required=True, + help='版本号,例如: 1.0.6(不要加 v)' + ) + + parser.add_argument( + '--deploy', '-d', + action='store_true', + help='部署到服务器' + ) + + parser.add_argument( + '--update-db', + action='store_true', + help='自动更新 MySQL 数据库' + ) + + parser.add_argument( + '--setup', + action='store_true', + help='配置服务器信息(首次运行)' + ) + + parser.add_argument( + '--skip-clean', + action='store_true', + help='跳过清除缓存步骤' + ) + + args = parser.parse_args() + + version = args.version + + print(f""" +{BLUE}{'='*60} + DesignerCEP 自动发布脚本 +{'='*60}{RESET} + +[配置信息] + 版本号: {version} + 项目根目录: {PROJECT_ROOT} + Designer: {DESIGNER_DIR} + 部署到服务器: {'是' if args.deploy else '否'} + 更新数据库: {'是' if args.update_db else '否'} + 清除缓存: {'否' if args.skip_clean else '是'} + +{BLUE}{'='*60}{RESET} +""") + + try: + # 加载配置 + config = None + if args.deploy or args.setup: + config = load_config() + if not config or args.setup: + config = step6_setup_config() + + # 执行发布步骤 + step1_build_frontend() + step2_package_shell_zip(version) + + if args.deploy: + if not config: + print_error("未找到配置文件,请先运行 --setup 配置服务器") + sys.exit(1) + + step3_upload_to_server(version, config) + + if args.update_db: + step4_update_mysql(version, config) + else: + print_step(4, 8, "更新数据库(已跳过)") + print_warning("数据库未自动更新,请手动执行:") + print(f" UPDATE plugin_groups SET current_version = '{version}';") + else: + print_step(3, 8, "上传到服务器(已跳过)") + print_step(4, 8, "更新数据库(已跳过)") + + if not args.skip_clean: + step5_clean_cache() + else: + print_step(5, 8, "清除缓存(已跳过)") + + step7_summary(version, args.deploy) + + except KeyboardInterrupt: + print_error("\n用户中断") + sys.exit(1) + except Exception as e: + print_error(f"发布失败: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + +if __name__ == "__main__": + main() + diff --git a/AdminTool/deploy_core_only.py b/AdminTool/deploy_core_only.py new file mode 100644 index 0000000..531bb0a --- /dev/null +++ b/AdminTool/deploy_core_only.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Core 快速发布脚本 +仅执行:构建 Core -> 上传 Core -> 更新数据库 +跳过 Shell 的构建和上传 +""" + +import os +import sys +import shutil +import subprocess +import argparse +import zipfile +import json +from pathlib import Path +from datetime import datetime +import paramiko + +# ==================== 配置区域 ==================== +PROJECT_ROOT = Path(__file__).parent.parent.absolute() +DESIGNER_DIR = PROJECT_ROOT / "Designer" +DIST_CORE_DIR = DESIGNER_DIR / "dist_core" # Core 构建输出目录 +CONFIG_FILE = Path(__file__).parent / "deploy_config.json" + +# 颜色输出 +try: + import colorama + colorama.init() + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + BLUE = '\033[94m' + RESET = '\033[0m' +except ImportError: + GREEN = YELLOW = RED = BLUE = RESET = '' + +# ==================== 工具函数 ==================== + +def print_step(message): + print(f"\n{BLUE}{'='*60}{RESET}") + print(f"{GREEN}{message}{RESET}") + print(f"{BLUE}{'='*60}{RESET}\n") + +def print_success(message): + print(f"{GREEN}✓ {message}{RESET}") + +def print_error(message): + print(f"{RED}✗ {message}{RESET}") + +def run_command(command, cwd=None): + print(f" 执行: {command}") + try: + result = subprocess.run( + command, + cwd=cwd, + shell=True, + check=True, + capture_output=True, + text=True, + encoding='utf-8', + errors='replace' + ) + if result.stdout: + print(f" 输出: {result.stdout.strip()[:200]}...") # 只打印前200字符 + return result + except subprocess.CalledProcessError as e: + print_error(f"命令执行失败: {e}") + if e.stderr: + print(f" 错误: {e.stderr}") + sys.exit(1) + +def load_config(): + if not CONFIG_FILE.exists(): + print_error("未找到配置文件 deploy_config.json") + sys.exit(1) + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + +# ==================== 核心步骤 ==================== + +def step1_build_core(): + print_step("步骤 1: 构建 Core") + + # 检查目录 + if not DESIGNER_DIR.exists(): + print_error(f"Designer 目录不存在: {DESIGNER_DIR}") + sys.exit(1) + + # 执行构建 + print(" 正在构建 Core (npm run build:core)...") + os.chdir(DESIGNER_DIR) + run_command("npm run build:core", cwd=DESIGNER_DIR) + + # 验证 + if not DIST_CORE_DIR.exists(): + print_error(f"Core 构建失败,输出目录不存在: {DIST_CORE_DIR}") + sys.exit(1) + + print_success("Core 构建完成") + +def step2_upload_core(version, config): + print_step("步骤 2: 上传 Core 到服务器") + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + print(f" 连接服务器: {config['host']}") + ssh.connect( + hostname=config['host'], + port=int(config.get('port', 22)), + username=config['username'], + password=config.get('password', ''), + timeout=30 + ) + print_success("SSH 连接成功") + + sftp = ssh.open_sftp() + remote_base = config['remote_path'] + remote_core_dir = f"{remote_base}/core/{version}" + + # 创建远程目录 + print(f" 创建远程目录: {remote_core_dir}") + ssh.exec_command(f"mkdir -p {remote_core_dir}") + + # 递归上传 + print(" 开始上传文件...") + upload_count = 0 + for root, dirs, files in os.walk(DIST_CORE_DIR): + relative_root = Path(root).relative_to(DIST_CORE_DIR) + remote_root = f"{remote_core_dir}/{relative_root}".replace("\\", "/").rstrip("/") + if str(relative_root) == ".": + remote_root = remote_core_dir + + # 确保子目录存在 + try: + sftp.stat(remote_root) + except IOError: + sftp.mkdir(remote_root) + + for file in files: + local_file = Path(root) / file + remote_file = f"{remote_root}/{file}" + sftp.put(str(local_file), remote_file) + upload_count += 1 + if upload_count % 10 == 0: + print(f" 已上传 {upload_count} 个文件...", end='\r') + + print(f"\n 共上传 {upload_count} 个文件") + print_success("Core 上传完成") + + sftp.close() + ssh.close() + + except Exception as e: + print_error(f"上传失败: {e}") + raise + +def step3_update_db(version, config): + print_step("步骤 3: 更新数据库") + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + ssh.connect( + hostname=config['host'], + port=int(config.get('port', 22)), + username=config['username'], + password=config.get('password', ''), + timeout=30 + ) + + # 构造 SQL + sql = f"UPDATE plugin_groups SET current_version_file = 'core-v{version}.zip' WHERE name = 'default';" + + # 数据库配置 + db_conf = config.get('mysql', {}) + db_user = db_conf.get('username', 'designer_user') + db_pass = db_conf.get('password', 'DesignerPass123!') + db_name = db_conf.get('database', 'designer_db') + container_name = "designercep_db" + + print(f" 更新 'default' 分组版本为: core-v{version}.zip") + docker_cmd = f'docker exec -i {container_name} mysql -u{db_user} -p{db_pass} {db_name} -e "{sql}"' + + stdin, stdout, stderr = ssh.exec_command(docker_cmd) + exit_status = stdout.channel.recv_exit_status() + + if exit_status == 0: + print_success("数据库更新成功") + else: + print_error(f"数据库更新失败: {stderr.read().decode().strip()}") + raise Exception("DB Update Failed") + + ssh.close() + + except Exception as e: + print_error(f"数据库操作失败: {e}") + raise + +def step4_clean_cache(): + print_step("步骤 4: 清除本地缓存") + cache_dir = Path.home() / "AppData" / "Roaming" / "DesignerCache" + if cache_dir.exists(): + try: + shutil.rmtree(cache_dir) + print_success(f"已清除: {cache_dir}") + except Exception as e: + print_error(f"清除失败: {e}") + else: + print(" 无需清除") + +# ==================== 主入口 ==================== + +def main(): + parser = argparse.ArgumentParser(description='Core 快速发布脚本') + parser.add_argument('--version', '-v', required=True, help='版本号 (如 1.0.7)') + args = parser.parse_args() + + version = args.version + + print(f"{BLUE}开始发布 Core - 版本: {version}{RESET}") + + try: + config = load_config() + + step1_build_core() + step2_upload_core(version, config) + step3_update_db(version, config) + step4_clean_cache() + + print_step("🎉 发布全部完成!") + print(f" 版本: {version}") + print(f" 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + except Exception as e: + print_error(f"\n发布过程中止: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/AdminTool/fix_default_group.py b/AdminTool/fix_default_group.py new file mode 100644 index 0000000..59a485e --- /dev/null +++ b/AdminTool/fix_default_group.py @@ -0,0 +1,69 @@ +import requests +import sys + +# Configuration from admin_gui.py +API_URL = "https://backend.aidg168.uk/api/v1" +TOKEN = "admin-secret-token" +HEADERS = {"x-admin-token": TOKEN} + +def fix_group(): + print(f"Connecting to {API_URL}...") + + # 1. List groups + try: + resp = requests.get(f"{API_URL}/admin/groups", headers=HEADERS) + resp.raise_for_status() + groups = resp.json() + except Exception as e: + print(f"Error listing groups: {e}") + print("Please check your network connection or server status.") + return + + print(f"Found {len(groups)} groups.") + + target_group = None + default_exists = False + + for g in groups: + print(f" - ID: {g['id']}, Name: {g['name']}, Comment: {g.get('comment')}") + if g['name'] == '默认': + target_group = g + if g['name'] == 'default': + default_exists = True + + if default_exists: + print("\n[OK] Group 'default' already exists.") + if target_group: + print("Warning: Group '默认' also exists. Please verify which one you want to keep.") + else: + print("Everything looks good.") + return + + if target_group: + print(f"\nFound group '默认' (ID: {target_group['id']}). Renaming to 'default'...") + try: + url = f"{API_URL}/admin/groups/{target_group['id']}" + # The API expects JSON body with fields to update + resp = requests.put(url, json={"name": "default"}, headers=HEADERS) + resp.raise_for_status() + print("Success! Group renamed to 'default'.") + except Exception as e: + print(f"Error renaming group: {e}") + if hasattr(e, 'response') and e.response: + print(f"Server response: {e.response.text}") + else: + print("\nGroup '默认' not found and 'default' does not exist.") + print("Creating 'default' group...") + try: + url = f"{API_URL}/admin/groups" + payload = {"name": "default", "comment": "Default User Group"} + resp = requests.post(url, json=payload, headers=HEADERS) + resp.raise_for_status() + print("Success! Group 'default' created.") + except Exception as e: + print(f"Error creating group: {e}") + if hasattr(e, 'response') and e.response: + print(f"Server response: {e.response.text}") + +if __name__ == "__main__": + fix_group() diff --git a/AdminTool/requirements.txt b/AdminTool/requirements.txt new file mode 100644 index 0000000..f61f9df --- /dev/null +++ b/AdminTool/requirements.txt @@ -0,0 +1,5 @@ +PyQt5 +requests +paramiko +pymysql +colorama diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..5135724 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,131 @@ +# DesignerCEP Caddy 配置文件 +# +# 部署架构: +# - app.aidg168.uk → 前端应用(登录 + 主功能) +# - backend.aidg168.uk → 后端 API +# +# 使用方法: +# 1. 将此文件上传到服务器 /etc/caddy/Caddyfile +# 2. sudo caddy validate --config /etc/caddy/Caddyfile +# 3. sudo systemctl restart caddy + +# ==================== 全局配置 ==================== +{ + # 如果使用 Cloudflare,关闭自动 HTTPS + # auto_https off + + # 如果不使用 Cloudflare,保持默认(自动申请证书) + email admin@aidg168.uk +} + +# ==================== 前端应用 ==================== +app.aidg168.uk { + # 静态文件根目录 + root * /var/www/DesignerCEP/Server/static/app + + # SPA 路由支持(重要!) + # 所有路由都返回 index.html,让 Vue Router 处理 + try_files {path} /index.html + + # 提供静态文件 + file_server + + # ========== 缓存策略 ========== + + # HTML 文件不缓存(确保更新即时生效) + @html { + path *.html + } + header @html { + Cache-Control "no-cache, no-store, must-revalidate" + Pragma "no-cache" + Expires "0" + } + + # JS/CSS 长期缓存(文件名有 hash,可以安全缓存) + @assets { + path *.js *.css *.woff *.woff2 *.ttf *.eot + } + header @assets { + Cache-Control "public, max-age=31536000, immutable" + } + + # 图片缓存 + @images { + path *.png *.jpg *.jpeg *.gif *.svg *.ico *.webp + } + header @images { + Cache-Control "public, max-age=2592000" + } + + # ========== 安全头 ========== + header { + # 允许在 iframe 中加载(CEP 需要) + X-Frame-Options "SAMEORIGIN" + + # 防止 MIME 类型嗅探 + X-Content-Type-Options "nosniff" + + # XSS 保护 + X-XSS-Protection "1; mode=block" + + # 隐藏服务器信息 + -Server + } + + # ========== 压缩 ========== + encode { + gzip 6 + zstd + } + + # ========== 日志 ========== + log { + output file /var/log/caddy/app.aidg168.uk.log { + roll_size 50mb + roll_keep 10 + roll_keep_for 720h + } + format json + level INFO + } +} + +# ==================== 后端 API ==================== +backend.aidg168.uk { + # 反向代理到 FastAPI + reverse_proxy localhost:8000 { + # 传递客户端真实 IP + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host {host} + + # 超时设置 + transport http { + dial_timeout 5s + response_header_timeout 30s + } + } + + # ========== 压缩 ========== + encode gzip + + # ========== 日志 ========== + log { + output file /var/log/caddy/backend.aidg168.uk.log { + roll_size 50mb + roll_keep 10 + roll_keep_for 720h + } + format json + level INFO + } +} + +# ==================== 主域名重定向(可选)==================== +aidg168.uk, www.aidg168.uk { + # 重定向到应用 + redir https://app.aidg168.uk{uri} permanent +} + diff --git a/Designer b/Designer new file mode 160000 index 0000000..28947f1 --- /dev/null +++ b/Designer @@ -0,0 +1 @@ +Subproject commit 28947f13fd1cee9f2e0e17e6f9929fc73b9a32fb diff --git a/Server/.dockerignore b/Server/.dockerignore new file mode 100644 index 0000000..bddba2a --- /dev/null +++ b/Server/.dockerignore @@ -0,0 +1,53 @@ +# Ignore git files +.git +.gitignore + +# Ignore python cache +__pycache__ +*.pyc +*.pyo +*.pyd + +# Ignore test cache +.pytest_cache + +# Ignore local env (env vars should be passed to docker) +.env + +# Ignore databases (should be mounted as volumes) +*.db +*.sqlite + +# Ignore test files +tests/ +test_*.py +*_test.py + +# Ignore documentation +API_DOCUMENTATION.md +tempdocs/ + +# Ignore temporary/extra directories +tempdemo/ +test_unzip/ +dist_core_*.zip +plugin_v1.0.zip +test_plugin_v1.0.zip + +# Ignore deployment scripts +deploy_core.py +publish.py +update_version.py +update_version.py.bak + +# Ignore archives (should be a volume) +archives/ + +# Ignore frontend source/build if not served by backend (but user said Shell is mounted in Dev) +# Assuming for production we might want to copy it if we serve it, or ignore it if Nginx serves it. +# For a simple "all-in-one" container, we might keep it. +# However, `Designer/` seems to be the built frontend assets. +# Let's keep `Designer/` for now as the app mounts it in DEV mode, +# and user might want to run it similarly or we can configure it. +# But usually node_modules should be ignored if they exist there. +Designer/node_modules/ diff --git a/Server/Designer/CSInterface.js b/Server/Designer/CSInterface.js new file mode 100644 index 0000000..fe0a761 --- /dev/null +++ b/Server/Designer/CSInterface.js @@ -0,0 +1,1291 @@ +/************************************************************************************************** +* +* ADOBE SYSTEMS INCORPORATED +* Copyright 2013 Adobe Systems Incorporated +* All Rights Reserved. +* +* NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the +* terms of the Adobe license agreement accompanying it. If you have received this file from a +* source other than Adobe, then your use, modification, or distribution of it requires the prior +* written permission of Adobe. +* +**************************************************************************************************/ + +/** CSInterface - v9.4.0 */ + +/** + * Stores constants for the window types supported by the CSXS infrastructure. + */ +function CSXSWindowType() +{ +} + +/** Constant for the CSXS window type Panel. */ +CSXSWindowType._PANEL = "Panel"; + +/** Constant for the CSXS window type Modeless. */ +CSXSWindowType._MODELESS = "Modeless"; + +/** Constant for the CSXS window type ModalDialog. */ +CSXSWindowType._MODAL_DIALOG = "ModalDialog"; + +/** EvalScript error message */ +EvalScript_ErrMessage = "EvalScript error."; + +/** + * @class Version + * Defines a version number with major, minor, micro, and special + * components. The major, minor and micro values are numeric; the special + * value can be any string. + * + * @param major The major version component, a positive integer up to nine digits long. + * @param minor The minor version component, a positive integer up to nine digits long. + * @param micro The micro version component, a positive integer up to nine digits long. + * @param special The special version component, an arbitrary string. + * + * @return A new \c Version object. + */ +function Version(major, minor, micro, special) +{ + this.major = major; + this.minor = minor; + this.micro = micro; + this.special = special; +} + +/** + * The maximum value allowed for a numeric version component. + * This reflects the maximum value allowed in PlugPlug and the manifest schema. + */ +Version.MAX_NUM = 999999999; + +/** + * @class VersionBound + * Defines a boundary for a version range, which associates a \c Version object + * with a flag for whether it is an inclusive or exclusive boundary. + * + * @param version The \c #Version object. + * @param inclusive True if this boundary is inclusive, false if it is exclusive. + * + * @return A new \c VersionBound object. + */ +function VersionBound(version, inclusive) +{ + this.version = version; + this.inclusive = inclusive; +} + +/** + * @class VersionRange + * Defines a range of versions using a lower boundary and optional upper boundary. + * + * @param lowerBound The \c #VersionBound object. + * @param upperBound The \c #VersionBound object, or null for a range with no upper boundary. + * + * @return A new \c VersionRange object. + */ +function VersionRange(lowerBound, upperBound) +{ + this.lowerBound = lowerBound; + this.upperBound = upperBound; +} + +/** + * @class Runtime + * Represents a runtime related to the CEP infrastructure. + * Extensions can declare dependencies on particular + * CEP runtime versions in the extension manifest. + * + * @param name The runtime name. + * @param version A \c #VersionRange object that defines a range of valid versions. + * + * @return A new \c Runtime object. + */ +function Runtime(name, versionRange) +{ + this.name = name; + this.versionRange = versionRange; +} + +/** +* @class Extension +* Encapsulates a CEP-based extension to an Adobe application. +* +* @param id The unique identifier of this extension. +* @param name The localizable display name of this extension. +* @param mainPath The path of the "index.html" file. +* @param basePath The base path of this extension. +* @param windowType The window type of the main window of this extension. + Valid values are defined by \c #CSXSWindowType. +* @param width The default width in pixels of the main window of this extension. +* @param height The default height in pixels of the main window of this extension. +* @param minWidth The minimum width in pixels of the main window of this extension. +* @param minHeight The minimum height in pixels of the main window of this extension. +* @param maxWidth The maximum width in pixels of the main window of this extension. +* @param maxHeight The maximum height in pixels of the main window of this extension. +* @param defaultExtensionDataXml The extension data contained in the default \c ExtensionDispatchInfo section of the extension manifest. +* @param specialExtensionDataXml The extension data contained in the application-specific \c ExtensionDispatchInfo section of the extension manifest. +* @param requiredRuntimeList An array of \c Runtime objects for runtimes required by this extension. +* @param isAutoVisible True if this extension is visible on loading. +* @param isPluginExtension True if this extension has been deployed in the Plugins folder of the host application. +* +* @return A new \c Extension object. +*/ +function Extension(id, name, mainPath, basePath, windowType, width, height, minWidth, minHeight, maxWidth, maxHeight, + defaultExtensionDataXml, specialExtensionDataXml, requiredRuntimeList, isAutoVisible, isPluginExtension) +{ + this.id = id; + this.name = name; + this.mainPath = mainPath; + this.basePath = basePath; + this.windowType = windowType; + this.width = width; + this.height = height; + this.minWidth = minWidth; + this.minHeight = minHeight; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + this.defaultExtensionDataXml = defaultExtensionDataXml; + this.specialExtensionDataXml = specialExtensionDataXml; + this.requiredRuntimeList = requiredRuntimeList; + this.isAutoVisible = isAutoVisible; + this.isPluginExtension = isPluginExtension; +} + +/** + * @class CSEvent + * A standard JavaScript event, the base class for CEP events. + * + * @param type The name of the event type. + * @param scope The scope of event, can be "GLOBAL" or "APPLICATION". + * @param appId The unique identifier of the application that generated the event. + * @param extensionId The unique identifier of the extension that generated the event. + * + * @return A new \c CSEvent object + */ +function CSEvent(type, scope, appId, extensionId) +{ + this.type = type; + this.scope = scope; + this.appId = appId; + this.extensionId = extensionId; +} + +/** Event-specific data. */ +CSEvent.prototype.data = ""; + +/** + * @class SystemPath + * Stores operating-system-specific location constants for use in the + * \c #CSInterface.getSystemPath() method. + * @return A new \c SystemPath object. + */ +function SystemPath() +{ +} + +/** The path to user data. */ +SystemPath.USER_DATA = "userData"; + +/** The path to common files for Adobe applications. */ +SystemPath.COMMON_FILES = "commonFiles"; + +/** The path to the user's default document folder. */ +SystemPath.MY_DOCUMENTS = "myDocuments"; + +/** @deprecated. Use \c #SystemPath.Extension. */ +SystemPath.APPLICATION = "application"; + +/** The path to current extension. */ +SystemPath.EXTENSION = "extension"; + +/** The path to hosting application's executable. */ +SystemPath.HOST_APPLICATION = "hostApplication"; + +/** + * @class ColorType + * Stores color-type constants. + */ +function ColorType() +{ +} + +/** RGB color type. */ +ColorType.RGB = "rgb"; + +/** Gradient color type. */ +ColorType.GRADIENT = "gradient"; + +/** Null color type. */ +ColorType.NONE = "none"; + +/** + * @class RGBColor + * Stores an RGB color with red, green, blue, and alpha values. + * All values are in the range [0.0 to 255.0]. Invalid numeric values are + * converted to numbers within this range. + * + * @param red The red value, in the range [0.0 to 255.0]. + * @param green The green value, in the range [0.0 to 255.0]. + * @param blue The blue value, in the range [0.0 to 255.0]. + * @param alpha The alpha (transparency) value, in the range [0.0 to 255.0]. + * The default, 255.0, means that the color is fully opaque. + * + * @return A new RGBColor object. + */ +function RGBColor(red, green, blue, alpha) +{ + this.red = red; + this.green = green; + this.blue = blue; + this.alpha = alpha; +} + +/** + * @class Direction + * A point value in which the y component is 0 and the x component + * is positive or negative for a right or left direction, + * or the x component is 0 and the y component is positive or negative for + * an up or down direction. + * + * @param x The horizontal component of the point. + * @param y The vertical component of the point. + * + * @return A new \c Direction object. + */ +function Direction(x, y) +{ + this.x = x; + this.y = y; +} + +/** + * @class GradientStop + * Stores gradient stop information. + * + * @param offset The offset of the gradient stop, in the range [0.0 to 1.0]. + * @param rgbColor The color of the gradient at this point, an \c #RGBColor object. + * + * @return GradientStop object. + */ +function GradientStop(offset, rgbColor) +{ + this.offset = offset; + this.rgbColor = rgbColor; +} + +/** + * @class GradientColor + * Stores gradient color information. + * + * @param type The gradient type, must be "linear". + * @param direction A \c #Direction object for the direction of the gradient + (up, down, right, or left). + * @param numStops The number of stops in the gradient. + * @param gradientStopList An array of \c #GradientStop objects. + * + * @return A new \c GradientColor object. + */ +function GradientColor(type, direction, numStops, arrGradientStop) +{ + this.type = type; + this.direction = direction; + this.numStops = numStops; + this.arrGradientStop = arrGradientStop; +} + +/** + * @class UIColor + * Stores color information, including the type, anti-alias level, and specific color + * values in a color object of an appropriate type. + * + * @param type The color type, 1 for "rgb" and 2 for "gradient". + The supplied color object must correspond to this type. + * @param antialiasLevel The anti-alias level constant. + * @param color A \c #RGBColor or \c #GradientColor object containing specific color information. + * + * @return A new \c UIColor object. + */ +function UIColor(type, antialiasLevel, color) +{ + this.type = type; + this.antialiasLevel = antialiasLevel; + this.color = color; +} + +/** + * @class AppSkinInfo + * Stores window-skin properties, such as color and font. All color parameter values are \c #UIColor objects except that systemHighlightColor is \c #RGBColor object. + * + * @param baseFontFamily The base font family of the application. + * @param baseFontSize The base font size of the application. + * @param appBarBackgroundColor The application bar background color. + * @param panelBackgroundColor The background color of the extension panel. + * @param appBarBackgroundColorSRGB The application bar background color, as sRGB. + * @param panelBackgroundColorSRGB The background color of the extension panel, as sRGB. + * @param systemHighlightColor The highlight color of the extension panel, if provided by the host application. Otherwise, the operating-system highlight color. + * + * @return AppSkinInfo object. + */ +function AppSkinInfo(baseFontFamily, baseFontSize, appBarBackgroundColor, panelBackgroundColor, appBarBackgroundColorSRGB, panelBackgroundColorSRGB, systemHighlightColor) +{ + this.baseFontFamily = baseFontFamily; + this.baseFontSize = baseFontSize; + this.appBarBackgroundColor = appBarBackgroundColor; + this.panelBackgroundColor = panelBackgroundColor; + this.appBarBackgroundColorSRGB = appBarBackgroundColorSRGB; + this.panelBackgroundColorSRGB = panelBackgroundColorSRGB; + this.systemHighlightColor = systemHighlightColor; +} + +/** + * @class HostEnvironment + * Stores information about the environment in which the extension is loaded. + * + * @param appName The application's name. + * @param appVersion The application's version. + * @param appLocale The application's current license locale. + * @param appUILocale The application's current UI locale. + * @param appId The application's unique identifier. + * @param isAppOnline True if the application is currently online. + * @param appSkinInfo An \c #AppSkinInfo object containing the application's default color and font styles. + * + * @return A new \c HostEnvironment object. + */ +function HostEnvironment(appName, appVersion, appLocale, appUILocale, appId, isAppOnline, appSkinInfo) +{ + this.appName = appName; + this.appVersion = appVersion; + this.appLocale = appLocale; + this.appUILocale = appUILocale; + this.appId = appId; + this.isAppOnline = isAppOnline; + this.appSkinInfo = appSkinInfo; +} + +/** + * @class HostCapabilities + * Stores information about the host capabilities. + * + * @param EXTENDED_PANEL_MENU True if the application supports panel menu. + * @param EXTENDED_PANEL_ICONS True if the application supports panel icon. + * @param DELEGATE_APE_ENGINE True if the application supports delegated APE engine. + * @param SUPPORT_HTML_EXTENSIONS True if the application supports HTML extensions. + * @param DISABLE_FLASH_EXTENSIONS True if the application disables FLASH extensions. + * + * @return A new \c HostCapabilities object. + */ +function HostCapabilities(EXTENDED_PANEL_MENU, EXTENDED_PANEL_ICONS, DELEGATE_APE_ENGINE, SUPPORT_HTML_EXTENSIONS, DISABLE_FLASH_EXTENSIONS) +{ + this.EXTENDED_PANEL_MENU = EXTENDED_PANEL_MENU; + this.EXTENDED_PANEL_ICONS = EXTENDED_PANEL_ICONS; + this.DELEGATE_APE_ENGINE = DELEGATE_APE_ENGINE; + this.SUPPORT_HTML_EXTENSIONS = SUPPORT_HTML_EXTENSIONS; + this.DISABLE_FLASH_EXTENSIONS = DISABLE_FLASH_EXTENSIONS; // Since 5.0.0 +} + +/** + * @class ApiVersion + * Stores current api version. + * + * Since 4.2.0 + * + * @param major The major version + * @param minor The minor version. + * @param micro The micro version. + * + * @return ApiVersion object. + */ +function ApiVersion(major, minor, micro) +{ + this.major = major; + this.minor = minor; + this.micro = micro; +} + +/** + * @class MenuItemStatus + * Stores flyout menu item status + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function MenuItemStatus(menuItemLabel, enabled, checked) +{ + this.menuItemLabel = menuItemLabel; + this.enabled = enabled; + this.checked = checked; +} + +/** + * @class ContextMenuItemStatus + * Stores the status of the context menu item. + * + * Since 5.2.0 + * + * @param menuItemID The menu item id. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function ContextMenuItemStatus(menuItemID, enabled, checked) +{ + this.menuItemID = menuItemID; + this.enabled = enabled; + this.checked = checked; +} +//------------------------------ CSInterface ---------------------------------- + +/** + * @class CSInterface + * This is the entry point to the CEP extensibility infrastructure. + * Instantiate this object and use it to: + *
requestOpenExtension("HLP", "");
+ *
+ */
+CSInterface.prototype.requestOpenExtension = function(extensionId, params)
+{
+ window.__adobe_cep__.requestOpenExtension(extensionId, params);
+};
+
+/**
+ * Retrieves the list of extensions currently loaded in the current host application.
+ * The extension list is initialized once, and remains the same during the lifetime
+ * of the CEP session.
+ *
+ * @param extensionIds Optional, an array of unique identifiers for extensions of interest.
+ * If omitted, retrieves data for all extensions.
+ *
+ * @return Zero or more \c #Extension objects.
+ */
+CSInterface.prototype.getExtensions = function(extensionIds)
+{
+ var extensionIdsStr = JSON.stringify(extensionIds);
+ var extensionsStr = window.__adobe_cep__.getExtensions(extensionIdsStr);
+
+ var extensions = JSON.parse(extensionsStr);
+ return extensions;
+};
+
+/**
+ * Retrieves network-related preferences.
+ *
+ * @return A JavaScript object containing network preferences.
+ */
+CSInterface.prototype.getNetworkPreferences = function()
+{
+ var result = window.__adobe_cep__.getNetworkPreferences();
+ var networkPre = JSON.parse(result);
+
+ return networkPre;
+};
+
+/**
+ * Initializes the resource bundle for this extension with property values
+ * for the current application and locale.
+ * To support multiple locales, you must define a property file for each locale,
+ * containing keyed display-string values for that locale.
+ * See localization documentation for Extension Builder and related products.
+ *
+ * Keys can be in the
+ * form key.value="localized string", for use in HTML text elements.
+ * For example, in this input element, the localized \c key.value string is displayed
+ * instead of the empty \c value string:
+ *
+ *
+ *
+ * @return An object containing the resource bundle information.
+ */
+CSInterface.prototype.initResourceBundle = function()
+{
+ var resourceBundle = JSON.parse(window.__adobe_cep__.initResourceBundle());
+ var resElms = document.querySelectorAll('[data-locale]');
+ for (var n = 0; n < resElms.length; n++)
+ {
+ var resEl = resElms[n];
+ // Get the resource key from the element.
+ var resKey = resEl.getAttribute('data-locale');
+ if (resKey)
+ {
+ // Get all the resources that start with the key.
+ for (var key in resourceBundle)
+ {
+ if (key.indexOf(resKey) === 0)
+ {
+ var resValue = resourceBundle[key];
+ if (key.length == resKey.length)
+ {
+ resEl.innerHTML = resValue;
+ }
+ else if ('.' == key.charAt(resKey.length))
+ {
+ var attrKey = key.substring(resKey.length + 1);
+ resEl[attrKey] = resValue;
+ }
+ }
+ }
+ }
+ }
+ return resourceBundle;
+};
+
+/**
+ * Writes installation information to a file.
+ *
+ * @return The file path.
+ */
+CSInterface.prototype.dumpInstallationInfo = function()
+{
+ return window.__adobe_cep__.dumpInstallationInfo();
+};
+
+/**
+ * Retrieves version information for the current Operating System,
+ * See http://www.useragentstring.com/pages/Chrome/ for Chrome \c navigator.userAgent values.
+ *
+ * @return A string containing the OS version, or "unknown Operation System".
+ * If user customizes the User Agent by setting CEF command parameter "--user-agent", only
+ * "Mac OS X" or "Windows" will be returned.
+ */
+CSInterface.prototype.getOSInformation = function()
+{
+ var userAgent = navigator.userAgent;
+
+ if ((navigator.platform == "Win32") || (navigator.platform == "Windows"))
+ {
+ var winVersion = "Windows";
+ var winBit = "";
+ if (userAgent.indexOf("Windows") > -1)
+ {
+ if (userAgent.indexOf("Windows NT 5.0") > -1)
+ {
+ winVersion = "Windows 2000";
+ }
+ else if (userAgent.indexOf("Windows NT 5.1") > -1)
+ {
+ winVersion = "Windows XP";
+ }
+ else if (userAgent.indexOf("Windows NT 5.2") > -1)
+ {
+ winVersion = "Windows Server 2003";
+ }
+ else if (userAgent.indexOf("Windows NT 6.0") > -1)
+ {
+ winVersion = "Windows Vista";
+ }
+ else if (userAgent.indexOf("Windows NT 6.1") > -1)
+ {
+ winVersion = "Windows 7";
+ }
+ else if (userAgent.indexOf("Windows NT 6.2") > -1)
+ {
+ winVersion = "Windows 8";
+ }
+ else if (userAgent.indexOf("Windows NT 6.3") > -1)
+ {
+ winVersion = "Windows 8.1";
+ }
+ else if (userAgent.indexOf("Windows NT 10") > -1)
+ {
+ winVersion = "Windows 10";
+ }
+
+ if (userAgent.indexOf("WOW64") > -1 || userAgent.indexOf("Win64") > -1)
+ {
+ winBit = " 64-bit";
+ }
+ else
+ {
+ winBit = " 32-bit";
+ }
+ }
+
+ return winVersion + winBit;
+ }
+ else if ((navigator.platform == "MacIntel") || (navigator.platform == "Macintosh"))
+ {
+ var result = "Mac OS X";
+
+ if (userAgent.indexOf("Mac OS X") > -1)
+ {
+ result = userAgent.substring(userAgent.indexOf("Mac OS X"), userAgent.indexOf(")"));
+ result = result.replace(/_/g, ".");
+ }
+
+ return result;
+ }
+
+ return "Unknown Operation System";
+};
+
+/**
+ * Opens a page in the default system browser.
+ *
+ * Since 4.2.0
+ *
+ * @param url The URL of the page/file to open, or the email address.
+ * Must use HTTP/HTTPS/file/mailto protocol. For example:
+ * "http://www.adobe.com"
+ * "https://github.com"
+ * "file:///C:/log.txt"
+ * "mailto:test@adobe.com"
+ *
+ * @return One of these error codes:\n
+ * d?G(e,n,a,!0,!1,p):P(r,t,o,n,a,i,l,c,p)},H=function(e,r,t,o,n,a,i,l,c){for(var u=0,s=r.length,d=e.length-1,p=s-1;u<=d&&u<=p;){var f=e[u],v=r[u]=c?ga(r[u]):va(r[u]);if(!aa(f,v))break;y(f,v,t,null,n,a,i,l,c),u++}for(;u<=d&&u<=p;){var g=e[d],m=r[p]=c?ga(r[p]):va(r[p]);if(!aa(g,m))break;y(g,m,t,null,n,a,i,l,c),d--,p--}if(u>d){if(u<=p)for(var h=p+1,b=h 0&&m(),i,!v.value&&d.value>0&&m(),ua(op,{onResize:g},{default:function(){return[ua("div",{ref:l,class:`${n}-spacer`},null)]}})])}}}),QP=Object.assign(JP,{install:function(e,r){eu(e,r);var t=Qc(r);e.component(t+JP.name,JP)}}),eL=co({name:"VerificationCode",props:{modelValue:String,defaultValue:{type:String,default:""},length:{type:Number,default:6},size:{type:String},disabled:Boolean,masked:Boolean,readonly:Boolean,error:{type:Boolean,default:!1},separator:{type:Function},formatter:{type:Function}},emits:{"update:modelValue":function(e){return!0},change:function(e){return!0},finish:function(e){return!0},input:function(e,r,t){return!0}},setup:function(e,r){var t=r.emit,o=ru("verification-code"),n=ru("input"),a=Zr([]),i=Na((function(){var r;return null!=(r=e.modelValue)?r:e.defaultValue})),l=Na((function(){return e.masked?"password":"text"})),c=Na((function(){return[n,m({},`${n}-size-${e.size}`,e.size)]})),u=Na((function(){var r=String(i.value).split("");return new Array(e.length).fill("").map((function(e,t){return Fc(r[t])?String(r[t]):""}))})),s=Zr(u.value);Mn(i,(function(){s.value=u.value}));var d=function(){var r=s.value.join("").trim();t("update:modelValue",r),t("change",r),r.length===e.length&&t("finish",r),f()},p=function(e){return null==a?void 0:a.value[e].focus()},f=function(e){if(!Fc(e)||!s.value[e])for(var r=0;rp)for(;u<=d;)q(e[u],n,a,!0),u++;else{var x,k=u,w=u,C=new Map;for(u=w;u<=p;u++){var S=r[u]=c?ga(r[u]):va(r[u]);null!=S.key&&C.set(S.key,u)}var z=0,$=p-w+1,O=!1,P=0,L=new Array($);for(u=0;u<$;u++)L[u]=0;for(u=k;u<=d;u++){var j=e[u];if(z>=$)q(j,n,a,!0);else{var B=void 0;if(null!=j.key)B=C.get(j.key);else for(x=w;x<=p;x++)if(0===L[x-w]&&aa(j,r[x])){B=x;break}void 0===B?q(j,n,a,!0):(L[B-w]=u+1,B>=P?P=B:O=!0,y(j,r[B],t,null,n,a,i,l,c),z++)}}var I=O?function(e){var r,t,o,n,a,i=e.slice(),l=[0],c=e.length;for(r=0;r4&&void 0!==arguments[4]?arguments[4]:null,i=e.el,l=e.type,c=e.transition,u=e.children,s=e.shapeFlag;if(6&s)K(e.component.subTree,r,t,o);else if(128&s)e.suspense.move(r,t,o);else if(64&s)l.move(e,r,t,te);else if(l!==qn){if(l!==Zn)if(2!==o&&1&s&&c)if(0===o)c.beforeEnter(i),a(i,r,t),Cn((function(){return c.enter(i)}),n);else{var d=c.leave,p=c.delayLeave,f=c.afterLeave,v=function(){return a(i,r,t)},g=function(){d(i,(function(){v(),f&&f()}))};p?p(i,v,g):g()}else a(i,r,t);else C(e,r,t)}else{a(i,r,t);for(var m=0;m