1.前言
2.说明
一个基于 Flask 的轻量级网页 IDE,支持项目管理、代码编辑和 Python 代码运行。
功能特性
项目管理
- ✅ 创建、编辑、删除项目
- ✅ 项目列表管理
- ✅ 项目路径配置
文件操作
- ✅ 文件树浏览
- ✅ 文件读取和编辑
- ✅ 新建文件和文件夹
- ✅ 文件重命名
- ✅ 文件/文件夹删除
- ✅ 文件/文件夹复制
- ✅ 文件/文件夹移动
- ✅ 文件下载
- ✅ 文件夹打包下载
- ✅ 文件/文件夹大小查询
- ✅ 自动保存功能
代码编辑
- ✅ Monaco Editor 集成(VS Code 编辑器内核)
- ✅ 语法高亮(支持多种语言)
- ✅ 代码自动补全
- ✅ AI 代码补全(支持通义千问、hiagent 等)
- ✅ 多文件编辑
代码运行
- ✅ Python 代码执行
- ✅ 实时日志输出
- ✅ 运行状态监控
- ✅ 进程终止功能
- ✅ 增量日志查询
用户界面
- ✅ 现代化 UI 设计
- ✅ 响应式布局
- ✅ 可拖拽调整日志面板高度
- ✅ 日志面板全屏模式
- ✅ 快捷键支持(Ctrl+S 保存)
技术栈
- 后端: Flask 3.0.0
- 前端: HTML5, CSS3, JavaScript, Monaco Editor
- 数据库: SQLite
- 跨域支持: Flask-CORS
安装
环境要求
- Python 3.7+
- pip
安装步骤
-
克隆或下载项目
-
安装依赖
pip install -r requirements.txt
依赖包:
- Flask==3.0.0
- Flask-CORS==4.0.0
- requests(用于 AI 代码补全功能)
运行
基本运行
python nordrassil_ide.py
默认配置:
- 服务器地址:
0.0.0.0 - 端口:
5003 - Python 命令:
python - AI 代码补全提供商:
tongyi
自定义配置
# 指定 Python 命令(如使用 python3)
python nordrassil_ide.py --python-cmd python3
# 指定服务器地址和端口
python nordrassil_ide.py --host 127.0.0.1 --port 8080
# 指定 AI 代码补全提供商
python nordrassil_ide.py --ai-provider tongyi
# 组合使用
python nordrassil_ide.py --python-cmd python3 --host 0.0.0.0 --port 5003 --ai-provider tongyi
命令行参数
--python-cmd,-p: Python 命令(默认:python)--host: 服务器地址(默认:0.0.0.0)--port: 服务器端口(默认:5003)--ai-provider: AI 代码补全提供商(默认:tongyi,可选:tongyi,hiagent,other)
环境变量配置
AI 代码补全配置
通义千问(tongyi):
# 设置 API Key
export TONGYI_API_KEY=your_api_key_here
# 设置 API 地址(可选,默认使用官方地址)
export TONGYI_ADDRSS=https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
# 设置模型名称(可选,默认: qwen3-coder-plus)
export QWEN_MODEL=qwen3-coder-plus
hiagent:
# 设置 API Key
export HIAGENT_API_KEY=your_api_key_here
# 设置 API 地址
export HIAGENT_ADDRSS=your_api_address_here
访问应用
启动后,在浏览器中访问:
- 项目列表页:
http://localhost:5003/ - 编辑器页面:
http://localhost:5003/editor/<project_id>
使用说明
1. 创建项目
- 在项目列表页面点击"新建项目"
- 填写项目名称和项目路径
- 可选填写项目描述
- 点击"创建"按钮
2. 编辑代码
- 在项目列表中点击项目进入编辑器
- 在左侧文件树中选择要编辑的文件
- 在编辑器中编写代码
- 代码会自动保存(2秒延迟)
- 也可以使用
Ctrl+S手动保存
3. 运行代码
- 打开 Python 文件(
.py扩展名) - 点击工具栏中的"运行"按钮
- 在底部日志面板查看输出
- 运行中可以点击"终止"按钮停止程序
4. AI 代码补全
- 在编辑器中选中代码或定位光标
- 使用 AI 代码补全功能(需要配置相应的 API Key)
- 支持通义千问、hiagent 等 AI 服务
- 根据上下文和需求生成或修改代码
5. 文件管理
- 新建文件: 在文件树空白处右键 → 新建文件
- 新建文件夹: 在文件树空白处右键 → 新建文件夹
- 重命名: 右键点击文件/文件夹 → 重命名
- 删除: 右键点击文件/文件夹 → 删除
- 复制: 右键点击文件/文件夹 → 复制
- 移动: 右键点击文件/文件夹 → 移动
- 下载: 右键点击文件/文件夹 → 下载
- 查询大小: 右键点击文件/文件夹 → 查询大小
6. 日志面板
- 展开/收起: 点击"展开"/"收起"按钮
- 调整高度: 鼠标按住日志面板上边界拖拽
- 全屏模式: 点击"全屏"按钮
- 清除日志: 点击"清除"按钮
API 文档
项目管理 API
获取项目列表
GET /api/projects
创建项目
POST /api/projects
Content-Type: application/json
{
"name": "项目名称",
"path": "项目路径",
"description": "项目描述(可选)"
}
更新项目
PUT /api/projects/<project_id>
Content-Type: application/json
{
"name": "新名称",
"path": "新路径",
"description": "新描述"
}
删除项目
DELETE /api/projects/<project_id>
文件操作 API
获取文件树
GET /api/projects/<project_id>/files
读取文件
GET /api/projects/<project_id>/files/read?path=<file_path>
写入文件
POST /api/projects/<project_id>/files/write
Content-Type: application/json
{
"path": "文件路径",
"content": "文件内容"
}
创建文件/文件夹
POST /api/projects/<project_id>/files/create
Content-Type: application/json
{
"path": "路径",
"type": "file" | "directory"
}
重命名文件/文件夹
POST /api/projects/<project_id>/files/rename
Content-Type: application/json
{
"old_path": "旧路径",
"new_name": "新名称"
}
删除文件/文件夹
POST /api/projects/<project_id>/files/delete
Content-Type: application/json
{
"path": "路径"
}
复制文件/文件夹
POST /api/projects/<project_id>/files/copy
Content-Type: application/json
{
"source_path": "源路径",
"target_dir": "目标目录(可选,空字符串表示根目录)"
}
移动文件/文件夹
POST /api/projects/<project_id>/files/move
Content-Type: application/json
{
"old_path": "原路径",
"new_path": "新路径"
}
下载文件
GET /api/projects/<project_id>/files/download?path=<file_path>
下载文件夹(打包为 ZIP)
GET /api/projects/<project_id>/files/download-folder?path=<folder_path>
查询文件/文件夹大小
GET /api/projects/<project_id>/files/size?path=<path>
响应:
{
"size": 1024,
"size_mb": 0.001,
"size_gb": 0.000001
}
代码运行 API
运行代码
POST /api/projects/<project_id>/run
Content-Type: application/json
{
"path": "文件路径"
}
响应:
{
"run_id": "运行ID"
}
查询运行状态
GET /api/projects/<project_id>/run/status/<run_id>?last_position=<position>
响应:
{
"status": "running" | "completed" | "error" | "terminated",
"stdout": "完整输出",
"new_output": "增量输出",
"stderr": "错误输出",
"returncode": 0,
"position": 123
}
终止运行
POST /api/projects/<project_id>/run/terminate/<run_id>
AI 代码补全 API
代码补全
POST /api/projects/<project_id>/ai/complete
Content-Type: application/json
{
"selected_code": "选中的代码(可选)",
"context_code": "上下文代码(可选)",
"prompt": "用户需求提示(可选)",
"file_path": "文件路径(可选)",
"cursor_line": 行号,
"cursor_column": 列号
}
响应:
{
"code": "生成的代码",
"model": "使用的模型名称"
}
配置 API
获取配置
GET /api/config
响应:
{
"python_cmd": "python"
}
项目结构
世界树IDE/
├── nordrassil_ide.py # 主应用文件
├── requirements.txt # 依赖列表
├── projects.db # SQLite 数据库(自动创建)
├── README.md # 项目说明文档
├── templates/ # HTML 模板目录
│ ├── index.html # 项目列表页
│ └── editor.html # 代码编辑器页
└── static/ # 静态资源目录
└── monaco-editor/ # Monaco Editor 文件
打包
项目支持使用 PyInstaller 打包为独立可执行文件:
pyinstaller -F nordrassil_ide.py --clean --noconfirm
打包后的可执行文件将包含所有依赖,可以在没有 Python 环境的机器上运行。
注意 : 打包时需要确保 templates 和 static 目录与可执行文件在同一目录下。
安全说明
- 所有文件操作都进行了路径安全检查,防止目录遍历攻击
- 代码运行在项目目录内,确保安全性
- 建议在生产环境中使用反向代理(如 Nginx)和 HTTPS
注意事项
- 路径安全: 项目路径必须是绝对路径,且确保有读写权限
- Python 命令 : 如果系统使用
python3命令,请使用--python-cmd python3参数 - 端口占用 : 如果 5003 端口被占用,请使用
--port参数指定其他端口 - 文件编码: 支持 UTF-8 和 GBK 编码的文本文件,自动检测编码
- 文件大小限制: 编辑器仅支持打开小于 50MB 的文本文件,大文件请使用下载功能
- 进程管理: 运行中的进程会在服务重启后丢失,建议在运行前保存代码
- AI 代码补全: 使用 AI 代码补全功能需要配置相应的 API Key 环境变量
3.代码
3.1.编辑器下载
下载到本地
npm install monaco-editor(或者npm install monaco-editor@0.45.0)
下载后放到目录static下面
editor.html设计路径
const monacoPath = '/static/monaco-editor/min/vs';
打包
pyinstaller -F app.py --clean --noconfirm
3.2.app.py
python
import json
import os
import re
import sys
import sqlite3
import subprocess
import threading
import time
import traceback
import uuid
import argparse
import zipfile
import io
from datetime import datetime
from flask import Flask, request, jsonify, Response, send_from_directory
from flask_cors import CORS
import logging
import requests
# pyinstaller -F app.py --clean --noconfirm
def get_base_path():
if getattr(sys, 'frozen', False):
# 打包后(PyInstaller / cx_Freeze 等)
app_dir = os.path.dirname(sys.executable)
else:
# 未打包,正常 Python 运行
app_dir = os.path.dirname(os.path.abspath(__file__))
return app_dir
BASE_PATH = get_base_path()
# 禁用 Flask 默认的 static 路由,使用自定义路由
app = Flask(__name__, static_folder=None)
CORS(app)
# 设置日志格式和级别
logging.basicConfig(
format="%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s",
level=logging.INFO
)
logger = logging.getLogger(__name__)
# 禁用 Werkzeug (Flask) 的访问日志,减少日志输出
logging.getLogger('werkzeug').setLevel(logging.WARNING)
# 存储运行中的进程
running_processes = {}
# 存储每个运行进程的输出缓冲区
process_outputs = {}
# 记录已经安排清理的 run_id,防止重复清理
cleanup_scheduled = set()
user_id = f"user-{int(time.time())}-{uuid.uuid4()}"
logger.info(f'=> 用户ID: {user_id}')
# 配置变量(从命令行参数获取)
config = {
'python_cmd': 'python', # 默认值
'ai_provider': 'tongyi' # AI代码补全提供商,默认使用通义千问
}
# 数据库初始化
DB_FILE = 'projects.db'
def init_db():
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL,
description TEXT,
created_at TEXT,
updated_at TEXT
)
''')
conn.commit()
conn.close()
init_db()
@app.route('/api/projects', methods=['GET'])
def get_projects():
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute('SELECT * FROM projects ORDER BY updated_at DESC')
projects = [dict(row) for row in c.fetchall()]
conn.close()
return jsonify(projects)
@app.route('/api/projects', methods=['POST'])
def create_project():
data = request.json
name = data.get('name')
path = data.get('path')
description = data.get('description', '')
if not name or not path:
return jsonify({'error': '项目名称和路径不能为空'}), 400
# 确保路径存在
if not os.path.exists(path):
try:
os.makedirs(path, exist_ok=True)
except Exception as e:
return jsonify({'error': f'无法创建项目路径: {str(e)}'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
now = datetime.now().isoformat()
c.execute('''
INSERT INTO projects (name, path, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
''', (name, path, description, now, now))
project_id = c.lastrowid
conn.commit()
conn.close()
return jsonify({'id': project_id, 'name': name, 'path': path, 'description': description}), 201
@app.route('/api/projects/<int:project_id>', methods=['PUT'])
def update_project(project_id):
data = request.json
name = data.get('name')
path = data.get('path')
description = data.get('description', '')
if not name or not path:
return jsonify({'error': '项目名称和路径不能为空'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
now = datetime.now().isoformat()
c.execute('''
UPDATE projects
SET name=?, path=?, description=?, updated_at=?
WHERE id=?
''', (name, path, description, now, project_id))
if c.rowcount == 0:
conn.close()
return jsonify({'error': '项目不存在'}), 404
conn.commit()
conn.close()
return jsonify({'id': project_id, 'name': name, 'path': path, 'description': description})
@app.route('/api/projects/<int:project_id>', methods=['DELETE'])
def delete_project(project_id):
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('DELETE FROM projects WHERE id=?', (project_id,))
if c.rowcount == 0:
conn.close()
return jsonify({'error': '项目不存在'}), 404
conn.commit()
conn.close()
return jsonify({'message': '删除成功'})
# 文件操作API
@app.route('/api/projects/<int:project_id>/files', methods=['GET'])
def list_files(project_id):
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
if not os.path.exists(project_path):
logger.error(f'项目路径不存在: {project_path}')
return jsonify({'error': '项目路径不存在'}), 404
def build_file_tree(path, base_path):
directories = []
files = []
try:
for item in os.listdir(path):
item_path = os.path.join(path, item)
rel_path = os.path.relpath(item_path, base_path)
if os.path.isdir(item_path):
# 跳过隐藏目录和常见的不需要显示的目录
if item.startswith('.') and item != '.git':
continue
directories.append({
'name': item,
'path': rel_path.replace('\\', '/'),
'type': 'directory',
'children': build_file_tree(item_path, base_path)
})
else:
files.append({
'name': item,
'path': rel_path.replace('\\', '/'),
'type': 'file'
})
except PermissionError:
pass
# 对文件夹和文件分别按名称排序(不区分大小写)
directories.sort(key=lambda x: x['name'].lower())
files.sort(key=lambda x: x['name'].lower())
# 先添加文件夹,再添加文件
return directories + files
file_tree = build_file_tree(project_path, project_path)
return jsonify(file_tree)
@app.route('/api/projects/<int:project_id>/files/read', methods=['GET'])
def read_file(project_id):
file_path = request.args.get('path')
if not file_path:
return jsonify({'error': '文件路径不能为空'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
full_path = os.path.join(project_path, file_path)
# 安全检查:确保文件在项目目录内
if not os.path.abspath(full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法文件路径'}), 400
if not os.path.exists(full_path):
return jsonify({'error': f'[{file_path}] 文件不存在'}), 404
if not os.path.isfile(full_path):
return jsonify({'error': f'[{file_path}] 不是文件'}), 400
# 检查文件大小,如果超过50MB,直接返回错误,避免读取大文件
file_size = os.path.getsize(full_path)
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
if file_size > MAX_FILE_SIZE:
size_mb = file_size / (1024 * 1024)
return jsonify({'error': f'[{file_path}] 文件过大({size_mb:.1f}MB),无法在编辑器中打开。请使用下载功能。'}), 400
# 对于较大的文件(>1MB),先读取样本检测编码,快速失败
SAMPLE_SIZE = 1024 * 1024 * 10 # 10MB
if file_size > SAMPLE_SIZE:
try:
with open(full_path, 'rb') as f:
sample = f.read(SAMPLE_SIZE)
# 尝试检测是否是文本文件
# 如果解码失败且错误位置接近样本末尾,可能是截断了多字节字符
try:
sample.decode('utf-8')
detected_encoding = 'utf-8'
except UnicodeDecodeError as e:
# 如果错误位置接近样本末尾(最后10字节内),可能是截断了多字节字符
# 尝试从错误位置向前截断,找到完整的字符边界
error_pos = e.start if hasattr(e, 'start') else len(sample) - 1
detected_encoding = None
if error_pos >= len(sample) - 10:
# 从错误位置向前查找,找到最后一个可以成功解码的位置
# 可能是截断了多字节字符,需要找到完整的字符边界
for i in range(error_pos, max(0, error_pos - 4), -1):
try:
sample[:i].decode('utf-8')
# 找到可以成功解码的位置
detected_encoding = 'utf-8'
break
except UnicodeDecodeError:
continue
# 如果UTF-8检测失败(无论是截断问题还是真的不是UTF-8),尝试GBK
if detected_encoding is None:
try:
sample.decode('gbk')
detected_encoding = 'gbk'
except UnicodeDecodeError:
# 不是文本文件,快速返回错误,不读取整个文件
return jsonify({'error': f'[{file_path}] 文件不是文本文件2'}), 400
except Exception as e:
return jsonify({'error': f'读取文件失败: {str(e)}'}), 500
else:
detected_encoding = 'utf-8' # 小文件默认使用UTF-8
try:
# 读取完整文件
with open(full_path, 'r', encoding=detected_encoding) as f:
content = f.read()
return jsonify({'content': content})
except UnicodeDecodeError:
# 如果检测的编码失败,尝试其他编码
if detected_encoding == 'utf-8':
try:
with open(full_path, 'r', encoding='gbk') as f:
content = f.read()
return jsonify({'content': content})
except UnicodeDecodeError:
return jsonify({'error': f'[{file_path}] 文件不是文本文件3'}), 400
else:
return jsonify({'error': f'[{file_path}] 文件不是文本文件4'}), 400
except Exception as e:
return jsonify({'error': f'[{file_path}] 读取文件失败: {str(e)}'}), 500
@app.route('/api/projects/<int:project_id>/files/write', methods=['POST'])
def write_file(project_id):
data = request.json
file_path = data.get('path')
content = data.get('content', '')
if not file_path:
return jsonify({'error': '文件路径不能为空'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
full_path = os.path.join(project_path, file_path)
# 安全检查:确保文件在项目目录内
if not os.path.abspath(full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法文件路径'}), 400
# 确保目录存在
dir_path = os.path.dirname(full_path)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
try:
with open(full_path, 'w', encoding='utf-8') as f:
f.write(content)
return jsonify({'message': '保存成功'})
except Exception as e:
return jsonify({'error': f'保存文件失败: {str(e)}'}), 500
@app.route('/api/projects/<int:project_id>/files/create', methods=['POST'])
def create_file_or_folder(project_id):
data = request.json
path = data.get('path')
type = data.get('type') # 'file' or 'directory'
if not path or not type:
return jsonify({'error': '路径和类型不能为空'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
full_path = os.path.join(project_path, path)
# 安全检查:确保路径在项目目录内
if not os.path.abspath(full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法路径'}), 400
# 检查路径是否已存在
if os.path.exists(full_path):
if type == 'directory':
if os.path.isdir(full_path):
return jsonify({'error': '文件夹已存在'}), 400
else:
return jsonify({'error': '已存在同名文件,无法创建文件夹'}), 400
else: # file
if os.path.isfile(full_path):
return jsonify({'error': '文件已存在'}), 400
else:
return jsonify({'error': '已存在同名文件夹,无法创建文件'}), 400
try:
if type == 'directory':
os.makedirs(full_path, exist_ok=True)
else: # file
# 确保父目录存在
dir_path = os.path.dirname(full_path)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
# 创建空文件
with open(full_path, 'w', encoding='utf-8') as f:
f.write('')
return jsonify({'message': '创建成功'})
except Exception as e:
traceback.print_exc()
return jsonify({'error': f'创建失败: {str(e)}'}), 500
# 重命名文件或文件夹API
@app.route('/api/projects/<int:project_id>/files/rename', methods=['POST'])
def rename_file_or_folder(project_id):
data = request.json
old_path = data.get('old_path')
new_name = data.get('new_name')
if not old_path or not new_name:
return jsonify({'error': '路径和新名称不能为空'}), 400
# 验证新名称
if '/' in new_name or '\\' in new_name:
return jsonify({'error': '名称不能包含路径分隔符'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
old_full_path = os.path.join(project_path, old_path)
# 安全检查:确保旧路径在项目目录内
if not os.path.abspath(old_full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法路径'}), 400
if not os.path.exists(old_full_path):
return jsonify({'error': '文件或文件夹不存在'}), 404
# 构建新路径
path_parts = old_path.split('/')
path_parts[-1] = new_name # 替换最后一个部分(文件名或文件夹名)
new_path = '/'.join(path_parts)
new_full_path = os.path.join(project_path, new_path)
# 安全检查:确保新路径在项目目录内
if not os.path.abspath(new_full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法路径'}), 400
if os.path.exists(new_full_path):
return jsonify({'error': '目标文件或文件夹已存在'}), 400
try:
os.rename(old_full_path, new_full_path)
return jsonify({'message': '重命名成功', 'new_path': new_path})
except Exception as e:
return jsonify({'error': f'重命名失败: {str(e)}'}), 500
# 删除文件或文件夹API
@app.route('/api/projects/<int:project_id>/files/delete', methods=['POST'])
def delete_file_or_folder(project_id):
data = request.json
path = data.get('path')
if not path:
return jsonify({'error': '路径不能为空'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
full_path = os.path.join(project_path, path)
# 安全检查:确保路径在项目目录内
if not os.path.abspath(full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法路径'}), 400
if not os.path.exists(full_path):
return jsonify({'error': '文件或文件夹不存在'}), 404
try:
import shutil
if os.path.isdir(full_path):
# 删除文件夹及其内容
shutil.rmtree(full_path)
else:
# 删除文件
os.remove(full_path)
return jsonify({'message': '删除成功'})
except Exception as e:
return jsonify({'error': f'删除失败: {str(e)}'}), 500
# 复制文件或文件夹API
@app.route('/api/projects/<int:project_id>/files/copy', methods=['POST'])
def copy_file_or_folder(project_id):
data = request.json
source_path = data.get('source_path')
target_dir = data.get('target_dir', '') # 目标目录,空字符串表示根目录
if not source_path:
return jsonify({'error': '源路径不能为空'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
source_full_path = os.path.join(project_path, source_path)
# 安全检查:确保源路径在项目目录内
if not os.path.abspath(source_full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法源路径'}), 400
if not os.path.exists(source_full_path):
return jsonify({'error': '源文件或文件夹不存在'}), 404
# 生成新文件名
source_name = os.path.basename(source_path)
# 处理文件名:在扩展名前加 _copy
if '.' in source_name and not source_name.startswith('.'):
# 有扩展名的文件
name_parts = source_name.rsplit('.', 1)
new_name = f"{name_parts[0]}_copy.{name_parts[1]}"
else:
# 无扩展名的文件或文件夹
new_name = f"{source_name}_copy"
# 构建目标路径
if target_dir:
target_path = os.path.join(target_dir, new_name)
else:
target_path = new_name
target_full_path = os.path.join(project_path, target_path)
# 安全检查:确保目标路径在项目目录内
if not os.path.abspath(target_full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法目标路径'}), 400
# 如果目标已存在,添加数字后缀
base_target_path = target_full_path
counter = 1
while os.path.exists(target_full_path):
if '.' in new_name and not new_name.startswith('.'):
name_parts = new_name.rsplit('.', 1)
numbered_name = f"{name_parts[0]}_{counter}.{name_parts[1]}"
else:
numbered_name = f"{new_name}_{counter}"
if target_dir:
target_path = os.path.join(target_dir, numbered_name)
else:
target_path = numbered_name
target_full_path = os.path.join(project_path, target_path)
counter += 1
try:
import shutil
# 确保目标目录存在
target_dir_full = os.path.dirname(target_full_path)
if target_dir_full and not os.path.exists(target_dir_full):
os.makedirs(target_dir_full, exist_ok=True)
if os.path.isdir(source_full_path):
# 复制文件夹
shutil.copytree(source_full_path, target_full_path)
else:
# 复制文件
shutil.copy2(source_full_path, target_full_path)
return jsonify({'message': '复制成功', 'new_path': target_path.replace('\\', '/')})
except Exception as e:
return jsonify({'error': f'复制失败: {str(e)}'}), 500
# 移动文件或文件夹API
@app.route('/api/projects/<int:project_id>/files/move', methods=['POST'])
def move_file_or_folder(project_id):
data = request.json
old_path = data.get('old_path')
new_path = data.get('new_path')
if not old_path or not new_path:
return jsonify({'error': '原路径和新路径不能为空'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
old_full_path = os.path.join(project_path, old_path)
new_full_path = os.path.join(project_path, new_path)
# 安全检查:确保旧路径在项目目录内
if not os.path.abspath(old_full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法原路径'}), 400
# 安全检查:确保新路径在项目目录内
if not os.path.abspath(new_full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法新路径'}), 400
if not os.path.exists(old_full_path):
return jsonify({'error': '文件或文件夹不存在'}), 404
# 不能移动到自身或子目录
if old_path == new_path:
return jsonify({'error': '不能移动到自身'}), 400
if new_path.startswith(old_path + '/'):
return jsonify({'error': '不能移动到子目录'}), 400
if os.path.exists(new_full_path):
return jsonify({'error': '目标位置已存在文件或文件夹'}), 400
try:
# 确保目标目录存在
new_dir = os.path.dirname(new_full_path)
if new_dir and not os.path.exists(new_dir):
os.makedirs(new_dir, exist_ok=True)
os.rename(old_full_path, new_full_path)
return jsonify({'message': '移动成功', 'new_path': new_path})
except Exception as e:
return jsonify({'error': f'移动失败: {str(e)}'}), 500
# 文件下载API
@app.route('/api/projects/<int:project_id>/files/download', methods=['GET'])
def download_file(project_id):
path = request.args.get('path')
if not path:
return jsonify({'error': '路径不能为空'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
full_path = os.path.join(project_path, path)
# 安全检查:确保路径在项目目录内
if not os.path.abspath(full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法路径'}), 400
if not os.path.exists(full_path):
return jsonify({'error': '文件不存在'}), 404
if os.path.isdir(full_path):
return jsonify({'error': '路径是文件夹,请使用文件夹下载接口'}), 400
try:
# 读取文件内容
with open(full_path, 'rb') as f:
file_content = f.read()
# 获取文件名
filename = os.path.basename(path)
# 返回文件
return Response(
file_content,
mimetype='application/octet-stream',
headers={
'Content-Disposition': f'attachment; filename="{filename}"'
}
)
except Exception as e:
logger.error(f'下载文件失败: {str(e)}')
return jsonify({'error': f'下载文件失败: {str(e)}'}), 500
# 文件夹大小查询API
@app.route('/api/projects/<int:project_id>/files/size', methods=['GET'])
def get_file_or_folder_size(project_id):
path = request.args.get('path')
if not path:
return jsonify({'error': '路径不能为空'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
full_path = os.path.join(project_path, path)
# 安全检查:确保路径在项目目录内
if not os.path.abspath(full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法路径'}), 400
if not os.path.exists(full_path):
return jsonify({'error': '文件或文件夹不存在'}), 404
try:
if os.path.isdir(full_path):
# 计算文件夹大小
total_size = 0
for dirpath, dirnames, filenames in os.walk(full_path):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
try:
total_size += os.path.getsize(filepath)
except (OSError, IOError):
pass
else:
# 文件大小
total_size = os.path.getsize(full_path)
return jsonify({
'size': total_size,
'size_mb': round(total_size / (1024 * 1024), 2),
'size_gb': round(total_size / (1024 * 1024 * 1024), 2)
})
except Exception as e:
logger.error(f'查询文件或文件夹大小失败: {str(e)}')
return jsonify({'error': f'查询文件或文件夹大小失败: {str(e)}'}), 500
# 文件夹打包下载API
@app.route('/api/projects/<int:project_id>/files/download-folder', methods=['GET'])
def download_folder(project_id):
path = request.args.get('path')
if not path:
return jsonify({'error': '路径不能为空'}), 400
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
full_path = os.path.join(project_path, path)
# 安全检查:确保路径在项目目录内
if not os.path.abspath(full_path).startswith(os.path.abspath(project_path)):
return jsonify({'error': '非法路径'}), 400
if not os.path.exists(full_path):
return jsonify({'error': '文件夹不存在'}), 404
if not os.path.isdir(full_path):
return jsonify({'error': '路径不是文件夹'}), 400
try:
# 创建内存中的zip文件
zip_buffer = io.BytesIO()
folder_name = os.path.basename(path) or 'folder'
zip_filename = f'{folder_name}.zip'
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# 遍历文件夹并添加到zip
for root, dirs, files in os.walk(full_path):
# 计算相对于full_path的路径(这样zip内路径以文件夹名开头)
rel_path = os.path.relpath(root, full_path)
if rel_path == '.':
arc_prefix = folder_name
else:
arc_prefix = os.path.join(folder_name, rel_path).replace('\\', '/')
for file in files:
file_path = os.path.join(root, file)
try:
# zip内的路径:folder_name/relative_path/file
arcname = os.path.join(arc_prefix, file).replace('\\', '/')
zip_file.write(file_path, arcname)
except (OSError, IOError) as e:
logger.warning(f'跳过文件 {file_path}: {str(e)}')
continue
zip_buffer.seek(0)
# 返回zip文件
return Response(
zip_buffer.getvalue(),
mimetype='application/zip',
headers={
'Content-Disposition': f'attachment; filename="{zip_filename}"'
}
)
except Exception as e:
logger.error(f'打包下载文件夹失败: {str(e)}')
return jsonify({'error': f'打包下载文件夹失败: {str(e)}'}), 500
# 代码执行API
@app.route('/api/projects/<int:project_id>/run', methods=['POST'])
def run_code(project_id):
data = request.json
file_path = data.get('path')
code = data.get('code')
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'error': '项目不存在'}), 404
project_path = result[0]
# 生成运行ID
run_id = str(uuid.uuid4())
# 先初始化运行状态,避免轮询时找不到 run_id
running_processes[run_id] = {'status': 'starting'}
process_outputs[run_id] = {'stdout': '', 'stderr': '', 'position': 0}
logging.info('*'*100)
logging.info(f'创建运行任务,run_id: {run_id}, 文件路径: {file_path}')
def run_process():
try:
process = None
if file_path:
full_path = os.path.join(project_path, file_path)
if not os.path.exists(full_path):
running_processes[run_id] = {
'error': '文件不存在', 'status': 'error'}
if run_id in process_outputs:
del process_outputs[run_id]
return
# 安全检查
if not os.path.abspath(full_path).startswith(os.path.abspath(project_path)):
running_processes[run_id] = {
'error': '非法文件路径', 'status': 'error'}
if run_id in process_outputs:
del process_outputs[run_id]
return
try:
# 使用 -u 参数禁用 Python 输出缓冲,确保实时输出
process = subprocess.Popen(
[config['python_cmd'], '-u', full_path],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # 合并 stderr 到 stdout
text=True,
cwd=project_path,
bufsize=0, # 无缓冲
universal_newlines=True,
# 设置环境变量确保无缓冲
env=dict(os.environ, PYTHONUNBUFFERED='1')
)
logging.info(f'进程已创建,run_id: {run_id}, PID: {process.pid}')
except Exception as e:
logging.error(
f'创建进程失败,run_id: {run_id}, 错误: {str(e)}', exc_info=True)
running_processes[run_id] = {
'error': f'创建进程失败: {str(e)}', 'status': 'error'}
if run_id in process_outputs:
del process_outputs[run_id]
return
elif code:
try:
# 使用 -u 参数禁用 Python 输出缓冲,确保实时输出
process = subprocess.Popen(
[config['python_cmd'], '-u', '-c', code],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # 合并 stderr 到 stdout
text=True,
cwd=project_path,
bufsize=0, # 无缓冲
universal_newlines=True,
# 设置环境变量确保无缓冲
env=dict(os.environ, PYTHONUNBUFFERED='1')
)
logging.info(f'进程已创建,run_id: {run_id}, PID: {process.pid}')
except Exception as e:
logging.error(
f'创建进程失败,run_id: {run_id}, 错误: {str(e)}', exc_info=True)
running_processes[run_id] = {
'error': f'创建进程失败: {str(e)}', 'status': 'error'}
if run_id in process_outputs:
del process_outputs[run_id]
return
if process:
# 更新状态为运行中
running_processes[run_id] = {
'process': process, 'status': 'running'}
# process_outputs 已经在上面初始化了
logging.info(f'进程已启动,run_id: {run_id}, 状态: running')
# 实时读取输出
def read_output():
try:
while True:
line = process.stdout.readline()
if not line: # 进程结束,stdout 关闭
break
# 确保 process_outputs[run_id] 存在
if run_id in process_outputs:
process_outputs[run_id]['stdout'] += line
logging.debug(
f'读取到输出,run_id: {run_id}, 内容: {repr(line)}')
except Exception as e:
# 确保 process_outputs[run_id] 存在
if run_id in process_outputs:
process_outputs[run_id]['stderr'] += str(e) + '\n'
logging.error(
f'读取输出异常,run_id: {run_id}, 错误: {str(e)}', exc_info=True)
# 在单独线程中读取输出
output_thread = threading.Thread(target=read_output)
output_thread.daemon = True
output_thread.start()
# 等待进程结束
process.wait()
# 等待输出线程完成(最多等待2秒)
output_thread.join(timeout=2)
# 保存完整输出到 running_processes
# 确保 process_outputs[run_id] 存在,如果不存在则重新创建
if run_id not in process_outputs:
process_outputs[run_id] = {
'stdout': '', 'stderr': '', 'position': 0}
final_stdout = process_outputs[run_id]['stdout']
final_stderr = process_outputs[run_id].get('stderr', '')
running_processes[run_id] = {
'stdout': final_stdout,
'stderr': final_stderr,
'returncode': process.returncode,
'status': 'completed'
}
logging.info(f'任务 {run_id} 执行完成,返回码: {process.returncode}')
# 注意:不要立即删除 process_outputs,让轮询有机会读取最后的输出
# 延迟删除,给轮询一些时间获取最后的输出
def delayed_delete():
import time
time.sleep(2) # 等待2秒,确保轮询已读取
if run_id in process_outputs:
del process_outputs[run_id]
threading.Thread(target=delayed_delete, daemon=True).start()
except Exception as e:
logging.error(
f'运行进程异常,run_id: {run_id}, 错误: {str(e)}', exc_info=True)
running_processes[run_id] = {'error': str(e), 'status': 'error'}
if run_id in process_outputs:
del process_outputs[run_id]
# 在后台线程中运行
thread = threading.Thread(target=run_process)
thread.daemon = True
thread.start()
logging.info(
f'返回 run_id: {run_id}, 当前 running_processes 中的 run_id: {list(running_processes.keys())}')
return jsonify({'run_id': run_id})
# 检查运行状态API(支持增量日志查询)
@app.route('/api/projects/<int:project_id>/run/status/<run_id>', methods=['GET'])
def get_run_status(project_id, run_id):
# 获取上次查询的位置(用于增量输出)
last_position = int(request.args.get('last_position', 0))
if run_id not in running_processes:
# 记录调试信息
logging.warning(
f'运行ID不存在: {run_id}, 当前所有运行ID: {list(running_processes.keys())}')
return jsonify({'error': '运行ID不存在'}), 404
result = running_processes[run_id]
status = result.get('status', '')
# 记录状态查询(用于调试)
logging.debug(f'查询运行状态: run_id={run_id}, status={status}')
# 如果还在启动中,返回启动状态
if status == 'starting':
return jsonify({
'status': 'starting',
'stdout': '',
'new_output': '',
'position': 0
})
# 如果已完成或出错,返回结果并清理
if status in ['completed', 'error', 'terminated']:
# 获取完整输出
full_stdout = result.get('stdout', '')
full_stderr = result.get('stderr', '')
# 计算增量输出
new_output = ''
if last_position < len(full_stdout):
new_output = full_stdout[last_position:]
response_data = {
'status': status,
'stdout': full_stdout,
'new_output': new_output,
'stderr': full_stderr,
'returncode': result.get('returncode', 0),
'error': result.get('error', ''),
'position': len(full_stdout)
}
# 延迟清理,给前端最后一次获取数据的机会
# 注意:只有在状态是 completed/error/terminated 时才会执行到这里
# 使用 cleanup_scheduled 防止重复启动清理线程
if run_id not in cleanup_scheduled:
cleanup_scheduled.add(run_id)
import threading
def delayed_cleanup():
import time
time.sleep(2) # 等待2秒,确保前端已获取数据
# 再次检查状态,确保不是 running 状态
if run_id in running_processes:
check_status = running_processes[run_id].get('status', '')
if check_status in ['completed', 'error', 'terminated']:
del running_processes[run_id]
if run_id in process_outputs:
del process_outputs[run_id]
# 清理完成后,从 cleanup_scheduled 中移除
cleanup_scheduled.discard(run_id)
threading.Thread(target=delayed_cleanup, daemon=True).start()
return jsonify(response_data)
# 仍在运行中,返回增量输出
# 确保 process_outputs[run_id] 存在
if run_id not in process_outputs:
process_outputs[run_id] = {'stdout': '', 'stderr': '', 'position': 0}
current_output = process_outputs[run_id].get('stdout', '')
# 计算增量输出
new_output = ''
if last_position < len(current_output):
new_output = current_output[last_position:]
return jsonify({
'status': 'running',
'stdout': current_output,
'new_output': new_output,
'position': len(current_output)
})
# 终止运行API
@app.route('/api/projects/<int:project_id>/run/terminate/<run_id>', methods=['POST'])
def terminate_run(project_id, run_id):
if run_id not in running_processes:
return jsonify({'error': '运行ID不存在'}), 404
result = running_processes[run_id]
if result['status'] == 'running' and 'process' in result:
try:
process = result['process']
process.terminate()
# 等待进程结束
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
running_processes[run_id] = {
'stdout': '',
'stderr': '程序已被用户终止',
'returncode': -1,
'status': 'terminated'
}
return jsonify({'message': '已终止'})
except Exception as e:
return jsonify({'error': f'终止失败: {str(e)}'}), 500
return jsonify({'error': '进程不在运行中'}), 400
# 配置API
@app.route('/api/config', methods=['GET'])
def get_config():
return jsonify(config)
# 大模型代码补全API
@app.route('/api/projects/<int:project_id>/ai/complete', methods=['POST'])
def ai_complete(project_id):
"""调用大模型进行代码补全"""
data = request.json
selected_code = data.get('selected_code', '')
context_code = data.get('context_code', '') # 上下文代码(光标周围的代码)
user_prompt = data.get('prompt', '')
file_path = data.get('file_path', '')
cursor_line = data.get('cursor_line') # 光标行号
cursor_column = data.get('cursor_column') # 光标列号
if not selected_code and not context_code and not user_prompt:
return jsonify({'error': '请提供选中的代码、上下文或提示'}), 400
# 获取项目路径(可选,用于上下文)
project_path = None
if file_path:
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT path FROM projects WHERE id=?', (project_id,))
result = c.fetchone()
conn.close()
if result:
project_path = result[0]
# 构建提示词
system_prompt = "你是一个专业的python代码助手。根据用户提供的代码片段和需求,生成或修改代码。只返回代码,不要包含解释和markdown格式。代码中要包含合适的缩进!"
user_message = ""
if selected_code:
# 有选中代码
user_message += f"选中的代码:\n```\n{selected_code}\n```\n\n"
# if context_code:
# user_message += f"上下文代码(仅供参考):\n```\n{context_code}\n```\n\n"
elif context_code:
# 没有选中代码,只有上下文
user_message += f"当前光标位置的上下文代码(光标在第 {cursor_line} 行,第 {cursor_column} 列):\n```\n{context_code}\n```\n\n"
user_message += "请根据上下文代码,在光标位置生成合适的代码。注意缩进要合适!\n\n"
if user_prompt:
user_message += f"用户需求:{user_prompt}\n\n"
if selected_code:
user_message += "请根据以上信息修改**选中的代码**,只返回修改后的代码本身,不要包含任何解释。注意缩进要合适!"
else:
user_message += "请根据上下文和需求,在光标位置生成合适的代码,只返回要插入的代码本身,不要包含任何解释。注意缩进要合适!"
# 根据配置选择调用哪个AI代码补全服务
ai_provider = config.get('ai_provider', 'tongyi').lower()
if ai_provider == 'tongyi':
return call_ai_complete_tongyi(user_message, system_prompt, project_id)
elif ai_provider == 'hiagent':
return call_ai_complete_hiagent(user_message, system_prompt, project_id)
elif ai_provider == 'other':
return call_ai_complete_other(user_message, system_prompt, project_id)
else:
logger.warning(f'未知的AI提供商: {ai_provider},使用默认的通义千问')
return call_ai_complete_tongyi(user_message, system_prompt, project_id)
def call_ai_complete_other(user_message, system_prompt, project_id):
type = '其他'
try:
generated_code = ''
model = ''
return jsonify({
'code': generated_code,
'model': model
})
except requests.exceptions.Timeout:
logger.error(f'{type} API 请求超时')
return jsonify({'error': 'API 请求超时,请稍后重试'}), 500
except requests.exceptions.RequestException as e:
logger.error(f'{type} API 请求异常: {str(e)}', exc_info=True)
return jsonify({'error': f'请求 API 失败: {str(e)}'}), 500
except Exception as e:
logger.error(f'{type} 代码补全异常: {str(e)}', exc_info=True)
return jsonify({'error': f'代码补全失败: {str(e)}'}), 500
# 自定义
# 调用{} 进行代码补全
def call_ai_complete_hiagent(user_message, system_prompt, project_id):
type = 'hiagent'
try:
generated_code = ''
model = ''
global hiagent_dh_id
if hiagent_dh_id is None:
url = hiagent_addrss + "create_conversation"
json_data = {
"AppKey": hiagent_api_key,
"UserID": user_id
}
response = requests.get(
url,
headers={
"Apikey": hiagent_api_key,
"Content-Type": "application/json"
},
json=json_data # 自动设置 Content-Type 并序列化 JSON
)
logger.info(
f'=> 创建会话\nreq:{json.dumps(json_data,ensure_ascii=False)}\nresponse:{response.text}')
hiagent_dh_id = response.json(
)['Conversation']['AppConversationID']
json_data = {
"AppKey": hiagent_api_key,
"Query": user_message,
"AppConversationID": hiagent_dh_id,
"ResponseMode": "blocking",
"UserID": user_id
}
response = requests.get(hiagent_addrss + "chat_query",
headers={
"Apikey": hiagent_api_key,
"Content-Type": "application/json"
}, json=json_data)
response.encoding = 'utf-8'
# 使用正则提取 data 行中的 JSON 部分
match = re.search(r'data:\s*(\{.*\})', response.text)
json_str = match.group(1)
data = json.loads(json_str)
answer = data.get("answer", "")
final_result = ''
if '```json' in answer:
# 从文本 rs 中提取 ```json 和 ``` 之间的内容
json_block_match = re.search(
r'```json\s*(.*?)\s*```', answer, re.DOTALL)
if json_block_match:
final_result = json_block_match.group(1)
else:
final_result = answer.split('</think>')[1].strip()
else:
final_result = answer
generated_code = final_result
return jsonify({
'code': generated_code,
'model': model
})
except requests.exceptions.Timeout:
logger.error(f'{type} API 请求超时')
return jsonify({'error': 'API 请求超时,请稍后重试'}), 500
except requests.exceptions.RequestException as e:
logger.error(f'{type} API 请求异常: {str(e)}', exc_info=True)
return jsonify({'error': f'请求 API 失败: {str(e)}'}), 500
except Exception as e:
logger.error(f'{type} 代码补全异常: {str(e)}', exc_info=True)
return jsonify({'error': f'代码补全失败: {str(e)}'}), 500
# 调用通义千问 API 进行代码补全
def call_ai_complete_tongyi(user_message, system_prompt, project_id):
try:
# 调用通义千问 API(使用 OpenAI 兼容接口)
# 通义千问模型名称,默认使用 qwen-turbo
model = os.environ.get('QWEN_MODEL', 'qwen3-coder-plus')
if not tongyi_api_key:
return jsonify({'error': '未配置 API Key,请设置 TONGYI_API_KEY 环境变量'}), 400
# 调用 API
headers = {
'Authorization': f'Bearer {tongyi_api_key}',
'Content-Type': 'application/json'
}
print(user_message)
payload = {
'model': model,
'messages': [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_message}
],
'temperature': 0.7,
'max_tokens': 2000
}
logger.info(
f'调用通义千问 API:模型={model}, 项目={project_id}, 提示长度={len(user_message)}')
response = requests.post(
f'{tongyi_addrss}',
headers=headers,
json=payload,
timeout=60
)
if response.status_code != 200:
error_msg = response.text
logger.error(
f'通义千问 API 调用失败: status={response.status_code}, error={error_msg}')
try:
error_json = response.json()
error_detail = error_json.get(
'error', {}).get('message', error_msg)
except:
error_detail = error_msg
return jsonify({'error': f'API 调用失败: {error_detail}'}), 500
result = response.json()
if 'choices' not in result or len(result['choices']) == 0:
logger.error(f'API 返回格式错误: {result}')
return jsonify({'error': 'API 返回格式错误'}), 500
generated_code = result['choices'][0]['message']['content'].rstrip()
# 清理代码(移除可能的 markdown 代码块标记)
if generated_code.startswith('```'):
lines = generated_code.split('\n')
# 移除第一行和最后一行(代码块标记)
if lines[0].startswith('```'):
lines = lines[1:]
if lines and lines[-1].strip() == '```':
lines = lines[:-1]
generated_code = '\n'.join(lines)
logger.info(
f'通义千问代码补全成功:项目={project_id}, 生成代码长度={len(generated_code)}')
return jsonify({
'code': generated_code,
'model': model
})
except requests.exceptions.Timeout:
logger.error(f'通义千问 API 请求超时')
return jsonify({'error': 'API 请求超时,请稍后重试'}), 500
except requests.exceptions.RequestException as e:
logger.error(f'通义千问 API 请求异常: {str(e)}', exc_info=True)
return jsonify({'error': f'请求 API 失败: {str(e)}'}), 500
except Exception as e:
logger.error(f'代码补全异常: {str(e)}', exc_info=True)
return jsonify({'error': f'代码补全失败: {str(e)}'}), 500
# 静态资源处理
@app.route('/static/<path:filename>')
def serve_static(filename):
"""处理 static 目录下的静态资源文件"""
logger.info(f'=== serve_static 被调用 ===')
logger.info(f'请求的文件名: {filename}')
logger.info(f'BASE_PATH: {BASE_PATH}')
static_dir = os.path.join(BASE_PATH, 'static')
logger.info(f'static_dir: {static_dir}')
file_path = os.path.join(static_dir, filename)
logger.info(f'file_path: {file_path}')
# 安全检查:确保文件在 static 目录内
static_dir_abs = os.path.abspath(static_dir)
file_path_abs = os.path.abspath(file_path)
if not file_path_abs.startswith(static_dir_abs):
return jsonify({'error': '非法文件路径'}), 400
logger.info(f'file_path_abs: {file_path_abs}')
if not os.path.exists(file_path):
logger.error(f'文件不存在: {file_path}')
return jsonify({'error': '文件不存在'}), 404
logger.info(f'file_path exists: {os.path.exists(file_path)}')
if not os.path.isfile(file_path):
return jsonify({'error': '不是文件'}), 400
logger.info(f'file_path is file: {os.path.isfile(file_path)}')
# 根据文件扩展名设置 Content-Type
content_type = 'application/octet-stream'
ext = os.path.splitext(filename)[1].lower()
mime_types = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
'.xml': 'application/xml',
'.txt': 'text/plain'
}
content_type = mime_types.get(ext, content_type)
try:
# 对于文本文件,使用 UTF-8 编码读取
if content_type.startswith('text/') or content_type in ['application/javascript', 'application/json', 'application/xml']:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
return Response(content, mimetype=content_type)
else:
# 对于二进制文件,直接发送
return send_from_directory(static_dir, filename)
except Exception as e:
return jsonify({'error': f'读取文件失败: {str(e)}'}), 500
# 前端页面
@app.route('/')
def index():
"""返回首页 HTML"""
html_path = os.path.join(BASE_PATH, 'templates', 'index.html')
try:
with open(html_path, 'r', encoding='utf-8') as f:
content = f.read()
return Response(content, mimetype='text/html')
except FileNotFoundError:
return jsonify({'error': 'index.html 文件不存在'}), 404
except Exception as e:
return jsonify({'error': f'读取文件失败: {str(e)}'}), 500
@app.route('/editor/<int:project_id>')
def editor(project_id):
"""返回编辑器页面 HTML"""
# 获取项目信息
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute('SELECT name FROM projects WHERE id = ?', (project_id,))
project = c.fetchone()
conn.close()
project_name = project['name'] if project else '代码编辑器'
# 读取 HTML 文件
html_path = os.path.join(BASE_PATH, 'templates', 'editor.html')
try:
with open(html_path, 'r', encoding='utf-8') as f:
content = f.read()
# 替换模板变量
content = content.replace('{{ project_name }}', project_name)
content = content.replace('{{ project_id }}', str(project_id))
return Response(content, mimetype='text/html')
except FileNotFoundError:
return jsonify({'error': 'editor.html 文件不存在'}), 404
except Exception as e:
return jsonify({'error': f'读取文件失败: {str(e)}'}), 500
if __name__ == '__main__':
# 解析命令行参数
parser = argparse.ArgumentParser(description='网页IDE服务')
parser.add_argument('--python-cmd', '-p',
default='python',
help='Python命令(默认: python,可以是 python3 等)')
parser.add_argument('--host',
default='0.0.0.0',
help='服务器地址(默认: 0.0.0.0)')
parser.add_argument('--port',
type=int,
default=5003,
help='服务器端口(默认: 5000)')
parser.add_argument('--ai-provider',
choices=['tongyi', 'hiagent', 'other'],
default='tongyi',
help='AI代码补全提供商(默认: tongyi,可选: tongyi, hiagent, other)')
args = parser.parse_args()
# 代码补全API
## tongyi
tongyi_api_key = os.environ.get('TONGYI_API_KEY', '')
tongyi_addrss = os.environ.get('TONGYI_ADDRSS', 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions')
## hiagent
hiagent_dh_id = None
hiagent_api_key = os.environ.get('HIAGENT_API_KEY', '')
hiagent_addrss = os.environ.get('HIAGENT_ADDRSS', '')
# 更新配置
config['python_cmd'] = args.python_cmd
config['ai_provider'] = args.ai_provider
print("\n" + "=" * 67)
print(f" 服务正在运行于: http://{args.host}:{args.port} ".center(60, "="))
print("=" * 67 + "\n")
# 如果启用调试模式,禁用自动重载功能,避免丢失运行中的进程状态
app.run(debug=False, host=args.host, port=args.port)
3.3.templates/editor.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ project_name }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #ffffff;
color: #333333;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
background: #ffffff;
border-bottom: 2px solid #ce93d8;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(156, 39, 176, 0.1);
}
.header h2 {
color: #6a1b9a;
font-size: 16px;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #9c27b0;
color: white;
}
.btn-primary:hover {
background: #7b1fa2;
}
.btn-primary:disabled {
background: #b39ddb;
cursor: not-allowed;
}
.btn-danger {
color: white;
background: #9c27b0;
}
.btn-danger:hover {
background: #7b1fa2;
}
.main-container {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 250px;
background: #ffffff;
border-right: 2px solid #ce93d8;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 10px;
border-bottom: 2px solid #ce93d8;
color: #6a1b9a;
font-size: 12px;
font-weight: bold;
background: #f3e5f5;
}
.file-tree {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.file-item {
padding: 4px 8px;
cursor: pointer;
border-radius: 3px;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #555555;
user-select: none;
position: relative;
min-width: 0;
/* 允许 flex 子元素收缩 */
}
.file-item>span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.file-item:hover {
background: #f3e5f5;
}
.file-item.active {
background: #e1bee7;
color: #6a1b9a;
font-weight: 500;
}
.file-item.directory {
color: #7b1fa2;
}
.file-item.dragging {
opacity: 0.5;
background: #e1bee7;
}
.file-item.drag-over {
background: #ce93d8;
border: 2px dashed #9c27b0;
border-radius: 4px;
}
.file-item.drag-over-top {
border-top: 3px solid #9c27b0;
}
.file-item.drag-over-bottom {
border-bottom: 3px solid #9c27b0;
}
.context-menu {
display: none;
position: fixed;
background: #ffffff;
border: 1px solid #ce93d8;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(156, 39, 176, 0.2);
min-width: 150px;
z-index: 1000;
padding: 4px 0;
}
.context-menu.active {
display: block;
}
.context-menu-item {
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
color: #333333;
transition: all 0.2s;
border: none;
background: none;
width: 100%;
text-align: left;
}
.context-menu-item:hover {
background: #f3e5f5;
color: #6a1b9a;
}
.context-menu-divider {
height: 1px;
background: #e0e0e0;
margin: 4px 0;
}
.file-item.directory .folder-icon {
margin-right: 4px;
}
.file-item.file .file-icon {
margin-right: 4px;
}
.file-item .indent {
width: 16px;
}
.file-item .expand-icon {
display: inline-block;
width: 12px;
text-align: center;
font-size: 10px;
color: #666;
margin-right: 4px;
}
.file-item .expand-placeholder {
display: inline-block;
width: 12px;
margin-right: 4px;
}
.editor-container {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.editor-toolbar {
background: #ffffff;
border-bottom: 1px solid #ce93d8;
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.editor-toolbar .file-name {
color: #6a1b9a;
font-size: 13px;
font-weight: 500;
}
/* 标签页样式 */
.tabs-container {
background: #f5f5f5;
border-bottom: 1px solid #ce93d8;
display: flex;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
min-height: 36px;
}
.tabs-container::-webkit-scrollbar {
height: 4px;
}
.tabs-container::-webkit-scrollbar-track {
background: #f5f5f5;
}
.tabs-container::-webkit-scrollbar-thumb {
background: #ce93d8;
border-radius: 2px;
}
.tab-item {
display: inline-flex;
align-items: center;
padding: 8px 12px;
background: #e0e0e0;
border-right: 1px solid #ce93d8;
cursor: pointer;
font-size: 13px;
color: #555;
user-select: none;
transition: background 0.2s;
max-width: 200px;
}
.tab-item:hover {
background: #d0d0d0;
}
.tab-item.active {
background: #ffffff;
color: #6a1b9a;
font-weight: 500;
}
.tab-item .tab-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.tab-item .tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 3px;
color: #666;
font-size: 14px;
line-height: 1;
transition: all 0.2s;
}
.tab-item .tab-close:hover {
background: #9c27b0;
color: #ffffff;
}
.tab-item.modified .tab-name::after {
content: ' •';
color: #9c27b0;
}
.editor-wrapper {
flex: 1;
position: relative;
min-height: 0;
overflow: hidden;
}
#editor-container {
width: 100%;
height: 100%;
position: relative;
background: #ffffff;
}
.log-panel {
height: 30%;
background: #ffffff;
border-top: 2px solid #ce93d8;
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: height 0.3s ease;
overflow: hidden;
min-height: 0;
position: relative;
}
.log-resizer {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
cursor: ns-resize;
background: transparent;
z-index: 10;
user-select: none;
}
.log-resizer:hover {
background: #ce93d8;
}
.log-resizer.dragging {
background: #9c27b0;
}
.log-panel.collapsed {
height: 0;
border-top: none;
min-height: 0;
}
.log-panel.collapsed .log-header {
display: flex;
position: fixed;
bottom: 0;
right: 20px;
width: auto;
background: #f3e5f5;
border: 2px solid #ce93d8;
border-bottom: none;
border-radius: 6px 6px 0 0;
padding: 6px 12px;
z-index: 100;
box-shadow: 0 -2px 8px rgba(156, 39, 176, 0.2);
}
.log-panel.collapsed .log-header h3 {
margin-right: 10px;
}
.log-panel.collapsed .log-content {
display: none;
}
.log-panel.collapsed .log-resizer {
display: none;
}
.log-panel.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100vh !important;
z-index: 10000;
border-top: none;
}
.log-panel.fullscreen.collapsed {
/* 全屏模式下不能收起 */
height: 100vh !important;
}
.log-panel.fullscreen .log-header {
/* 全屏模式下头部正常显示 */
position: relative;
}
.log-header {
background: #f3e5f5;
padding: 8px 16px;
border-bottom: 2px solid #ce93d8;
display: flex;
justify-content: space-between;
align-items: center;
}
.log-header h3 {
color: #6a1b9a;
font-size: 14px;
font-weight: 600;
margin: 0;
padding: 0;
flex: 1;
}
.log-header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.log-toggle,
.log-clear,
.log-fullscreen {
background: none;
border: none;
color: #6a1b9a;
cursor: pointer;
font-size: 12px;
padding: 4px 8px;
}
.log-toggle:hover,
.log-clear:hover,
.log-fullscreen:hover {
color: #9c27b0;
}
.log-content {
flex: 1;
overflow-y: auto;
padding: 10px;
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Source Code Pro', Consolas, "Liberation Mono", Menlo, Courier, monospace;
/* 根据实际情况调整 */
font-size: 14px;
/* 提高行间距以增强可读性 */
line-height: 1.5;
/* 深灰色文本有助于减少眼睛疲劳 */
color: #333;
/* 浅色背景 */
background-color: #f7f7f7;
}
.log-line {
margin-bottom: 4px;
padding: 2px 0;
display: flex;
align-items: flex-start;
border-left: 3px solid transparent;
padding-left: 8px;
transition: background-color 0.2s;
}
.log-line:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.log-line-number {
color: #999999;
margin-right: 12px;
min-width: 50px;
text-align: right;
user-select: none;
font-size: 11px;
}
.log-line-timestamp {
color: #6a9955;
margin-right: 12px;
min-width: 80px;
font-size: 11px;
user-select: none;
}
.log-line-content {
flex: 1;
word-break: break-all;
}
.log-line.error {
color: #d32f2f;
border-left-color: #d32f2f;
}
.log-line.error .log-line-number {
color: #d32f2f;
}
.log-line.success {
/* color: #388e3c; */
color: #333;
}
.log-line.separator {
border-top: 1px solid #e0e0e0;
border-left: none;
margin: 12px 0;
padding: 8px 0 0 0;
min-height: 1px;
background-color: transparent;
}
.log-line.separator:hover {
background-color: transparent;
}
.log-line.command {
color: #1976d2;
border-left-color: #1976d2;
font-weight: 500;
}
.empty-editor {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #9c27b0;
font-size: 14px;
background: #ffffff;
}
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #ffffff;
border: 2px solid #ce93d8;
border-radius: 6px;
padding: 12px 24px;
box-shadow: 0 4px 12px rgba(156, 39, 176, 0.3);
z-index: 10000;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: #333333;
animation: toastSlideIn 0.3s ease, toastFadeOut 0.3s ease 2.7s;
animation-fill-mode: both;
}
.toast.success {
border-color: #388e3c;
color: #2e7d32;
}
.toast.error {
border-color: #d32f2f;
color: #c62828;
}
.toast.info {
border-color: #9c27b0;
color: #6a1b9a;
}
@keyframes toastSlideIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes toastFadeOut {
from {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
to {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
}
.btn-secondary {
background-color: #9d31edb3;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: #ffffff;
border: 2px solid #ce93d8;
border-radius: 6px;
padding: 30px;
width: 90%;
max-width: 500px;
box-shadow: 0 8px 24px rgba(156, 39, 176, 0.3);
}
.modal-content h2 {
color: #6a1b9a;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
color: #555555;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 10px;
background: #fafafa;
border: 1px solid #ce93d8;
border-radius: 4px;
color: #333333;
font-size: 14px;
font-family: inherit;
}
.form-group input:focus {
outline: none;
border-color: #9c27b0;
background: #ffffff;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="header">
<h2>{{ project_name }}</h2>
<div class="header-actions">
<button class="btn btn-primary" onclick="window.location.href='/'">返回项目列表</button>
</div>
</div>
<div class="main-container">
<div class="sidebar">
<div class="sidebar-header">文件资源管理器</div>
<div class="file-tree" id="file-tree">
<div style="padding: 10px; color: #858585; font-size: 12px;">加载中...</div>
</div>
</div>
<div class="editor-container">
<div class="tabs-container" id="tabs-container">
<!-- 标签页将动态添加到这里 -->
</div>
<div class="editor-toolbar">
<div class="file-name" id="file-name">未打开文件</div>
<div style="display: flex; gap: 8px; align-items: center;">
<span id="auto-save-status" style="color: #999; font-size: 12px; display: none;"></span>
<button class="btn btn-secondary" id="save-btn" onclick="saveFile()" title="按 CTRL+S 保存"
disabled>保存</button>
<button class="btn btn-primary" id="run-btn" onclick="runCode()" disabled>▶ 运行</button>
</div>
</div>
<div class="editor-wrapper">
<div id="editor-container">
<div id="editor-placeholder" class="empty-editor">请从左侧文件树中选择一个文件进行编辑</div>
</div>
</div>
<div class="log-panel collapsed" id="log-panel">
<div class="log-resizer" id="log-resizer"></div>
<div class="log-header">
<h3 id="log-title">输出</h3>
<div class="log-header-actions">
<button class="log-toggle" onclick="toggleLogPanel()" id="log-toggle-btn" title="展开/收起">
<span id="log-toggle-icon">▲</span> <span id="log-toggle-text">展开</span>
</button>
<button class="log-fullscreen" onclick="toggleLogFullscreen()" id="log-fullscreen-btn"
title="全屏">⛶ 全屏</button>
<button class="log-clear" onclick="clearLog()">清除</button>
</div>
</div>
<div class="log-content" id="log-content"></div>
</div>
</div>
</div>
<!-- 右键菜单 -->
<div class="context-menu" id="context-menu">
<button class="context-menu-item" onclick="openCreateFileModal()">新建文件</button>
<button class="context-menu-item" onclick="openCreateFolderModal()">新建文件夹</button>
<button class="context-menu-item" id="context-menu-refresh" onclick="refreshFileTree()"
style="display: none;">🔄 刷新</button>
<div class="context-menu-divider" id="context-menu-divider" style="display: none;"></div>
<button class="context-menu-item" id="context-menu-download" onclick="downloadFileOrFolder()"
style="display: none;">⬇️ 下载</button>
<button class="context-menu-item" id="context-menu-copy" onclick="copyFileOrFolder()"
style="display: none;">📋 复制</button>
<button class="context-menu-item" id="context-menu-rename" onclick="openRenameModal()"
style="display: none;">重命名</button>
<button class="context-menu-item" id="context-menu-delete" onclick="openDeleteConfirmModal()"
style="display: none; color: #d32f2f;">删除</button>
</div>
<!-- 创建文件模态框 -->
<div id="create-file-modal" class="modal">
<div class="modal-content">
<h2>新建文件</h2>
<form id="create-file-form">
<div class="form-group">
<label>文件名 *</label>
<input type="text" id="file-name-input" required placeholder="例如: test.py" autofocus>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeCreateFileModal()">取消</button>
<button type="submit" class="btn btn-primary">创建</button>
</div>
</form>
</div>
</div>
<!-- 创建文件夹模态框 -->
<div id="create-folder-modal" class="modal">
<div class="modal-content">
<h2>新建文件夹</h2>
<form id="create-folder-form">
<div class="form-group">
<label>文件夹名称 *</label>
<input type="text" id="folder-name-input" required placeholder="例如: myfolder" autofocus>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeCreateFolderModal()">取消</button>
<button type="submit" class="btn btn-primary">创建</button>
</div>
</form>
</div>
</div>
<!-- 重命名模态框 -->
<div id="rename-modal" class="modal">
<div class="modal-content">
<h2>重命名</h2>
<form id="rename-form">
<div class="form-group">
<label>新名称 *</label>
<input type="text" id="rename-input" required autofocus>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeRenameModal()">取消</button>
<button type="submit" class="btn btn-primary">确定</button>
</div>
</form>
</div>
</div>
<!-- 删除确认模态框 -->
<div id="delete-confirm-modal" class="modal">
<div class="modal-content">
<h2 style="color: #d32f2f;">确认删除</h2>
<p id="delete-confirm-message" style="margin: 20px 0; color: #555;">确定要删除这个文件吗?</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeDeleteConfirmModal()">取消</button>
<button type="button" class="btn btn-danger" onclick="confirmDelete()">删除</button>
</div>
</div>
</div>
<!-- 文件夹下载确认模态框 -->
<div id="download-folder-confirm-modal" class="modal">
<div class="modal-content">
<h2>确认下载</h2>
<p id="download-folder-confirm-message" style="margin: 20px 0; color: #555;">文件夹大小超过1GB,确定要继续下载吗?</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeDownloadFolderConfirmModal()">取消</button>
<button type="button" class="btn btn-primary" onclick="confirmDownloadFolder()">确定下载</button>
</div>
</div>
</div>
<!-- 文件下载确认模态框 -->
<div id="download-file-confirm-modal" class="modal">
<div class="modal-content">
<h2>确认下载</h2>
<p id="download-file-confirm-message" style="margin: 20px 0; color: #555;">文件较大,确定要继续下载吗?</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeDownloadFileConfirmModal()">取消</button>
<button type="button" class="btn btn-primary" onclick="confirmDownloadFile()">确定下载</button>
</div>
</div>
</div>
<!-- AI 代码补全模态框 -->
<div id="ai-complete-modal" class="modal">
<div class="modal-content" style="max-width: 800px; max-height: 90vh; overflow-y: auto;">
<h2>🤖 AI 代码补全</h2>
<div class="form-group">
<label>提示词(描述你的需求)</label>
<input type="text" id="ai-prompt-input" placeholder="例如:优化这段代码、添加错误处理、重构为函数等" autofocus>
</div>
<div class="form-group">
<label>选中的代码</label>
<textarea id="ai-selected-code" readonly style="width: 100%; min-height: 100px; padding: 10px; background: #f5f5f5; border: 1px solid #ce93d8; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12px; resize: vertical;"></textarea>
</div>
<div id="ai-result-container" style="display: none; margin-top: 20px;">
<label>生成的代码</label>
<textarea id="ai-generated-code" readonly style="width: 100%; min-height: 200px; padding: 10px; background: #f5f5f5; border: 1px solid #ce93d8; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12px; resize: vertical;"></textarea>
</div>
<div id="ai-loading" style="display: none; text-align: center; padding: 20px; color: #9c27b0;">
<p>正在调用 AI 生成代码...</p>
</div>
<div id="ai-error" style="display: none; padding: 10px; background: #ffebee; border: 1px solid #d32f2f; border-radius: 4px; color: #d32f2f; margin-top: 10px;"></div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeAICompleteModal()">取消</button>
<button type="button" class="btn btn-primary" id="ai-submit-btn" onclick="submitAIComplete()">生成代码</button>
<button type="button" class="btn btn-primary" id="ai-accept-btn" onclick="acceptAICode()" style="display: none;">采纳代码</button>
</div>
</div>
</div>
<script src="/static/monaco-editor/min/vs/loader.js"></script>
<script>
let editor = null;
let currentFile = null;
let projectId = {{ project_id }};
let fileTree = [];
let contextMenuTarget = null; // 右键菜单的目标路径
let contextMenuIsDirectory = false; // 右键菜单的目标是否为目录
let currentRunId = null; // 当前运行的ID
let statusCheckInterval = null; // 状态检查定时器
let lastLogPosition = 0; // 上次查询的日志位置
let appConfig = { python_cmd: 'python' }; // 应用配置,默认值
let expandedPaths = new Set(); // 展开的目录路径集合
let autoSaveTimer = null; // 自动保存定时器
let isAutoSaving = false; // 是否正在自动保存
let openTabs = []; // 打开的标签页列表 [{path, content, modified, savedContent}]
let activeTabIndex = -1; // 当前激活的标签页索引
let selectedItem = null; // 文件树中当前选中的项目(文件或文件夹路径)
let isDownloading = false; // 是否正在下载,防止重复点击
// 初始化Monaco Editor
// 优先使用本地文件,如果不存在则使用 CDN
const monacoPath = '/static/monaco-editor/min/vs';
require.config({ paths: { vs: monacoPath } });
require(['vs/editor/editor.main'], function () {
const editorContainer = document.getElementById('editor-container');
const placeholder = document.getElementById('editor-placeholder');
// 创建编辑器(初始隐藏)
editor = monaco.editor.create(editorContainer, {
value: '',
language: 'python',
theme: 'vs',
automaticLayout: true,
fontSize: 14,
minimap: { enabled: true },
scrollBeyondLastLine: false,
// 启用代码建议(关键配置)
suggest: {
showKeywords: true,
showSnippets: true,
showFields: true,
showFunctions: true,
showVariables: true
},
quickSuggestions: {
other: true,
comments: false,
strings: false
},
acceptSuggestionOnCommitCharacter: true,
acceptSuggestionOnEnter: "on"
});
// 初始隐藏编辑器( TODO 一开始为none, 改为block不确定是否有问题, 带确认)
editorContainer.style.display = 'block';
// 注册 Monaco 编辑器快捷键:Ctrl+K 打开 AI 补全
// 直接注册,不添加条件,这样无论何时都能触发
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, function() {
openAICompleteModal();
});
// 监听内容变化,实现自动保存和标记修改
editor.onDidChangeModelContent(() => {
if (activeTabIndex >= 0 && !isAutoSaving) {
const tab = openTabs[activeTabIndex];
const currentContent = editor.getValue();
// 标记是否修改
tab.modified = currentContent !== tab.savedContent;
tab.content = currentContent;
renderTabs();
// 清除之前的定时器
if (autoSaveTimer) {
clearTimeout(autoSaveTimer);
}
// 设置新的定时器,2秒后自动保存(防抖)
autoSaveTimer = setTimeout(() => {
autoSave();
}, 2000);
}
});
// 编辑器初始化完成后,如果有活动的标签页,设置编辑器内容
if (activeTabIndex >= 0 && activeTabIndex < openTabs.length) {
const tab = openTabs[activeTabIndex];
if (tab && tab.content !== undefined && tab.content !== null) {
editor.setValue(tab.content);
// 设置语言
const ext = tab.path.split('.').pop().toLowerCase();
const languageMap = {
'py': 'python',
'js': 'javascript',
'html': 'html',
'css': 'css',
'json': 'json',
'md': 'markdown',
'txt': 'plaintext'
};
monaco.editor.setModelLanguage(editor.getModel(), languageMap[ext] || 'plaintext');
}
}
});
// 加载配置
async function loadConfig() {
try {
const response = await fetch('/api/config');
if (response.ok) {
appConfig = await response.json();
}
} catch (error) {
console.error('加载配置失败:', error);
// 使用默认配置,不显示错误提示
}
}
// 查找第一个可编辑的文件
function findFirstEditableFile(items) {
// 可编辑的文件扩展名
const editableExtensions = ['py', 'js', 'html', 'css', 'json', 'md', 'txt', 'xml', 'yaml', 'yml', 'sh', 'bat', 'ps1'];
for (const item of items) {
if (item.type === 'file') {
const ext = item.path.split('.').pop().toLowerCase();
if (editableExtensions.includes(ext)) {
return item.path;
}
} else if (item.type === 'directory' && item.children && item.children.length > 0) {
// 递归查找子目录
const found = findFirstEditableFile(item.children);
if (found) {
return found;
}
}
}
return null;
}
// 加载文件树
async function loadFileTree() {
try {
const response = await fetch(`/api/projects/${projectId}/files`);
fileTree = await response.json();
renderFileTree();
// 检查 URL hash 中是否有文件路径
const hashPath = window.location.hash ? decodeURIComponent(window.location.hash.slice(1)) : null;
// 如果当前没有打开文件
if (!currentFile && fileTree.length > 0) {
let fileToOpen = null;
// 如果 URL hash 中有路径,检查文件是否存在
if (hashPath) {
if (checkFileExistsInTree(hashPath, fileTree)) {
fileToOpen = hashPath;
} else {
// 文件不存在,清除 hash
window.location.hash = '';
}
}
// 如果没有指定文件或文件不存在,打开第一个可编辑的文件
if (!fileToOpen) {
fileToOpen = findFirstEditableFile(fileTree);
}
if (fileToOpen) {
// 延迟打开文件,确保编辑器已初始化
setTimeout(() => {
openFile(fileToOpen);
}, 100);
}
}
} catch (error) {
console.error('加载文件树失败:', error);
showToast('加载文件树失败: ' + error.message, 'error');
}
}
// 刷新文件树(保持当前编辑状态)
async function refreshFileTree() {
closeContextMenu();
try {
const response = await fetch(`/api/projects/${projectId}/files`);
fileTree = await response.json();
renderFileTree();
// 检查当前打开的文件是否还存在
if (currentFile) {
const fileExists = checkFileExistsInTree(currentFile.path, fileTree);
if (!fileExists) {
// 文件被删除,加载默认文件
showToast('当前文件已被删除,正在加载默认文件', 'info');
currentFile = null;
document.getElementById('file-name').textContent = '未打开文件';
const runBtn = document.getElementById('run-btn');
const saveBtn = document.getElementById('save-btn');
runBtn.disabled = true;
saveBtn.disabled = true;
// 显示占位符
const placeholder = document.getElementById('editor-placeholder');
const editorContainer = document.getElementById('editor-container');
if (placeholder) placeholder.style.display = 'flex';
if (editorContainer) editorContainer.style.display = 'none';
// 打开第一个可编辑文件
if (fileTree.length > 0) {
const firstFile = findFirstEditableFile(fileTree);
if (firstFile) {
setTimeout(() => {
openFile(firstFile);
}, 100);
}
}
} else {
// 文件还存在,保持编辑状态
showToast('文件树已刷新', 'success');
}
} else {
showToast('文件树已刷新', 'success');
}
} catch (error) {
console.error('刷新文件树失败:', error);
showToast('刷新文件树失败: ' + error.message, 'error');
}
}
// 检查文件是否存在于文件树中
function checkFileExistsInTree(filePath, items) {
for (const item of items) {
if (item.path === filePath) {
return true;
}
if (item.type === 'directory' && item.children && item.children.length > 0) {
if (checkFileExistsInTree(filePath, item.children)) {
return true;
}
}
}
return false;
}
// 渲染文件树
function renderFileTree() {
const container = document.getElementById('file-tree');
container.innerHTML = '';
renderFileTreeItems(fileTree, container, 0);
}
function renderFileTreeItems(items, container, level) {
items.forEach(item => {
const div = document.createElement('div');
const isExpanded = item.type === 'directory' && item.children && item.children.length > 0 && expandedPaths.has(item.path);
// 选中状态:只有选中的项目显示高亮
const isSelected = item.path === selectedItem;
let className = `file-item ${item.type} ${isSelected ? 'active' : ''}`;
if (isExpanded) {
className += ' expanded';
}
div.className = className;
div.style.paddingLeft = `${level * 16 + 8}px`;
// 添加 title 属性,鼠标悬浮时显示完整路径
div.title = item.path;
// 设置拖拽属性
div.draggable = true;
div.dataset.path = item.path;
div.dataset.type = item.type;
// 构建HTML内容
let htmlContent = '';
if (item.type === 'directory') {
// 目录:展开图标(如果有子项)或占位符 -> 文件夹图标 -> 文件名
if (item.children && item.children.length > 0) {
const expandIcon = isExpanded ? '▼' : '>';
htmlContent = `<span class="expand-icon">${expandIcon}</span><span class="folder-icon">📁</span><span>${escapeHtml(item.name)}</span>`;
} else {
htmlContent = `<span class="expand-placeholder"></span><span class="folder-icon">📁</span><span>${escapeHtml(item.name)}</span>`;
}
} else {
// 文件:占位符 -> 文件图标 -> 文件名
htmlContent = `<span class="expand-placeholder"></span><span class="file-icon">📄</span><span>${escapeHtml(item.name)}</span>`;
}
div.innerHTML = htmlContent;
let childrenDiv = null;
// 如果是目录且有子项,创建子容器
if (item.type === 'directory' && item.children && item.children.length > 0) {
childrenDiv = document.createElement('div');
childrenDiv.className = 'file-children';
// 根据展开状态决定是否显示
childrenDiv.style.display = isExpanded ? 'block' : 'none';
renderFileTreeItems(item.children, childrenDiv, level + 1);
}
div.onclick = (e) => {
e.stopPropagation();
// 设置选中状态
selectedItem = item.path;
if (item.type === 'file') {
openFile(item.path);
} else if (childrenDiv) {
// 切换目录展开/折叠
const wasExpanded = expandedPaths.has(item.path);
const expandIcon = div.querySelector('.expand-icon');
if (wasExpanded) {
expandedPaths.delete(item.path);
childrenDiv.style.display = 'none';
div.classList.remove('expanded');
if (expandIcon) {
expandIcon.textContent = '>';
}
} else {
expandedPaths.add(item.path);
childrenDiv.style.display = 'block';
div.classList.add('expanded');
if (expandIcon) {
expandIcon.textContent = '▼';
}
}
// 重新渲染文件树以更新选中样式
renderFileTree();
}
};
// 添加右键菜单
div.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
showContextMenu(e, item.path, item.type === 'directory');
};
// 拖拽开始
div.addEventListener('dragstart', (e) => {
e.stopPropagation();
div.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
const dragData = {
path: item.path,
type: item.type,
name: item.name
};
e.dataTransfer.setData('text/plain', JSON.stringify(dragData));
// 存储到全局变量,以便在dragover中使用
window.currentDragData = dragData;
});
// 拖拽结束
div.addEventListener('dragend', (e) => {
e.stopPropagation();
div.classList.remove('dragging');
// 清除所有拖拽样式
document.querySelectorAll('.file-item').forEach(el => {
el.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
});
// 清除全局拖拽数据
window.currentDragData = null;
});
// 拖拽悬停(在目标上)
div.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
// 尝试获取拖拽数据(注意:dragover事件中可能无法获取dataTransfer数据)
// 使用全局变量来存储当前拖拽的项目
if (!window.currentDragData) return;
const dragged = window.currentDragData;
// 不能拖到自己或自己的子目录
if (dragged.path === item.path || item.path.startsWith(dragged.path + '/')) {
e.dataTransfer.dropEffect = 'none';
return;
}
e.dataTransfer.dropEffect = 'move';
// 计算鼠标位置,决定是插入到上方还是下方
const rect = div.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const mouseY = e.clientY;
// 清除之前的样式
document.querySelectorAll('.file-item').forEach(el => {
el.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
});
// 如果是目录,可以拖入
if (item.type === 'directory') {
div.classList.add('drag-over');
} else {
// 文件项,显示上方或下方指示
if (mouseY < midY) {
div.classList.add('drag-over-top');
} else {
div.classList.add('drag-over-bottom');
}
}
});
// 拖拽离开
div.addEventListener('dragleave', (e) => {
e.stopPropagation();
// 只有当真正离开元素时才移除样式
if (!div.contains(e.relatedTarget)) {
div.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
}
});
// 放置
div.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
const dragData = e.dataTransfer.getData('text/plain');
if (!dragData) return;
const dragged = JSON.parse(dragData);
// 清除所有拖拽样式
document.querySelectorAll('.file-item').forEach(el => {
el.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
});
// 不能拖到自己或自己的子目录
if (dragged.path === item.path || item.path.startsWith(dragged.path + '/')) {
showToast('不能移动到自身或子目录', 'error');
return;
}
// 执行移动
if (item.type === 'directory') {
// 拖到目录中
moveFileOrFolder(dragged.path, item.path, dragged.name);
} else {
// 拖到文件上,移动到同一目录
const pathParts = item.path.split('/');
pathParts.pop(); // 移除文件名
const targetDir = pathParts.length > 0 ? pathParts.join('/') : '';
moveFileOrFolder(dragged.path, targetDir, dragged.name);
}
});
container.appendChild(div);
if (childrenDiv) {
container.appendChild(childrenDiv);
}
});
}
// 展开包含指定文件路径的所有父目录
function expandParentDirectories(filePath) {
const pathParts = filePath.split('/');
let currentPath = '';
for (let i = 0; i < pathParts.length - 1; i++) {
if (i === 0) {
currentPath = pathParts[i];
} else {
currentPath = currentPath + '/' + pathParts[i];
}
expandedPaths.add(currentPath);
}
}
// 渲染标签页
function renderTabs() {
const container = document.getElementById('tabs-container');
if (!container) {
// 容器还未加载,跳过渲染
return;
}
container.innerHTML = '';
openTabs.forEach((tab, index) => {
const tabItem = document.createElement('div');
tabItem.className = `tab-item ${index === activeTabIndex ? 'active' : ''} ${tab.modified ? 'modified' : ''}`;
tabItem.title = tab.path;
const tabName = document.createElement('span');
tabName.className = 'tab-name';
tabName.textContent = tab.path.split('/').pop();
const tabClose = document.createElement('span');
tabClose.className = 'tab-close';
tabClose.textContent = '×';
tabClose.onclick = (e) => {
e.stopPropagation();
closeTab(index);
};
tabItem.appendChild(tabName);
tabItem.appendChild(tabClose);
tabItem.onclick = () => {
switchToTab(index);
};
container.appendChild(tabItem);
});
}
// 切换到指定标签页
function switchToTab(index) {
if (index < 0 || index >= openTabs.length) return;
if (index === activeTabIndex) return;
// 保存当前标签页的内容
if (activeTabIndex >= 0 && editor) {
openTabs[activeTabIndex].content = editor.getValue();
}
// 切换到新标签页
activeTabIndex = index;
const tab = openTabs[index];
currentFile = { path: tab.path, content: tab.content };
// 设置选中项为当前标签页的文件
selectedItem = tab.path;
// 展开包含该文件的所有父目录
expandParentDirectories(tab.path);
// 更新编辑器内容
if (editor) {
editor.setValue(tab.content || '');
// 设置语言
const ext = tab.path.split('.').pop().toLowerCase();
const languageMap = {
'py': 'python',
'js': 'javascript',
'html': 'html',
'css': 'css',
'json': 'json',
'md': 'markdown',
'txt': 'plaintext'
};
monaco.editor.setModelLanguage(editor.getModel(), languageMap[ext] || 'plaintext');
} else {
// 如果编辑器还未初始化,等待初始化完成后再设置内容
// 这会在编辑器初始化回调中处理
}
// 更新UI
document.getElementById('file-name').textContent = tab.path;
const runBtn = document.getElementById('run-btn');
const saveBtn = document.getElementById('save-btn');
// 如果有运行中的进程,运行按钮显示为终止
if (currentRunId) {
runBtn.disabled = false;
runBtn.textContent = '▇ 终止';
runBtn.className = 'btn btn-danger';
} else {
runBtn.disabled = !tab.path.endsWith('.py');
runBtn.textContent = '▶ 运行';
runBtn.className = 'btn btn-primary';
}
saveBtn.disabled = false;
// 更新 URL hash(支持刷新后恢复)
window.location.hash = encodeURIComponent(tab.path);
renderTabs();
renderFileTree();
}
// 关闭标签页
function closeTab(index) {
if (index < 0 || index >= openTabs.length) return;
const tab = openTabs[index];
// 如果有未保存的修改,提示用户
if (tab.modified) {
if (!confirm(`文件 "${tab.path.split('/').pop()}" 有未保存的修改,确定要关闭吗?`)) {
return;
}
}
// 删除标签页
openTabs.splice(index, 1);
// 调整活动标签页索引
if (openTabs.length === 0) {
// 没有标签页了
activeTabIndex = -1;
currentFile = null;
document.getElementById('file-name').textContent = '未打开文件';
const runBtn = document.getElementById('run-btn');
const saveBtn = document.getElementById('save-btn');
// 如果有运行中的进程,运行按钮仍然显示为终止
if (currentRunId) {
runBtn.disabled = false;
runBtn.textContent = '▇ 终止';
runBtn.className = 'btn btn-danger';
} else {
runBtn.disabled = true;
runBtn.textContent = '▶ 运行';
runBtn.className = 'btn btn-primary';
}
saveBtn.disabled = true;
// 显示占位符
const placeholder = document.getElementById('editor-placeholder');
const editorContainer = document.getElementById('editor-container');
if (placeholder) placeholder.style.display = 'flex';
if (editorContainer) editorContainer.style.display = 'none';
} else if (index === activeTabIndex) {
// 关闭的是当前标签页,切换到相邻标签页
const newIndex = Math.min(index, openTabs.length - 1);
activeTabIndex = -1; // 重置,以便switchToTab生效
switchToTab(newIndex);
} else if (index < activeTabIndex) {
// 关闭的标签页在当前标签页之前,调整索引
activeTabIndex--;
}
renderTabs();
}
// 打开文件
async function openFile(filePath) {
try {
// 设置选中项为当前文件
selectedItem = filePath;
// 检查文件是否已经打开
const existingIndex = openTabs.findIndex(tab => tab.path === filePath);
if (existingIndex >= 0) {
// 文件已打开,切换到该标签页
switchToTab(existingIndex);
return;
}
const response = await fetch(`/api/projects/${projectId}/files/read?path=${encodeURIComponent(filePath)}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '读取文件失败');
}
const data = await response.json();
// 展开包含该文件的所有父目录
expandParentDirectories(filePath);
// 添加到标签页列表
openTabs.push({
path: filePath,
content: data.content,
savedContent: data.content,
modified: false
});
// 切换到新标签页
activeTabIndex = -1; // 重置,以便switchToTab生效
switchToTab(openTabs.length - 1);
// 显示编辑器
const placeholder = document.getElementById('editor-placeholder');
const editorContainer = document.getElementById('editor-container');
if (placeholder) placeholder.style.display = 'none';
if (editorContainer) editorContainer.style.display = 'block';
// 清除自动保存定时器
if (autoSaveTimer) {
clearTimeout(autoSaveTimer);
autoSaveTimer = null;
}
// 隐藏自动保存状态
const autoSaveStatus = document.getElementById('auto-save-status');
if (autoSaveStatus) {
autoSaveStatus.style.display = 'none';
}
// 更新 URL hash(支持刷新后恢复)
window.location.hash = encodeURIComponent(filePath);
} catch (error) {
console.error('打开文件失败:', error);
showToast('打开文件失败: ' + error.message, 'error');
}
}
// 保存文件(支持自动保存和手动保存)
async function saveFile(isAuto = false) {
if (activeTabIndex < 0 || !currentFile) return;
try {
const content = editor.getValue();
const response = await fetch(`/api/projects/${projectId}/files/write`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: currentFile.path,
content: content
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '保存文件失败');
}
// 更新标签页状态
const tab = openTabs[activeTabIndex];
tab.savedContent = content;
tab.content = content;
tab.modified = false;
renderTabs();
if (isAuto) {
// 自动保存:显示状态提示
updateAutoSaveStatus();
} else {
// 手动保存:显示提示消息
showToast('文件已保存', 'success');
}
} catch (error) {
console.error('保存文件失败:', error);
if (!isAuto) {
showToast('保存文件失败: ' + error.message, 'error');
}
}
}
// 自动保存
async function autoSave() {
if (!currentFile || isAutoSaving) return;
isAutoSaving = true;
try {
await saveFile(true);
} finally {
isAutoSaving = false;
}
}
// 更新自动保存状态显示
function updateAutoSaveStatus() {
const statusElement = document.getElementById('auto-save-status');
if (statusElement) {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeStr = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
statusElement.textContent = `已自动保存(${timeStr})`;
statusElement.style.display = 'inline';
// 3秒后淡出
setTimeout(() => {
statusElement.style.opacity = '0';
statusElement.style.transition = 'opacity 0.5s';
setTimeout(() => {
statusElement.style.display = 'none';
statusElement.style.opacity = '1';
statusElement.style.transition = '';
}, 500);
}, 3000);
}
}
// 运行代码
async function runCode() {
// 如果正在运行,则终止
if (currentRunId) {
await terminateRun();
return;
}
if (!currentFile) {
showToast('请先打开一个文件', 'error');
return;
}
// 先保存文件
await saveFile();
// 确保之前的轮询已停止
stopStatusCheck();
// 确保 currentRunId 已清空
currentRunId = null;
lastLogPosition = 0;
// 更新日志面板标题为运行命令
const logTitle = document.getElementById('log-title');
if (logTitle) {
logTitle.textContent = '$ ' + appConfig.python_cmd + ' ' + currentFile.path;
}
// 添加分隔线和新的运行命令(不清空之前的日志)
addLog('', 'separator'); // 添加空行作为分隔
addLog('$ ' + appConfig.python_cmd + ' ' + currentFile.path, 'command');
const runBtn = document.getElementById('run-btn');
runBtn.disabled = false;
// 初始显示运行按钮
runBtn.textContent = '▶ 运行';
runBtn.className = 'btn btn-primary';
// 自动打开日志视图
openLogPanel();
try {
const response = await fetch(`/api/projects/${projectId}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: currentFile.path
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '运行失败');
}
const result = await response.json();
currentRunId = result.run_id;
// 防抖:0.5秒后按钮改为终止
setTimeout(() => {
if (currentRunId) {
runBtn.textContent = '▇ 终止';
runBtn.className = 'btn btn-danger';
runBtn.onclick = runCode; // 更新点击事件为终止
}
}, 500);
// 延迟一下再开始轮询,给后端进程初始化时间
setTimeout(() => {
if (currentRunId) {
startStatusCheck();
}
}, 200);
} catch (error) {
console.error('运行代码失败:', error);
addLog('运行代码失败: ' + error.message, 'error');
resetRunButton();
}
}
// 终止运行
async function terminateRun() {
if (!currentRunId) return;
try {
const response = await fetch(`/api/projects/${projectId}/run/terminate/${currentRunId}`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '终止失败');
}
addLog('程序已被用户终止', 'error');
stopStatusCheck();
resetRunButton();
currentRunId = null;
lastLogPosition = 0;
} catch (error) {
console.error('终止运行失败:', error);
showToast('终止运行失败: ' + error.message, 'error');
}
}
// 开始状态检查(轮询方式)
function startStatusCheck() {
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
}
if (!currentRunId) {
return;
}
// 立即检查一次
checkStatus();
// 每0.5秒轮询一次
statusCheckInterval = setInterval(() => {
if (!currentRunId) {
stopStatusCheck();
return;
}
checkStatus();
}, 500);
}
// 检查运行状态和获取日志
async function checkStatus() {
if (!currentRunId) {
return;
}
try {
const response = await fetch(`/api/projects/${projectId}/run/status/${currentRunId}?last_position=${lastLogPosition}`);
if (!response.ok) {
if (response.status === 404) {
// 运行ID不存在,停止轮询
stopStatusCheck();
resetRunButton();
currentRunId = null;
}
return;
}
const result = await response.json();
// 如果还在启动中,不处理,等待下次轮询
if (result.status === 'starting') {
return;
}
// 更新日志位置
if (result.position !== undefined) {
lastLogPosition = result.position;
}
// 显示增量输出
if (result.new_output) {
addLog(result.new_output, 'success');
}
// 如果已完成或出错
if (result.status !== 'running') {
stopStatusCheck();
// 显示错误信息
if (result.stderr) {
addLog(result.stderr, 'error');
}
if (result.error) {
addLog('运行错误: ' + result.error, 'error');
}
if (result.returncode !== undefined && result.returncode !== 0) {
addLog(`程序退出,返回码: ${result.returncode}`, 'error');
}
resetRunButton();
currentRunId = null;
lastLogPosition = 0;
}
} catch (error) {
console.error('检查运行状态失败:', error);
}
}
// 停止状态检查
function stopStatusCheck() {
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
statusCheckInterval = null;
}
}
// 重置运行按钮
function resetRunButton() {
const runBtn = document.getElementById('run-btn');
runBtn.textContent = '▶ 运行';
runBtn.className = 'btn btn-primary';
runBtn.disabled = false;
runBtn.onclick = runCode;
}
// AI 代码补全相关变量
let aiSelectedCode = '';
let aiGeneratedCode = '';
let aiInsertPosition = null; // 保存插入位置 {lineNumber, column}
let aiContextCode = ''; // 上下文代码
let aiBaseIndent = ''; // 基础缩进(用于保持缩进一致)
let aiHasSelectedCode = false; // 是否有选中代码
// 打开 AI 代码补全模态框
function openAICompleteModal() {
if (!editor) {
showToast('请先打开一个文件', 'error');
return;
}
const model = editor.getModel();
const selection = editor.getSelection();
const position = editor.getPosition(); // 当前光标位置
let selectedText = '';
let contextBefore = '';
let contextAfter = '';
// 辅助函数:获取行的缩进
function getLineIndent(lineNumber) {
const lineContent = model.getLineContent(lineNumber);
const match = lineContent.match(/^(\s*)/);
return match ? match[1] : '';
}
// 辅助函数:检测缩进类型(空格或制表符)
function detectIndentType(text) {
if (text.includes('\t')) {
return '\t';
}
// 检测空格缩进的大小(通常是2或4)
const lines = text.split('\n').filter(line => line.trim());
if (lines.length === 0) return ' '; // 默认4个空格
for (const line of lines) {
const indent = line.match(/^(\s+)/);
if (indent) {
const spaces = indent[1].length;
// 检测是否是2、4、8的倍数
if (spaces % 4 === 0) return ' '.repeat(4);
if (spaces % 2 === 0) return ' '.repeat(2);
return ' '.repeat(spaces);
}
}
return ' '; // 默认4个空格
}
// 辅助函数:检测缩进单位(用于添加一个缩进级别)
function detectIndentUnit(text) {
if (text.includes('\t')) {
return '\t';
}
// 检测最常见的缩进大小
const lines = text.split('\n').filter(line => line.trim());
if (lines.length === 0) return ' '; // 默认4个空格
// 统计所有缩进的大小
const indentSizes = [];
for (const line of lines) {
const indent = line.match(/^(\s+)/);
if (indent) {
indentSizes.push(indent[1].length);
}
}
if (indentSizes.length === 0) return ' '; // 默认4个空格
// 找到最常见的缩进差值(通常是缩进单位)
const diffs = [];
for (let i = 1; i < indentSizes.length; i++) {
const diff = Math.abs(indentSizes[i] - indentSizes[i-1]);
if (diff > 0 && diff <= 8) { // 只考虑合理的差值
diffs.push(diff);
}
}
// 找到最常见的差值
if (diffs.length > 0) {
const counts = {};
diffs.forEach(d => {
counts[d] = (counts[d] || 0) + 1;
});
const mostCommon = Object.keys(counts).reduce((a, b) => counts[a] > counts[b] ? a : b);
const unit = parseInt(mostCommon);
// 优先使用4个空格,如果是2的倍数但不是4,使用2
if (unit === 4) return ' ';
if (unit === 2) return ' ';
return ' '.repeat(unit);
}
// 如果没有找到差值,检查最常见的缩进大小
const counts = {};
indentSizes.forEach(s => {
counts[s] = (counts[s] || 0) + 1;
});
const mostCommonSize = parseInt(Object.keys(counts).reduce((a, b) => counts[a] > counts[b] ? a : b));
// 如果最常见的缩进是4的倍数,返回4;如果是2的倍数,返回2
if (mostCommonSize % 4 === 0) return ' ';
if (mostCommonSize % 2 === 0) return ' ';
return ' '; // 默认4个空格
}
if (!selection.isEmpty()) {
// 有选中代码
selectedText = model.getValueInRange(selection);
if (!selectedText.trim()) {
showToast('选中的代码为空', 'error');
return;
}
aiSelectedCode = selectedText;
aiHasSelectedCode = true;
// 检测选中代码第一行的缩进
const firstLine = model.getLineContent(selection.startLineNumber);
const indentMatch = firstLine.match(/^(\s*)/);
aiBaseIndent = indentMatch ? indentMatch[1] : '';
// 获取选中代码周围的上下文(前后各15行)
const totalLines = model.getLineCount();
const contextLines = 15;
const startLine = Math.max(1, selection.startLineNumber - contextLines);
const endLine = Math.min(totalLines, selection.endLineNumber + contextLines);
// 获取上下文代码
const contextRange = {
startLineNumber: startLine,
startColumn: 1,
endLineNumber: endLine,
endColumn: model.getLineMaxColumn(endLine)
};
aiContextCode = model.getValueInRange(contextRange);
// 保存插入位置(选中区域的结束位置)
aiInsertPosition = {
lineNumber: selection.endLineNumber,
column: selection.endColumn
};
} else {
// 没有选中代码,获取光标位置的上下文
const currentLine = position.lineNumber;
const totalLines = model.getLineCount();
aiHasSelectedCode = false;
// 获取光标所在行的缩进
const currentLineContent = model.getLineContent(currentLine);
console.log("currentLineContent:",currentLineContent);
// 如果当前行是空行,获取上一行的缩进
if (currentLineContent.trim() === '') {
if (currentLine > 1) {
aiBaseIndent = getLineIndent(currentLine - 1);
} else {
aiBaseIndent = '';
}
} else {
// 当前行不是空行,获取光标所在位置的缩进
// 获取光标所在列之前的内容(包括缩进)
const lineBeforeCursor = currentLineContent.substring(0, position.column - 1);
const indentMatch = lineBeforeCursor.match(/^(\s*)/);
aiBaseIndent = indentMatch ? indentMatch[1] : getLineIndent(currentLine);
}
// 获取光标前后各15行作为上下文
const contextLines = 15;
const startLine = Math.max(1, currentLine - contextLines);
const endLine = Math.min(totalLines, currentLine + contextLines);
// 获取上下文代码
const contextRange = {
startLineNumber: startLine,
startColumn: 1,
endLineNumber: endLine,
endColumn: model.getLineMaxColumn(endLine)
};
const fullContext = model.getValueInRange(contextRange);
// 分割为前后两部分(以当前行为分界)
const lines = fullContext.split('\n');
const currentLineIndex = currentLine - startLine;
contextBefore = lines.slice(0, currentLineIndex).join('\n');
contextAfter = lines.slice(currentLineIndex + 1).join('\n');
aiSelectedCode = ''; // 没有选中代码
aiContextCode = fullContext; // 保存完整上下文
// 保存插入位置(光标位置)
aiInsertPosition = {
lineNumber: currentLine,
column: position.column
};
}
// 先让编辑器失去焦点,避免 aria-hidden 警告
try {
const editorDom = editor.getDomNode();
if (editorDom) {
editorDom.blur();
}
// 或者直接让当前活动元素失去焦点
if (document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
} catch (err) {
// 忽略错误
}
// 显示模态框
const selectedCodeTextarea = document.getElementById('ai-selected-code');
if (selectedText) {
selectedCodeTextarea.value = selectedText;
selectedCodeTextarea.previousElementSibling.textContent = '选中的代码';
} else {
// 没有选中代码,显示上下文
selectedCodeTextarea.value = aiContextCode || '(光标位置,无选中代码)';
selectedCodeTextarea.previousElementSibling.textContent = '上下文代码(光标前后各15行)';
}
document.getElementById('ai-prompt-input').value = '';
document.getElementById('ai-result-container').style.display = 'none';
document.getElementById('ai-loading').style.display = 'none';
document.getElementById('ai-error').style.display = 'none';
const submitBtn = document.getElementById('ai-submit-btn');
submitBtn.style.display = 'inline-block';
submitBtn.disabled = false; // 重置按钮状态
document.getElementById('ai-accept-btn').style.display = 'none';
document.getElementById('ai-complete-modal').classList.add('active');
// 延迟设置焦点,给 Monaco 编辑器时间处理焦点变化
setTimeout(() => {
const promptInput = document.getElementById('ai-prompt-input');
if (promptInput) {
promptInput.focus();
}
}, 100);
}
// 关闭 AI 代码补全模态框
function closeAICompleteModal() {
document.getElementById('ai-complete-modal').classList.remove('active');
aiSelectedCode = '';
aiGeneratedCode = '';
aiContextCode = '';
aiBaseIndent = '';
aiHasSelectedCode = false;
aiInsertPosition = null;
// 延迟将焦点返回编辑器,避免 aria-hidden 警告
setTimeout(() => {
if (editor) {
editor.focus();
}
}, 50);
}
// 提交 AI 代码补全请求
async function submitAIComplete() {
const prompt = document.getElementById('ai-prompt-input').value.trim();
if (!prompt && !aiSelectedCode && !aiContextCode) {
showToast('请输入提示词', 'error');
return;
}
const submitBtn = document.getElementById('ai-submit-btn');
const loadingDiv = document.getElementById('ai-loading');
const errorDiv = document.getElementById('ai-error');
const resultContainer = document.getElementById('ai-result-container');
// 显示加载状态
submitBtn.disabled = true;
loadingDiv.style.display = 'block';
errorDiv.style.display = 'none';
resultContainer.style.display = 'none';
try {
const requestBody = {
selected_code: aiSelectedCode,
context_code: aiContextCode, // 添加上下文代码
prompt: prompt,
file_path: currentFile ? currentFile.path : '',
cursor_line: aiInsertPosition ? aiInsertPosition.lineNumber : null,
cursor_column: aiInsertPosition ? aiInsertPosition.column : null
};
const response = await fetch(`/api/projects/${projectId}/ai/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'AI 代码补全失败');
}
const result = await response.json();
aiGeneratedCode = result.code;
// 显示结果
document.getElementById('ai-generated-code').value = aiGeneratedCode;
resultContainer.style.display = 'block';
submitBtn.style.display = 'none';
document.getElementById('ai-accept-btn').style.display = 'inline-block';
loadingDiv.style.display = 'none';
} catch (error) {
console.error('AI 代码补全失败:', error);
errorDiv.textContent = '错误: ' + error.message;
errorDiv.style.display = 'block';
loadingDiv.style.display = 'none';
submitBtn.disabled = false;
}
}
// 采纳 AI 生成的代码
function acceptAICode() {
if (!editor || !aiGeneratedCode || !aiInsertPosition) {
showToast('无法采纳代码', 'error');
return;
}
try {
// 获取当前选中的区域
const selection = editor.getSelection();
// 如果有选中内容,替换选中的代码
if (!selection.isEmpty()) {
editor.executeEdits('ai-complete', [{
range: selection,
text: aiGeneratedCode
}]);
} else {
console.log("aiInsertPosition:",aiInsertPosition);
// 如果没有选中,在插入位置插入代码
editor.executeEdits('ai-complete', [{
// column 为 0 表示在行首插入, 因为大模型会自动添加缩进
range: {
startLineNumber: aiInsertPosition.lineNumber,
startColumn: 0,
endLineNumber: aiInsertPosition.lineNumber,
endColumn: 0
},
text: aiGeneratedCode
}]);
}
showToast('代码已采纳', 'success');
closeAICompleteModal();
} catch (error) {
console.error('采纳代码失败:', error);
showToast('采纳代码失败: ' + error.message, 'error');
}
}
// 显示右键菜单
function showContextMenu(e, targetPath, isDirectory) {
contextMenuTarget = targetPath;
contextMenuIsDirectory = isDirectory;
const menu = document.getElementById('context-menu');
const divider = document.getElementById('context-menu-divider');
const downloadBtn = document.getElementById('context-menu-download');
const copyBtn = document.getElementById('context-menu-copy');
const renameBtn = document.getElementById('context-menu-rename');
const deleteBtn = document.getElementById('context-menu-delete');
const refreshBtn = document.getElementById('context-menu-refresh');
// 如果有选中的文件或文件夹,显示下载、复制、重命名和删除选项
if (targetPath && targetPath !== '') {
divider.style.display = 'block';
downloadBtn.style.display = 'block';
copyBtn.style.display = 'block';
renameBtn.style.display = 'block';
deleteBtn.style.display = 'block';
refreshBtn.style.display = 'none';
} else {
// 空白处右键,显示刷新选项
divider.style.display = 'none';
downloadBtn.style.display = 'none';
copyBtn.style.display = 'none';
renameBtn.style.display = 'none';
deleteBtn.style.display = 'none';
refreshBtn.style.display = 'block';
}
menu.style.left = e.pageX + 'px';
menu.style.top = e.pageY + 'px';
menu.classList.add('active');
}
// 关闭右键菜单
function closeContextMenu() {
const menu = document.getElementById('context-menu');
menu.classList.remove('active');
}
// 打开创建文件模态框
function openCreateFileModal() {
closeContextMenu();
document.getElementById('file-name-input').value = '';
document.getElementById('create-file-modal').classList.add('active');
setTimeout(() => {
document.getElementById('file-name-input').focus();
}, 100);
}
// 关闭创建文件模态框
function closeCreateFileModal() {
document.getElementById('create-file-modal').classList.remove('active');
}
// 创建文件
async function createFile() {
const fileName = document.getElementById('file-name-input').value.trim();
if (!fileName) {
showToast('请输入文件名', 'error');
return;
}
let filePath;
if (contextMenuTarget && contextMenuTarget !== '') {
if (contextMenuIsDirectory) {
// 如果是在目录上右键,在该目录下创建
filePath = contextMenuTarget + '/' + fileName;
} else {
// 如果是在文件上右键,在文件的父目录下创建
const pathParts = contextMenuTarget.split('/');
pathParts.pop(); // 移除文件名,获取父目录路径
if (pathParts.length > 0) {
filePath = pathParts.join('/') + '/' + fileName;
} else {
// 文件在根目录,直接在根目录创建
filePath = fileName;
}
}
} else {
// 在根目录创建
filePath = fileName;
}
try {
const response = await fetch(`/api/projects/${projectId}/files/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: filePath,
type: 'file'
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '创建文件失败');
}
closeCreateFileModal();
showToast(`文件已创建: ${filePath}`, 'success');
loadFileTree();
// 自动打开新创建的文件
setTimeout(() => openFile(filePath), 300);
} catch (error) {
console.error('创建文件失败:', error);
showToast('创建文件失败: ' + error.message, 'error');
}
}
// 打开创建文件夹模态框
function openCreateFolderModal() {
closeContextMenu();
document.getElementById('folder-name-input').value = '';
document.getElementById('create-folder-modal').classList.add('active');
setTimeout(() => {
document.getElementById('folder-name-input').focus();
}, 100);
}
// 关闭创建文件夹模态框
function closeCreateFolderModal() {
document.getElementById('create-folder-modal').classList.remove('active');
}
// 创建文件夹
async function createFolder() {
const folderName = document.getElementById('folder-name-input').value.trim();
if (!folderName) {
showToast('请输入文件夹名称', 'error');
return;
}
let folderPath;
if (contextMenuTarget && contextMenuTarget !== '') {
if (contextMenuIsDirectory) {
// 如果是在目录上右键,在该目录下创建
folderPath = contextMenuTarget + '/' + folderName;
} else {
// 如果是在文件上右键,在文件的父目录下创建
const pathParts = contextMenuTarget.split('/');
pathParts.pop(); // 移除文件名,获取父目录路径
if (pathParts.length > 0) {
folderPath = pathParts.join('/') + '/' + folderName;
} else {
// 文件在根目录,直接在根目录创建
folderPath = folderName;
}
}
} else {
// 在根目录创建
folderPath = folderName;
}
try {
const response = await fetch(`/api/projects/${projectId}/files/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: folderPath,
type: 'directory'
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '创建文件夹失败');
}
// 自动展开父目录,以便看到新创建的文件夹
if (contextMenuTarget && contextMenuTarget !== '') {
if (contextMenuIsDirectory) {
// 如果是在目录上右键,展开该目录
expandedPaths.add(contextMenuTarget);
} else {
// 如果是在文件上右键,展开文件的父目录
const pathParts = contextMenuTarget.split('/');
pathParts.pop(); // 移除文件名,获取父目录路径
if (pathParts.length > 0) {
expandedPaths.add(pathParts.join('/'));
}
}
}
closeCreateFolderModal();
showToast(`文件夹已创建: ${folderPath}`, 'success');
loadFileTree();
} catch (error) {
console.error('创建文件夹失败:', error);
showToast('创建文件夹失败: ' + error.message, 'error');
}
}
// 切换日志面板
function toggleLogPanel() {
const panel = document.getElementById('log-panel');
const icon = document.getElementById('log-toggle-icon');
const text = document.getElementById('log-toggle-text');
if (panel.classList.contains('collapsed')) {
// 展开:移除 collapsed 类,如果有保存的高度则恢复
panel.classList.remove('collapsed');
if (icon) icon.textContent = '▼';
if (text) text.textContent = '收起';
// 如果之前拖动设置了高度,保持该高度;否则恢复默认高度
if (!panel.style.height || panel.style.height === '0px') {
panel.style.height = '30%';
}
} else {
// 收起:保存当前高度,然后设置为 0
const currentHeight = panel.offsetHeight;
if (currentHeight > 0) {
panel.dataset.savedHeight = currentHeight + 'px';
}
panel.classList.add('collapsed');
panel.style.height = '0';
if (icon) icon.textContent = '▲';
if (text) text.textContent = '展开';
}
}
// 打开日志面板
function openLogPanel() {
const panel = document.getElementById('log-panel');
const icon = document.getElementById('log-toggle-icon');
const text = document.getElementById('log-toggle-text');
panel.classList.remove('collapsed');
if (icon) icon.textContent = '▼';
if (text) text.textContent = '收起';
// 恢复之前保存的高度,如果没有则使用默认高度
if (panel.dataset.savedHeight) {
panel.style.height = panel.dataset.savedHeight;
} else if (!panel.style.height || panel.style.height === '0px') {
panel.style.height = '30%';
}
}
// 日志行号计数器
let logLineNumber = 0;
// 添加日志(仅用于运行代码的输出)
function addLog(message, type = '') {
const logContent = document.getElementById('log-content');
const line = document.createElement('div');
line.className = `log-line ${type}`;
if (type === 'separator') {
// 分隔符使用 CSS 样式,不需要内容
logContent.appendChild(line);
return;
}
// 增加行号
logLineNumber++;
// 获取当前时间戳
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
const timestamp = `${hours}:${minutes}:${seconds}.${milliseconds}`;
// 创建行号元素
const lineNumber = document.createElement('span');
lineNumber.className = 'log-line-number';
lineNumber.textContent = String(logLineNumber).padStart(4, '0');
// 创建时间戳元素
const timestampEl = document.createElement('span');
timestampEl.className = 'log-line-timestamp';
timestampEl.textContent = timestamp;
// 创建内容元素
const content = document.createElement('span');
content.className = 'log-line-content';
// 处理多行消息,每行单独显示
const lines = message.split('\n');
if (lines.length === 1) {
// 单行消息
content.textContent = message;
line.appendChild(lineNumber);
line.appendChild(timestampEl);
line.appendChild(content);
logContent.appendChild(line);
} else {
// 多行消息,第一行显示行号和时间戳,后续行只显示内容
lines.forEach((lineText, index) => {
if (lineText === '' && index === lines.length - 1) {
// 忽略最后一行空行
return;
}
const logLine = document.createElement('div');
logLine.className = `log-line ${type}`;
if (index === 0) {
// 第一行显示行号和时间戳
logLine.appendChild(lineNumber);
logLine.appendChild(timestampEl);
} else {
// 后续行显示空白占位符
const emptyNumber = document.createElement('span');
emptyNumber.className = 'log-line-number';
emptyNumber.textContent = '';
emptyNumber.style.width = '50px';
logLine.appendChild(emptyNumber);
const emptyTimestamp = document.createElement('span');
emptyTimestamp.className = 'log-line-timestamp';
emptyTimestamp.textContent = '';
emptyTimestamp.style.width = '80px';
logLine.appendChild(emptyTimestamp);
}
const lineContent = document.createElement('span');
lineContent.className = 'log-line-content';
lineContent.textContent = lineText;
logLine.appendChild(lineContent);
logContent.appendChild(logLine);
});
}
// 自动滚动到底部,显示最新日志
logContent.scrollTop = logContent.scrollHeight;
}
// 显示悬浮提示
function showToast(message, type = 'info', autoCloseDelay = 2700) {
// 移除已存在的toast
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
// 如果autoCloseDelay为0,不自动关闭
if (autoCloseDelay > 0) {
// autoCloseDelay毫秒后自动移除
setTimeout(() => {
toast.style.animation = 'toastFadeOut 0.3s ease';
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
}, autoCloseDelay);
}
return toast; // 返回toast元素,方便外部操作
}
// 清除日志
function clearLog() {
document.getElementById('log-content').innerHTML = '';
logLineNumber = 0;
}
// 切换日志全屏
function toggleLogFullscreen() {
const panel = document.getElementById('log-panel');
const btn = document.getElementById('log-fullscreen-btn');
if (panel.classList.contains('fullscreen')) {
// 退出全屏
panel.classList.remove('fullscreen');
btn.textContent = '⛶ 全屏';
btn.title = '全屏';
// 退出全屏后,如果之前是收起状态,恢复收起状态(显示浮动按钮)
// 如果之前是展开状态,恢复展开状态
if (panel.dataset.wasCollapsed === 'true') {
// 恢复收起状态
panel.classList.add('collapsed');
panel.style.height = '0';
const toggleIcon = document.getElementById('log-toggle-icon');
const toggleText = document.getElementById('log-toggle-text');
if (toggleIcon) toggleIcon.textContent = '▲';
if (toggleText) toggleText.textContent = '展开';
} else {
// 恢复展开状态
panel.classList.remove('collapsed');
if (panel.dataset.savedHeight) {
panel.style.height = panel.dataset.savedHeight;
} else {
panel.style.height = '30%';
}
const toggleIcon = document.getElementById('log-toggle-icon');
const toggleText = document.getElementById('log-toggle-text');
if (toggleIcon) toggleIcon.textContent = '▼';
if (toggleText) toggleText.textContent = '收起';
}
// 清除标记
delete panel.dataset.wasCollapsed;
} else {
// 进入全屏前,记录当前是否收起
const wasCollapsed = panel.classList.contains('collapsed');
if (wasCollapsed) {
panel.dataset.wasCollapsed = 'true';
}
// 进入全屏
panel.classList.remove('collapsed');
panel.classList.add('fullscreen');
btn.textContent = '⛶ 退出';
btn.title = '退出全屏';
// 更新展开/收起按钮状态
const toggleIcon = document.getElementById('log-toggle-icon');
const toggleText = document.getElementById('log-toggle-text');
if (toggleIcon) toggleIcon.textContent = '▼';
if (toggleText) toggleText.textContent = '收起';
}
}
// 日志面板拖拽调整高度
let isResizing = false;
let startY = 0;
let startHeight = 0;
const minLogHeight = 50; // 最小高度(像素)
const maxLogHeightPercent = 90; // 最大高度(百分比)
function initLogResizer() {
const resizer = document.getElementById('log-resizer');
const panel = document.getElementById('log-panel');
const editorContainer = document.querySelector('.editor-container');
if (!resizer || !panel || !editorContainer) return;
resizer.addEventListener('mousedown', (e) => {
// 如果面板是折叠或全屏状态,不处理拖拽
if (panel.classList.contains('collapsed') || panel.classList.contains('fullscreen')) {
return;
}
e.preventDefault();
isResizing = true;
startY = e.clientY;
startHeight = panel.offsetHeight;
// 添加拖拽样式
resizer.classList.add('dragging');
// 禁用transition以获得流畅的拖拽体验
panel.style.transition = 'none';
// 防止文本选择
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ns-resize';
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const deltaY = startY - e.clientY; // 向上拖拽为正,向下拖拽为负
const newHeight = startHeight + deltaY;
const containerHeight = editorContainer.offsetHeight;
// 计算最小和最大高度
const minHeight = minLogHeight;
const maxHeight = (containerHeight * maxLogHeightPercent) / 100;
// 限制高度范围
let finalHeight = Math.max(minHeight, Math.min(newHeight, maxHeight));
// 设置新高度
panel.style.height = finalHeight + 'px';
});
document.addEventListener('mouseup', () => {
if (!isResizing) return;
isResizing = false;
const resizer = document.getElementById('log-resizer');
const panel = document.getElementById('log-panel');
// 移除拖拽样式
if (resizer) {
resizer.classList.remove('dragging');
}
// 恢复transition
if (panel) {
panel.style.transition = 'height 0.3s ease';
}
// 恢复文本选择和光标
document.body.style.userSelect = '';
document.body.style.cursor = '';
});
}
// 打开重命名模态框
function openRenameModal() {
closeContextMenu();
if (!contextMenuTarget || contextMenuTarget === '') {
showToast('请先选择一个文件或文件夹', 'error');
return;
}
// 获取当前名称
const currentName = contextMenuTarget.split('/').pop();
document.getElementById('rename-input').value = currentName;
document.getElementById('rename-modal').classList.add('active');
setTimeout(() => {
const input = document.getElementById('rename-input');
input.focus();
// 如果有后缀(包含点,且点不在开头),只选中文件名部分
const lastDotIndex = currentName.lastIndexOf('.');
if (lastDotIndex > 0 && lastDotIndex < currentName.length - 1) {
// 有后缀,只选中文件名部分(不包含点和后缀)
input.setSelectionRange(0, lastDotIndex);
} else {
// 没有后缀或点在最前面,选中全部
input.select();
}
}, 100);
}
// 关闭重命名模态框
function closeRenameModal() {
document.getElementById('rename-modal').classList.remove('active');
}
// 重命名文件或文件夹
async function renameItem() {
if (!contextMenuTarget || contextMenuTarget === '') {
showToast('请先选择一个文件或文件夹', 'error');
return;
}
// 获取当前名称
const currentName = contextMenuTarget.split('/').pop();
const newName = document.getElementById('rename-input').value.trim();
if (!newName || newName === currentName) {
closeRenameModal();
return;
}
// 验证名称
if (newName.includes('/') || newName.includes('\\')) {
showToast('名称不能包含路径分隔符', 'error');
return;
}
try {
const response = await fetch(`/api/projects/${projectId}/files/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
old_path: contextMenuTarget,
new_name: newName
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '重命名失败');
}
const result = await response.json();
const newPath = result.new_path;
// 如果重命名的是当前打开的文件,更新 currentFile
if (currentFile && currentFile.path === contextMenuTarget) {
currentFile.path = newPath;
document.getElementById('file-name').textContent = newPath;
}
closeRenameModal();
showToast(`已重命名为: ${newName}`, 'success');
loadFileTree();
// 如果重命名的是当前打开的文件,重新打开
if (currentFile && currentFile.path === newPath) {
setTimeout(() => openFile(newPath), 300);
}
} catch (error) {
console.error('重命名失败:', error);
showToast('重命名失败: ' + error.message, 'error');
}
}
// 移动文件或文件夹
async function moveFileOrFolder(sourcePath, targetDir, name) {
try {
// 构建新路径
let newPath;
if (targetDir && targetDir !== '') {
newPath = targetDir + '/' + name;
} else {
newPath = name;
}
// 如果路径没有变化,不需要移动
if (sourcePath === newPath) {
return;
}
// 检查目标路径是否已存在
const response = await fetch(`/api/projects/${projectId}/files/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
old_path: sourcePath,
new_path: newPath
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '移动失败');
}
// 如果移动的是当前打开的文件,更新路径
if (currentFile && currentFile.path === sourcePath) {
currentFile.path = newPath;
document.getElementById('file-name').textContent = newPath;
}
showToast(`已移动到: ${newPath}`, 'success');
loadFileTree();
// 如果移动的是当前打开的文件,重新打开
if (currentFile && currentFile.path === newPath) {
setTimeout(() => openFile(newPath), 300);
}
} catch (error) {
console.error('移动失败:', error);
showToast('移动失败: ' + error.message, 'error');
}
}
// 复制文件或文件夹
async function copyFileOrFolder() {
closeContextMenu();
if (!contextMenuTarget || contextMenuTarget === '') {
showToast('请先选择一个文件或文件夹', 'error');
return;
}
try {
// 获取源文件的父目录(复制到同一目录)
const pathParts = contextMenuTarget.split('/');
const targetDir = pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : '';
const response = await fetch(`/api/projects/${projectId}/files/copy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_path: contextMenuTarget,
target_dir: targetDir
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '复制失败');
}
const result = await response.json();
showToast(`已复制到: ${result.new_path}`, 'success');
// 刷新文件树
await refreshFileTree();
// 自动展开包含新文件的目录
if (targetDir) {
expandedPaths.add(targetDir);
}
renderFileTree();
} catch (error) {
console.error('复制失败:', error);
showToast('复制失败: ' + error.message, 'error');
}
}
// 下载文件或文件夹
async function downloadFileOrFolder() {
closeContextMenu();
if (!contextMenuTarget || contextMenuTarget === '') {
showToast('请先选择一个文件或文件夹', 'error');
return;
}
// 防止重复点击
if (isDownloading) {
showToast('正在下载中,请稍候...', 'info');
return;
}
try {
if (contextMenuIsDirectory) {
// 文件夹:先查询大小
const sizeResponse = await fetch(`/api/projects/${projectId}/files/size?path=${encodeURIComponent(contextMenuTarget)}`);
if (!sizeResponse.ok) {
const error = await sizeResponse.json();
throw new Error(error.error || '查询文件夹大小失败');
}
const sizeData = await sizeResponse.json();
const sizeGB = sizeData.size_gb;
// 如果超过1GB,弹出确认框
if (sizeGB >= 1.0) {
const name = contextMenuTarget.split('/').pop();
const message = document.getElementById('download-folder-confirm-message');
message.textContent = `文件夹【${name}】大小为 ${sizeGB.toFixed(2)} GB,确定要继续下载吗?`;
document.getElementById('download-folder-confirm-modal').classList.add('active');
return;
}
// 小于1GB,直接下载
await performFolderDownload();
} else {
// 文件:先查询大小
showToast('正在查询文件大小...', 'info');
const sizeResponse = await fetch(`/api/projects/${projectId}/files/size?path=${encodeURIComponent(contextMenuTarget)}`);
if (!sizeResponse.ok) {
const error = await sizeResponse.json();
throw new Error(error.error || '查询文件大小失败');
}
const sizeData = await sizeResponse.json();
const sizeMB = sizeData.size_mb;
const sizeGB = sizeData.size_gb;
// 如果超过100MB,弹出确认框
if (sizeMB >= 100) {
const name = contextMenuTarget.split('/').pop();
const message = document.getElementById('download-file-confirm-message');
if (sizeGB >= 1.0) {
message.textContent = `文件【${name}】大小为 ${sizeGB.toFixed(2)} GB,确定要继续下载吗?`;
} else {
message.textContent = `文件【${name}】大小为 ${sizeMB.toFixed(2)} MB,确定要继续下载吗?`;
}
document.getElementById('download-file-confirm-modal').classList.add('active');
return;
}
// 小于100MB,直接下载
await performFileDownload();
}
} catch (error) {
console.error('下载失败:', error);
showToast('下载失败: ' + error.message, 'error');
isDownloading = false;
}
}
// 执行文件下载
async function performFileDownload() {
if (isDownloading) {
return;
}
isDownloading = true;
const loadingToast = showToast('正在启动下载...', 'info', 0); // 0表示不自动关闭
try {
const url = `/api/projects/${projectId}/files/download?path=${encodeURIComponent(contextMenuTarget)}`;
// 获取文件名(从路径提取,如果HEAD请求成功则从响应头获取)
let filename = contextMenuTarget.split('/').pop();
// 尝试HEAD请求获取文件名(快速检查,不下载内容)
try {
const headResponse = await fetch(url, { method: 'HEAD' });
if (headResponse.ok) {
const contentDisposition = headResponse.headers.get('Content-Disposition');
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
}
} catch (e) {
// HEAD请求失败不影响下载,继续使用从路径提取的文件名
console.log('HEAD请求失败,使用默认文件名:', e);
}
// 更新提示
if (loadingToast) {
loadingToast.textContent = '下载已开始,请查看浏览器下载进度...';
}
// 直接创建下载链接,让浏览器处理下载(流式下载,不等待整个文件加载)
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
// 延迟移除元素,确保下载已开始
setTimeout(() => {
document.body.removeChild(a);
}, 100);
// 移除加载提示(延迟一下,让用户看到提示)
setTimeout(() => {
if (loadingToast && loadingToast.parentNode) {
loadingToast.remove();
}
showToast('下载已开始', 'success');
}, 500);
} catch (error) {
console.error('下载文件失败:', error);
// 移除加载提示
if (loadingToast && loadingToast.parentNode) {
loadingToast.remove();
}
showToast('下载文件失败: ' + error.message, 'error');
} finally {
// 延迟重置下载状态,避免立即重复点击
setTimeout(() => {
isDownloading = false;
}, 1000);
}
}
// 执行文件夹下载
async function performFolderDownload() {
if (isDownloading) {
return;
}
isDownloading = true;
const loadingToast = showToast('正在准备下载...', 'info', 0); // 0表示不自动关闭
try {
const url = `/api/projects/${projectId}/files/download-folder?path=${encodeURIComponent(contextMenuTarget)}`;
const response = await fetch(url);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '下载文件夹失败');
}
// 获取文件名
const contentDisposition = response.headers.get('Content-Disposition');
let filename = contextMenuTarget.split('/').pop() + '.zip';
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// 更新提示
if (loadingToast) {
loadingToast.textContent = '正在打包下载文件夹...';
}
// 创建blob并下载
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
// 移除加载提示
if (loadingToast && loadingToast.parentNode) {
loadingToast.remove();
}
showToast('文件夹下载成功', 'success');
} catch (error) {
console.error('下载文件夹失败:', error);
// 移除加载提示
if (loadingToast && loadingToast.parentNode) {
loadingToast.remove();
}
showToast('下载文件夹失败: ' + error.message, 'error');
} finally {
isDownloading = false;
}
}
// 打开文件夹下载确认模态框
function openDownloadFolderConfirmModal() {
closeContextMenu();
// 这个函数由downloadFileOrFolder内部调用,不需要单独实现
}
// 关闭文件夹下载确认模态框
function closeDownloadFolderConfirmModal() {
document.getElementById('download-folder-confirm-modal').classList.remove('active');
}
// 确认下载文件夹
async function confirmDownloadFolder() {
closeDownloadFolderConfirmModal();
await performFolderDownload();
}
// 关闭文件下载确认模态框
function closeDownloadFileConfirmModal() {
document.getElementById('download-file-confirm-modal').classList.remove('active');
}
// 确认下载文件
async function confirmDownloadFile() {
closeDownloadFileConfirmModal();
await performFileDownload();
}
// 打开删除确认模态框
function openDeleteConfirmModal() {
closeContextMenu();
if (!contextMenuTarget || contextMenuTarget === '') {
showToast('请先选择一个文件或文件夹', 'error');
return;
}
// 更新确认信息
const name = contextMenuTarget.split('/').pop();
const message = document.getElementById('delete-confirm-message');
if (contextMenuIsDirectory) {
message.textContent = `确定要删除文件夹"${name}"及其所有内容吗?此操作不可恢复!`;
} else {
message.textContent = `确定要删除文件"${name}"吗?此操作不可恢复!`;
}
document.getElementById('delete-confirm-modal').classList.add('active');
}
// 关闭删除确认模态框
function closeDeleteConfirmModal() {
document.getElementById('delete-confirm-modal').classList.remove('active');
}
// 确认删除
async function confirmDelete() {
if (!contextMenuTarget || contextMenuTarget === '') {
showToast('请先选择一个文件或文件夹', 'error');
return;
}
try {
const response = await fetch(`/api/projects/${projectId}/files/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: contextMenuTarget
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '删除失败');
}
// 如果删除的是当前打开的文件或其父目录,关闭文件
if (currentFile) {
if (currentFile.path === contextMenuTarget || currentFile.path.startsWith(contextMenuTarget + '/')) {
currentFile = null;
document.getElementById('file-name').textContent = '未打开文件';
const runBtn = document.getElementById('run-btn');
const saveBtn = document.getElementById('save-btn');
runBtn.disabled = true;
saveBtn.disabled = true;
// 显示占位符
const placeholder = document.getElementById('editor-placeholder');
const editorContainer = document.getElementById('editor-container');
if (placeholder) placeholder.style.display = 'flex';
if (editorContainer) editorContainer.style.display = 'none';
}
}
closeDeleteConfirmModal();
showToast('删除成功', 'success');
loadFileTree();
} catch (error) {
console.error('删除失败:', error);
showToast('删除失败: ' + error.message, 'error');
}
}
// HTML转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 表单提交事件
document.getElementById('create-file-form').addEventListener('submit', (e) => {
e.preventDefault();
createFile();
});
document.getElementById('create-folder-form').addEventListener('submit', (e) => {
e.preventDefault();
createFolder();
});
document.getElementById('rename-form').addEventListener('submit', (e) => {
e.preventDefault();
renameItem();
});
// 点击模态框外部关闭
document.getElementById('create-file-modal').addEventListener('click', (e) => {
if (e.target.id === 'create-file-modal') {
closeCreateFileModal();
}
});
document.getElementById('create-folder-modal').addEventListener('click', (e) => {
if (e.target.id === 'create-folder-modal') {
closeCreateFolderModal();
}
});
document.getElementById('rename-modal').addEventListener('click', (e) => {
if (e.target.id === 'rename-modal') {
closeRenameModal();
}
});
document.getElementById('delete-confirm-modal').addEventListener('click', (e) => {
if (e.target.id === 'delete-confirm-modal') {
closeDeleteConfirmModal();
}
});
document.getElementById('download-folder-confirm-modal').addEventListener('click', (e) => {
if (e.target.id === 'download-folder-confirm-modal') {
closeDownloadFolderConfirmModal();
}
});
document.getElementById('download-file-confirm-modal').addEventListener('click', (e) => {
if (e.target.id === 'download-file-confirm-modal') {
closeDownloadFileConfirmModal();
}
});
document.getElementById('ai-complete-modal').addEventListener('click', (e) => {
if (e.target.id === 'ai-complete-modal') {
closeAICompleteModal();
}
});
// AI 补全模态框回车提交
document.getElementById('ai-prompt-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const submitBtn = document.getElementById('ai-submit-btn');
if (!submitBtn.disabled && submitBtn.style.display !== 'none') {
submitAIComplete();
}
}
});
// AI 补全模态框回车采纳代码(全局监听,当采纳按钮显示时)
document.addEventListener('keydown', (e) => {
// 检查是否在模态框中
const modal = document.getElementById('ai-complete-modal');
if (!modal || !modal.classList.contains('active')) {
return;
}
// 检查采纳按钮是否显示
const acceptBtn = document.getElementById('ai-accept-btn');
if (!acceptBtn || acceptBtn.style.display === 'none') {
return;
}
// 如果焦点在提示词输入框中,不处理(由提示词输入框的监听器处理)
const target = e.target;
if (target.id === 'ai-prompt-input') {
return;
}
// 按回车键采纳代码
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
acceptAICode();
}
});
// 快捷键:Ctrl+S 保存
document.addEventListener('keydown', (e) => {
// 如果焦点在输入框或文本框中,不处理快捷键
const target = e.target;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
saveFile();
}
// 快捷键:Ctrl+K AI 代码补全(备用,当编辑器没有焦点时使用)
if (e.ctrlKey && e.key === 'k') {
// 检查是否在输入框或文本框中
const target = e.target;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
// 如果在输入框中,不处理(避免与模态框输入框冲突)
return;
}
// 检查编辑器是否有焦点
let editorHasFocus = false;
if (editor) {
try {
// Monaco 编辑器有焦点时,activeElement 通常是编辑器的某个子元素
const editorDom = editor.getDomNode();
if (editorDom && editorDom.contains(document.activeElement)) {
editorHasFocus = true;
}
} catch (err) {
// 如果获取失败,忽略
}
}
// 如果编辑器有焦点,不在这里处理(由 Monaco 的快捷键处理)
// 如果编辑器没有焦点,在这里处理
if (!editorHasFocus) {
e.preventDefault();
openAICompleteModal();
}
}
});
// 点击页面其他地方关闭右键菜单
document.addEventListener('click', (e) => {
if (!e.target.closest('.context-menu') && !e.target.closest('.file-item')) {
closeContextMenu();
}
});
// 文件树空白处右键
document.getElementById('file-tree').addEventListener('contextmenu', (e) => {
// 如果点击的是文件树容器本身,而不是文件项
if (e.target.id === 'file-tree' || (!e.target.closest('.file-item') && e.target.closest('.file-tree'))) {
e.preventDefault();
showContextMenu(e, '', true);
}
});
// 文件树空白处拖拽处理
const fileTreeContainer = document.getElementById('file-tree');
fileTreeContainer.addEventListener('dragover', (e) => {
// 如果拖拽的不是文件项,允许放置到根目录
if (!e.target.closest('.file-item')) {
e.preventDefault();
if (window.currentDragData) {
e.dataTransfer.dropEffect = 'move';
}
}
});
fileTreeContainer.addEventListener('drop', (e) => {
// 如果放置的不是文件项,移动到根目录
if (!e.target.closest('.file-item')) {
e.preventDefault();
const dragData = e.dataTransfer.getData('text/plain');
if (!dragData) return;
const dragged = JSON.parse(dragData);
// 如果已经在根目录,不需要移动
if (!dragged.path.includes('/')) {
return;
}
// 移动到根目录
moveFileOrFolder(dragged.path, '', dragged.name);
// 清除全局拖拽数据
window.currentDragData = null;
}
});
// 页面关闭时清理
window.addEventListener('beforeunload', () => {
stopStatusCheck();
if (currentRunId) {
// 尝试终止正在运行的进程
fetch(`/api/projects/${projectId}/run/terminate/${currentRunId}`, {
method: 'POST'
}).catch(() => { });
}
});
// 页面加载时加载配置和文件树
(async () => {
await loadConfig();
loadFileTree();
initLogResizer();
})();
</script>
</body>
</html>
3.4.templates/index.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>世界树IDE</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #ffffff;
color: #333333;
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #6a1b9a;
margin-bottom: 30px;
font-size: 28px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #9c27b0;
color: white;
}
.btn-primary:hover {
background: #7b1fa2;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.project-card {
background: #ffffff;
border: 2px solid #ce93d8;
border-radius: 6px;
padding: 20px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 2px 8px rgba(156, 39, 176, 0.1);
position: relative;
}
.project-card:hover {
border-color: #9c27b0;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(156, 39, 176, 0.3);
}
.project-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.project-card h3 {
color: #6a1b9a;
margin: 0;
font-size: 18px;
flex: 1;
}
.project-card-menu {
position: relative;
}
.project-card-menu-btn {
background: none;
border: none;
cursor: pointer;
padding: 6px 10px;
border-radius: 4px;
color: #6a1b9a;
font-size: 20px;
line-height: 1;
transition: all 0.2s;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
}
.project-card-menu-btn:hover {
background: #f3e5f5;
}
.project-card-menu-btn::before {
content: '⋯';
font-weight: normal;
letter-spacing: 2px;
}
.project-card-menu-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
background: #ffffff;
border: 1px solid #ce93d8;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(156, 39, 176, 0.2);
min-width: 120px;
z-index: 100;
margin-top: 4px;
}
.project-card-menu-dropdown.active {
display: block;
}
.project-card-menu-item {
padding: 10px 16px;
cursor: pointer;
font-size: 14px;
color: #333333;
transition: all 0.2s;
border: none;
background: none;
width: 100%;
text-align: left;
display: block;
}
.project-card-menu-item:first-child {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.project-card-menu-item:last-child {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
.project-card-menu-item:hover {
background: #f3e5f5;
color: #6a1b9a;
}
.project-card-menu-item.delete {
color: #d32f2f;
}
.project-card-menu-item.delete:hover {
background: #ffebee;
color: #c62828;
}
.project-card .path {
color: #666666;
font-size: 12px;
margin-bottom: 10px;
word-break: break-all;
}
.project-card .description {
color: #555555;
font-size: 14px;
margin-bottom: 15px;
min-height: 40px;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: #ffffff;
border: 2px solid #ce93d8;
border-radius: 6px;
padding: 30px;
width: 90%;
max-width: 500px;
box-shadow: 0 8px 24px rgba(156, 39, 176, 0.3);
}
.modal-content h2 {
color: #6a1b9a;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
color: #555555;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 10px;
background: #fafafa;
border: 1px solid #ce93d8;
border-radius: 4px;
color: #333333;
font-size: 14px;
font-family: inherit;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #9c27b0;
background: #ffffff;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666666;
}
.empty-state h2 {
margin-bottom: 10px;
color: #6a1b9a;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>世界树IDE</h1>
<button class="btn btn-primary" onclick="openAddModal()">+ 新建项目</button>
</div>
<div id="projects-container" class="projects-grid">
<!-- 项目卡片将在这里动态加载 -->
</div>
</div>
<!-- 添加/编辑项目模态框 -->
<div id="project-modal" class="modal">
<div class="modal-content">
<h2 id="modal-title">新建项目</h2>
<form id="project-form">
<input type="hidden" id="project-id">
<div class="form-group">
<label>项目名称 *</label>
<input type="text" id="project-name" required>
</div>
<div class="form-group">
<label>项目路径 *</label>
<input type="text" id="project-path" required placeholder="例如: C:\projects\myproject">
</div>
<div class="form-group">
<label>项目描述</label>
<textarea id="project-description"></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<script>
let projects = [];
let editingId = null;
// 加载项目列表
async function loadProjects() {
try {
const response = await fetch('/api/projects');
projects = await response.json();
renderProjects();
} catch (error) {
console.error('加载项目失败:', error);
alert('加载项目失败: ' + error.message);
}
}
// 渲染项目列表
function renderProjects() {
const container = document.getElementById('projects-container');
if (projects.length === 0) {
container.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<h2>还没有项目</h2>
<p>点击右上角的"新建项目"按钮创建第一个项目</p>
</div>
`;
return;
}
container.innerHTML = projects.map(project => `
<div class="project-card" onclick="openEditor(${project.id})">
<div class="project-card-header">
<h3>${escapeHtml(project.name)}</h3>
<div class="project-card-menu" onclick="event.stopPropagation()">
<button class="project-card-menu-btn" onclick="toggleMenu(${project.id})"></button>
<div class="project-card-menu-dropdown" id="menu-${project.id}">
<button class="project-card-menu-item" onclick="editProject(${project.id}); closeMenu(${project.id})">编辑</button>
<button class="project-card-menu-item delete" onclick="deleteProject(${project.id}); closeMenu(${project.id})">删除</button>
</div>
</div>
</div>
<div class="path">${escapeHtml(project.path)}</div>
<div class="description">${escapeHtml(project.description || '无描述')}</div>
</div>
`).join('');
}
// 打开编辑器
function openEditor(projectId) {
window.location.href = `/editor/${projectId}`;
}
// 切换菜单显示
function toggleMenu(projectId) {
// 关闭所有其他菜单
document.querySelectorAll('.project-card-menu-dropdown').forEach(menu => {
if (menu.id !== `menu-${projectId}`) {
menu.classList.remove('active');
}
});
// 切换当前菜单
const menu = document.getElementById(`menu-${projectId}`);
if (menu) {
menu.classList.toggle('active');
}
}
// 关闭菜单
function closeMenu(projectId) {
const menu = document.getElementById(`menu-${projectId}`);
if (menu) {
menu.classList.remove('active');
}
}
// 打开添加模态框
function openAddModal() {
editingId = null;
document.getElementById('modal-title').textContent = '新建项目';
document.getElementById('project-form').reset();
document.getElementById('project-id').value = '';
document.getElementById('project-modal').classList.add('active');
}
// 编辑项目
function editProject(id) {
const project = projects.find(p => p.id === id);
if (!project) return;
editingId = id;
document.getElementById('modal-title').textContent = '编辑项目';
document.getElementById('project-id').value = id;
document.getElementById('project-name').value = project.name;
document.getElementById('project-path').value = project.path;
document.getElementById('project-description').value = project.description || '';
document.getElementById('project-modal').classList.add('active');
}
// 关闭模态框
function closeModal() {
document.getElementById('project-modal').classList.remove('active');
}
// 删除项目
async function deleteProject(id) {
if (!confirm('确定要删除这个项目吗?')) return;
try {
const response = await fetch(`/api/projects/${id}`, {
method: 'DELETE'
});
if (response.ok) {
loadProjects();
} else {
const error = await response.json();
alert('删除失败: ' + error.error);
}
} catch (error) {
console.error('删除项目失败:', error);
alert('删除项目失败: ' + error.message);
}
}
// 提交表单
document.getElementById('project-form').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
name: document.getElementById('project-name').value,
path: document.getElementById('project-path').value,
description: document.getElementById('project-description').value
};
try {
let response;
if (editingId) {
response = await fetch(`/api/projects/${editingId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
response = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
if (response.ok) {
closeModal();
loadProjects();
} else {
const error = await response.json();
alert('保存失败: ' + error.error);
}
} catch (error) {
console.error('保存项目失败:', error);
alert('保存项目失败: ' + error.message);
}
});
// HTML转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 点击模态框外部关闭
document.getElementById('project-modal').addEventListener('click', (e) => {
if (e.target.id === 'project-modal') {
closeModal();
}
});
// 点击页面其他地方关闭所有菜单
document.addEventListener('click', (e) => {
if (!e.target.closest('.project-card-menu')) {
document.querySelectorAll('.project-card-menu-dropdown').forEach(menu => {
menu.classList.remove('active');
});
}
});
// 页面加载时获取项目列表
loadProjects();
</script>
</body>
</html>