优化guideline提示词,新增admin后台修改prompt功能
This commit is contained in:
parent
c5b68a91f3
commit
bb640e6d6e
@ -45,10 +45,12 @@
|
|||||||
- **限制条件**: [需要遵守的规则和约束]
|
- **限制条件**: [需要遵守的规则和约束]
|
||||||
- **可用资源**: [可以利用的工具和资源]
|
- **可用资源**: [可以利用的工具和资源]
|
||||||
|
|
||||||
### ⚡ 行动计划
|
### ⚡ 执行步骤
|
||||||
1. [第一步的具体行动]
|
1. [第一步的具体行动]
|
||||||
2. [第二步的具体行动]
|
2. [第二步的具体行动]
|
||||||
3. [第三步的具体行动]
|
3. [第三步的具体行动]
|
||||||
|
4. [第四步的具体行动]
|
||||||
|
5. [第五步的具体行动]
|
||||||
|
|
||||||
## 输出语言 (Language)
|
## 输出语言 (Language)
|
||||||
{language}
|
{language}
|
||||||
|
|||||||
@ -477,6 +477,174 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 文件编辑器相关样式 */
|
||||||
|
.editor-container {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-numbers {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
color: #6c757d;
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
min-width: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container textarea {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 500px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container textarea:focus {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 快捷键样式 */
|
||||||
|
kbd {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: #495057;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown 预览样式 */
|
||||||
|
.markdown-preview {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview h1, .markdown-preview h2, .markdown-preview h3,
|
||||||
|
.markdown-preview h4, .markdown-preview h5, .markdown-preview h6 {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview h1 { font-size: 2rem; border-bottom: 2px solid #eee; padding-bottom: 0.5rem; }
|
||||||
|
.markdown-preview h2 { font-size: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 0.3rem; }
|
||||||
|
.markdown-preview h3 { font-size: 1.25rem; }
|
||||||
|
.markdown-preview h4 { font-size: 1rem; }
|
||||||
|
.markdown-preview h5 { font-size: 0.875rem; }
|
||||||
|
.markdown-preview h6 { font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.markdown-preview p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview ul, .markdown-preview ol {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview code {
|
||||||
|
background-color: rgba(13, 110, 253, 0.1);
|
||||||
|
color: #d63384;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview pre {
|
||||||
|
background-color: #212529;
|
||||||
|
color: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview blockquote {
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview th, .markdown-preview td {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件编辑状态指示器 */
|
||||||
|
.edit-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-status.modified {
|
||||||
|
color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-status.saved {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-status.error {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -814,6 +982,99 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件编辑模态框 -->
|
||||||
|
<div class="modal fade" id="editFileModal" tabindex="-1" aria-labelledby="editFileModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="editFileModalLabel">
|
||||||
|
<i class="bi bi-file-earmark-code me-2"></i>编辑文件
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editFileName" class="form-label">文件路径:</label>
|
||||||
|
<input type="text" class="form-control" id="editFileName" readonly />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<label for="fileContent" class="form-label">文件内容:</label>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleWordWrap()">
|
||||||
|
<i class="bi bi-text-wrap me-1"></i>换行
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleLineNumbers()">
|
||||||
|
<i class="bi bi-list-ol me-1"></i>行号
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="formatMarkdown()">
|
||||||
|
<i class="bi bi-markdown me-1"></i>格式化
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="insertTab()">
|
||||||
|
<i class="bi bi-indent me-1"></i>Tab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-container">
|
||||||
|
<div class="editor-wrapper">
|
||||||
|
<div class="line-numbers" id="lineNumbers" style="display: none;">1</div>
|
||||||
|
<textarea class="form-control font-monospace" id="fileContent" rows="25" placeholder="文件内容将在这里显示..." style="min-height: 500px; resize: vertical;" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="text-muted">
|
||||||
|
<small id="editFileInfo">准备编辑...</small>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-warning" onclick="previewFile()">
|
||||||
|
<i class="bi bi-eye me-1"></i>预览
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="resetContent()">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="text-start">
|
||||||
|
<small class="text-muted">
|
||||||
|
快捷键: <kbd>Ctrl+S</kbd> 保存 | <kbd>Ctrl+P</kbd> 预览 | <kbd>Ctrl+L</kbd> 行号 | <kbd>Ctrl+W</kbd> 换行 | <kbd>Ctrl+/</kbd> 注释 | <kbd>Ctrl+D</kbd> 复制行 | <kbd>Tab</kbd> 缩进
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveFile()">
|
||||||
|
<i class="bi bi-save me-1"></i>保存文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件预览模态框 -->
|
||||||
|
<div class="modal fade" id="previewFileModal" tabindex="-1" aria-labelledby="previewFileModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="previewFileModalLabel">
|
||||||
|
<i class="bi bi-eye me-2"></i>文件预览
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="previewContent" class="markdown-preview">
|
||||||
|
<!-- Markdown 预览内容将在这里显示 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 全局变量
|
// 全局变量
|
||||||
const API_BASE = `${window.location.protocol}//${window.location.host}/api/v1`;
|
const API_BASE = `${window.location.protocol}//${window.location.host}/api/v1`;
|
||||||
@ -828,6 +1089,12 @@
|
|||||||
let currentItemToRename = '';
|
let currentItemToRename = '';
|
||||||
let batchMode = false;
|
let batchMode = false;
|
||||||
|
|
||||||
|
// 文件编辑相关变量
|
||||||
|
let currentEditingFile = '';
|
||||||
|
let originalFileContent = '';
|
||||||
|
let fileEditStatus = 'ready'; // ready, modified, saving, error
|
||||||
|
let showLineNumbers = false;
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// 检查登录状态
|
// 检查登录状态
|
||||||
@ -1503,13 +1770,15 @@
|
|||||||
// 加载文件列表
|
// 加载文件列表
|
||||||
async function loadFileList(path = '') {
|
async function loadFileList(path = '') {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${FILE_API_BASE}/list?path=${encodeURIComponent(path)}&recursive=false`);
|
// 清理路径,确保发送相对路径
|
||||||
|
const cleanPath = sanitizePath(path);
|
||||||
|
const response = await fetch(`${FILE_API_BASE}/list?path=${encodeURIComponent(cleanPath)}&recursive=false`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
currentPath = path;
|
currentPath = cleanPath;
|
||||||
renderFileList(data.items);
|
renderFileList(data.items);
|
||||||
updateBreadcrumb(path);
|
updateBreadcrumb(cleanPath);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载文件列表失败:', error);
|
console.error('加载文件列表失败:', error);
|
||||||
@ -1542,11 +1811,31 @@
|
|||||||
}
|
}
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
}).forEach(item => {
|
}).forEach(item => {
|
||||||
const fileItem = createFileItem(item);
|
// 确保路径是相对路径格式
|
||||||
|
const cleanItem = {
|
||||||
|
...item,
|
||||||
|
path: sanitizePath(item.path)
|
||||||
|
};
|
||||||
|
const fileItem = createFileItem(cleanItem);
|
||||||
fileList.appendChild(fileItem);
|
fileList.appendChild(fileItem);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理路径,确保使用相对路径
|
||||||
|
function sanitizePath(path) {
|
||||||
|
// 如果是绝对路径,转换为相对路径
|
||||||
|
if (path.startsWith('/')) {
|
||||||
|
const pathParts = path.split('/');
|
||||||
|
const qwenAgentIndex = pathParts.indexOf('qwen-agent');
|
||||||
|
if (qwenAgentIndex !== -1) {
|
||||||
|
return pathParts.slice(qwenAgentIndex + 1).join('/');
|
||||||
|
}
|
||||||
|
// 如果找不到 qwen-agent,取最后两部分
|
||||||
|
return pathParts.slice(-2).join('/');
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
// 创建文件项
|
// 创建文件项
|
||||||
function createFileItem(item) {
|
function createFileItem(item) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@ -1572,11 +1861,15 @@
|
|||||||
<div class="file-actions">
|
<div class="file-actions">
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
${item.type === 'file' ?
|
${item.type === 'file' ?
|
||||||
`<button class="btn btn-outline-primary btn-sm" onclick="downloadFile('${item.path}')"><i class="bi bi-download"></i></button>` :
|
(isEditableFile(item.name) ?
|
||||||
`<button class="btn btn-outline-primary btn-sm" onclick="downloadFolderAsZip('${item.path}')"><i class="bi bi-file-zip"></i></button>`
|
`<button class="btn btn-outline-success btn-sm" onclick="editFile('${item.path}')" title="编辑文件"><i class="bi bi-file-earmark-code"></i></button>
|
||||||
|
<button class="btn btn-outline-primary btn-sm" onclick="downloadFile('${item.path}')" title="下载"><i class="bi bi-download"></i></button>` :
|
||||||
|
`<button class="btn btn-outline-primary btn-sm" onclick="downloadFile('${item.path}')" title="下载"><i class="bi bi-download"></i></button>`
|
||||||
|
) :
|
||||||
|
`<button class="btn btn-outline-primary btn-sm" onclick="downloadFolderAsZip('${item.path}')" title="压缩下载"><i class="bi bi-file-zip"></i></button>`
|
||||||
}
|
}
|
||||||
<button class="btn btn-outline-secondary btn-sm" onclick="showRenameModal('${item.path}')"><i class="bi bi-pencil"></i></button>
|
<button class="btn btn-outline-secondary btn-sm" onclick="showRenameModal('${item.path}')" title="重命名"><i class="bi bi-pencil"></i></button>
|
||||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteItem('${item.path}')"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteItem('${item.path}')" title="删除"><i class="bi bi-trash"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -1697,7 +1990,8 @@
|
|||||||
// 搜索文件
|
// 搜索文件
|
||||||
async function searchFiles(query) {
|
async function searchFiles(query) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${FILE_API_BASE}/search?query=${encodeURIComponent(query)}&path=${encodeURIComponent(currentPath)}`);
|
const cleanPath = sanitizePath(currentPath);
|
||||||
|
const response = await fetch(`${FILE_API_BASE}/search?query=${encodeURIComponent(query)}&path=${encodeURIComponent(cleanPath)}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
@ -2219,8 +2513,597 @@
|
|||||||
showMessage('重命名失败', 'error');
|
showMessage('重命名失败', 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 文件编辑功能 ==========
|
||||||
|
|
||||||
|
// 判断文件是否可编辑
|
||||||
|
function isEditableFile(fileName) {
|
||||||
|
const editableExtensions = [
|
||||||
|
'md', 'txt', 'json', 'xml', 'yaml', 'yml',
|
||||||
|
'js', 'ts', 'jsx', 'tsx', 'html', 'css', 'scss', 'sass',
|
||||||
|
'py', 'java', 'cpp', 'c', 'h', 'hpp', 'cs', 'php', 'rb', 'go',
|
||||||
|
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||||
|
'sql', 'log', 'conf', 'ini', 'cfg', 'toml', 'env',
|
||||||
|
'gitignore', 'dockerfile', 'makefile', 'readme', 'license'
|
||||||
|
];
|
||||||
|
|
||||||
|
const ext = fileName.split('.').pop().toLowerCase();
|
||||||
|
const name = fileName.toLowerCase();
|
||||||
|
|
||||||
|
// 检查扩展名
|
||||||
|
if (editableExtensions.includes(ext)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查特殊文件名
|
||||||
|
const specialFiles = ['readme', 'license', 'dockerfile', 'makefile', 'gitignore'];
|
||||||
|
if (specialFiles.some(special => name.includes(special))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑文件
|
||||||
|
async function editFile(filePath) {
|
||||||
|
try {
|
||||||
|
currentEditingFile = filePath;
|
||||||
|
fileEditStatus = 'loading';
|
||||||
|
|
||||||
|
// 显示编辑模态框
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('editFileModal'));
|
||||||
|
|
||||||
|
// 设置文件路径
|
||||||
|
document.getElementById('editFileName').value = filePath;
|
||||||
|
|
||||||
|
// 显示加载状态
|
||||||
|
document.getElementById('fileContent').value = '加载文件内容中...';
|
||||||
|
document.getElementById('editFileInfo').innerHTML = '<span class="edit-status"><i class="bi bi-hourglass-split me-1"></i>正在加载...</span>';
|
||||||
|
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
// 获取文件内容
|
||||||
|
const response = await fetch(`${FILE_API_BASE}/read?path=${encodeURIComponent(filePath)}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
originalFileContent = data.content || '';
|
||||||
|
document.getElementById('fileContent').value = originalFileContent;
|
||||||
|
|
||||||
|
// 更新文件信息
|
||||||
|
const size = data.size ? formatFileSize(data.size) : '未知';
|
||||||
|
const modified = data.modified ? formatDate(data.modified * 1000) : '未知';
|
||||||
|
document.getElementById('editFileInfo').innerHTML =
|
||||||
|
`<span class="edit-status saved"><i class="bi bi-check-circle me-1"></i>已加载 • 大小: ${size} • 修改时间: ${modified}</span>`;
|
||||||
|
|
||||||
|
fileEditStatus = 'ready';
|
||||||
|
|
||||||
|
// 监听内容变化
|
||||||
|
setupContentChangeListener();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw new Error(data.detail || '读取文件失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载文件失败:', error);
|
||||||
|
document.getElementById('fileContent').value = `加载文件失败: ${error.message}`;
|
||||||
|
document.getElementById('editFileInfo').innerHTML =
|
||||||
|
`<span class="edit-status error"><i class="bi bi-exclamation-triangle me-1"></i>加载失败</span>`;
|
||||||
|
fileEditStatus = 'error';
|
||||||
|
showMessage(`加载文件失败: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置内容变化监听器
|
||||||
|
function setupContentChangeListener() {
|
||||||
|
const textarea = document.getElementById('fileContent');
|
||||||
|
|
||||||
|
// 移除旧的监听器(如果存在)
|
||||||
|
textarea.removeEventListener('input', handleContentChange);
|
||||||
|
textarea.removeEventListener('scroll', handleEditorScroll);
|
||||||
|
textarea.removeEventListener('keydown', handleEditorKeydown);
|
||||||
|
|
||||||
|
// 添加新的监听器
|
||||||
|
textarea.addEventListener('input', handleContentChange);
|
||||||
|
textarea.addEventListener('scroll', handleEditorScroll);
|
||||||
|
textarea.addEventListener('keydown', handleEditorKeydown);
|
||||||
|
|
||||||
|
// 初始化行号
|
||||||
|
updateLineNumbers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理内容变化
|
||||||
|
function handleContentChange() {
|
||||||
|
const currentContent = document.getElementById('fileContent').value;
|
||||||
|
const isModified = currentContent !== originalFileContent;
|
||||||
|
|
||||||
|
// 更新行号
|
||||||
|
updateLineNumbers();
|
||||||
|
|
||||||
|
if (isModified && fileEditStatus === 'ready') {
|
||||||
|
fileEditStatus = 'modified';
|
||||||
|
document.getElementById('editFileInfo').innerHTML =
|
||||||
|
'<span class="edit-status modified"><i class="bi bi-pencil-square me-1"></i>已修改</span>';
|
||||||
|
} else if (!isModified && fileEditStatus === 'modified') {
|
||||||
|
fileEditStatus = 'ready';
|
||||||
|
const size = formatFileSize(new Blob([currentContent]).size);
|
||||||
|
document.getElementById('editFileInfo').innerHTML =
|
||||||
|
`<span class="edit-status saved"><i class="bi bi-check-circle me-1"></i>已保存 • 大小: ${size}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理编辑器滚动
|
||||||
|
function handleEditorScroll() {
|
||||||
|
const textarea = document.getElementById('fileContent');
|
||||||
|
const lineNumbers = document.getElementById('lineNumbers');
|
||||||
|
|
||||||
|
if (showLineNumbers && lineNumbers) {
|
||||||
|
lineNumbers.scrollTop = textarea.scrollTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理编辑器键盘事件
|
||||||
|
function handleEditorKeydown(e) {
|
||||||
|
// Tab 键处理
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTab();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Z 撤销 (浏览器原生支持)
|
||||||
|
// Ctrl+Y 重做 (浏览器原生支持)
|
||||||
|
|
||||||
|
// 其他快捷键在全局事件处理器中处理
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新行号
|
||||||
|
function updateLineNumbers() {
|
||||||
|
if (!showLineNumbers) return;
|
||||||
|
|
||||||
|
const textarea = document.getElementById('fileContent');
|
||||||
|
const lineNumbers = document.getElementById('lineNumbers');
|
||||||
|
|
||||||
|
if (!lineNumbers) return;
|
||||||
|
|
||||||
|
const lines = textarea.value.split('\n').length;
|
||||||
|
let lineNumbersHtml = '';
|
||||||
|
|
||||||
|
for (let i = 1; i <= lines; i++) {
|
||||||
|
lineNumbersHtml += i + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
lineNumbers.textContent = lineNumbersHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换行号显示
|
||||||
|
function toggleLineNumbers() {
|
||||||
|
showLineNumbers = !showLineNumbers;
|
||||||
|
const lineNumbers = document.getElementById('lineNumbers');
|
||||||
|
const textarea = document.getElementById('fileContent');
|
||||||
|
|
||||||
|
if (showLineNumbers) {
|
||||||
|
lineNumbers.style.display = 'block';
|
||||||
|
updateLineNumbers();
|
||||||
|
showMessage('已启用行号', 'info');
|
||||||
|
} else {
|
||||||
|
lineNumbers.style.display = 'none';
|
||||||
|
showMessage('已禁用行号', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入 Tab
|
||||||
|
function insertTab() {
|
||||||
|
const textarea = document.getElementById('fileContent');
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const value = textarea.value;
|
||||||
|
|
||||||
|
// 插入4个空格作为Tab
|
||||||
|
const tabText = ' ';
|
||||||
|
|
||||||
|
textarea.value = value.substring(0, start) + tabText + value.substring(end);
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + tabText.length;
|
||||||
|
|
||||||
|
// 触发内容变化事件
|
||||||
|
handleContentChange();
|
||||||
|
|
||||||
|
// 设置焦点
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
async function saveFile() {
|
||||||
|
if (!currentEditingFile) {
|
||||||
|
showMessage('没有正在编辑的文件', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fileEditStatus = 'saving';
|
||||||
|
|
||||||
|
const content = document.getElementById('fileContent').value;
|
||||||
|
|
||||||
|
// 显示保存状态
|
||||||
|
document.getElementById('editFileInfo').innerHTML =
|
||||||
|
'<span class="edit-status"><i class="bi bi-hourglass-split me-1"></i>正在保存...</span>';
|
||||||
|
|
||||||
|
// 禁用保存按钮
|
||||||
|
const saveButton = document.querySelector('#editFileModal .btn-primary');
|
||||||
|
const originalText = saveButton.innerHTML;
|
||||||
|
saveButton.disabled = true;
|
||||||
|
saveButton.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>保存中...';
|
||||||
|
|
||||||
|
// 发送保存请求
|
||||||
|
const response = await fetch(`${FILE_API_BASE}/save`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: currentEditingFile,
|
||||||
|
content: content
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
originalFileContent = content;
|
||||||
|
fileEditStatus = 'ready';
|
||||||
|
|
||||||
|
const size = formatFileSize(new Blob([content]).size);
|
||||||
|
document.getElementById('editFileInfo').innerHTML =
|
||||||
|
`<span class="edit-status saved"><i class="bi bi-check-circle me-1"></i>已保存 • 大小: ${size} • ${new Date().toLocaleTimeString()}</span>`;
|
||||||
|
|
||||||
|
showMessage('文件保存成功', 'success');
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw new Error(data.detail || '保存文件失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存文件失败:', error);
|
||||||
|
fileEditStatus = 'error';
|
||||||
|
document.getElementById('editFileInfo').innerHTML =
|
||||||
|
`<span class="edit-status error"><i class="bi bi-exclamation-triangle me-1"></i>保存失败: ${error.message}</span>`;
|
||||||
|
showMessage(`保存文件失败: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
// 恢复保存按钮
|
||||||
|
const saveButton = document.querySelector('#editFileModal .btn-primary');
|
||||||
|
saveButton.disabled = false;
|
||||||
|
saveButton.innerHTML = '<i class="bi bi-save me-1"></i>保存文件';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置内容
|
||||||
|
function resetContent() {
|
||||||
|
if (fileEditStatus === 'modified') {
|
||||||
|
if (!confirm('确定要重置文件内容吗?所有未保存的修改将丢失。')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('fileContent').value = originalFileContent;
|
||||||
|
fileEditStatus = 'ready';
|
||||||
|
|
||||||
|
const size = formatFileSize(new Blob([originalFileContent]).size);
|
||||||
|
document.getElementById('editFileInfo').innerHTML =
|
||||||
|
`<span class="edit-status saved"><i class="bi bi-check-circle me-1"></i>已重置 • 大小: ${size}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换自动换行
|
||||||
|
function toggleWordWrap() {
|
||||||
|
const textarea = document.getElementById('fileContent');
|
||||||
|
const currentWrap = textarea.style.whiteSpace || 'pre-wrap';
|
||||||
|
|
||||||
|
if (currentWrap === 'pre-wrap') {
|
||||||
|
textarea.style.whiteSpace = 'pre';
|
||||||
|
textarea.style.overflowX = 'auto';
|
||||||
|
} else {
|
||||||
|
textarea.style.whiteSpace = 'pre-wrap';
|
||||||
|
textarea.style.overflowX = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage(`已${currentWrap === 'pre-wrap' ? '禁用' : '启用'}自动换行`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的 Markdown 格式化函数
|
||||||
|
function formatMarkdown() {
|
||||||
|
const textarea = document.getElementById('fileContent');
|
||||||
|
let content = textarea.value;
|
||||||
|
|
||||||
|
// 基本的 Markdown 格式化
|
||||||
|
content = content.replace(/\r\n/g, '\n'); // 统一换行符
|
||||||
|
content = content.replace(/\n{3,}/g, '\n\n'); // 减少多余空行
|
||||||
|
content = content.replace(/([^\n])\n([^\n#*-])/g, '$1\n\n$2'); // 在段落间添加空行
|
||||||
|
|
||||||
|
textarea.value = content;
|
||||||
|
handleContentChange(); // 更新修改状态
|
||||||
|
|
||||||
|
showMessage('已应用基本格式化', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览文件
|
||||||
|
function previewFile() {
|
||||||
|
const content = document.getElementById('fileContent').value;
|
||||||
|
const previewContainer = document.getElementById('previewContent');
|
||||||
|
|
||||||
|
// Markdown 到 HTML 转换
|
||||||
|
let html = markdownToHtml(content);
|
||||||
|
previewContainer.innerHTML = html;
|
||||||
|
|
||||||
|
// 应用代码高亮
|
||||||
|
if (typeof Prism !== 'undefined') {
|
||||||
|
Prism.highlightAllUnder(previewContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示预览模态框
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('previewFileModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改进的 Markdown 解析器,支持代码高亮
|
||||||
|
function markdownToHtml(markdown) {
|
||||||
|
if (!markdown) return '<p>空文件</p>';
|
||||||
|
|
||||||
|
let html = markdown;
|
||||||
|
|
||||||
|
// 代码块(支持语言标识)
|
||||||
|
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, function(match, lang, code) {
|
||||||
|
const language = lang || 'text';
|
||||||
|
const cleanCode = code.trim();
|
||||||
|
return `<pre><code class="language-${language}">${escapeHtml(cleanCode)}</code></pre>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
html = html.replace(/^#### (.*$)/gim, '<h4>$1</h4>');
|
||||||
|
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
||||||
|
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
||||||
|
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
||||||
|
|
||||||
|
// 粗体和斜体
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// 行内代码
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
|
// 链接
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
||||||
|
|
||||||
|
// 引用块
|
||||||
|
html = html.replace(/^> (.+)$/gim, '<blockquote>$1</blockquote>');
|
||||||
|
|
||||||
|
// 水平分割线
|
||||||
|
html = html.replace(/^---$/gim, '<hr>');
|
||||||
|
|
||||||
|
// 有序列表
|
||||||
|
html = html.replace(/^\d+\. (.+)$/gim, '<li>$1</li>');
|
||||||
|
|
||||||
|
// 无序列表
|
||||||
|
html = html.replace(/^[\*\-\+] (.+)$/gim, '<li>$1</li>');
|
||||||
|
|
||||||
|
// 合并连续列表项
|
||||||
|
html = html.replace(/(<li>.*<\/li>)\s*(<li>)/g, '$1$2');
|
||||||
|
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
||||||
|
|
||||||
|
// 段落处理
|
||||||
|
const lines = html.split('\n');
|
||||||
|
let result = [];
|
||||||
|
let inParagraph = false;
|
||||||
|
|
||||||
|
for (let line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
// 如果是空行,结束当前段落
|
||||||
|
if (!trimmed) {
|
||||||
|
if (inParagraph) {
|
||||||
|
result.push('</p>');
|
||||||
|
inParagraph = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是HTML标签块,直接添加
|
||||||
|
if (trimmed.match(/^<(h[1-6]|ul|ol|li|pre|blockquote|hr)/)) {
|
||||||
|
if (inParagraph) {
|
||||||
|
result.push('</p>');
|
||||||
|
inParagraph = false;
|
||||||
|
}
|
||||||
|
result.push(trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是段落结束标签
|
||||||
|
if (trimmed.match(/^<\/(ul|ol|pre|blockquote)>/)) {
|
||||||
|
result.push(trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通文本行
|
||||||
|
if (!inParagraph) {
|
||||||
|
result.push('<p>');
|
||||||
|
inParagraph = true;
|
||||||
|
}
|
||||||
|
result.push(trimmed + ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inParagraph) {
|
||||||
|
result.push('</p>');
|
||||||
|
}
|
||||||
|
|
||||||
|
html = result.join('\n');
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML转义函数
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加快捷键支持
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
// 只在编辑模态框打开时处理编辑器快捷键
|
||||||
|
const editModal = bootstrap.Modal.getInstance(document.getElementById('editFileModal'));
|
||||||
|
const isEditing = editModal && editModal._isShown;
|
||||||
|
|
||||||
|
// Ctrl+S 保存
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentEditingFile && fileEditStatus !== 'saving') {
|
||||||
|
saveFile();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+W 切换换行
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentEditingFile) {
|
||||||
|
toggleWordWrap();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+L 切换行号
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentEditingFile) {
|
||||||
|
toggleLineNumbers();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+P 预览
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentEditingFile) {
|
||||||
|
previewFile();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+F 格式化
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'F') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentEditingFile) {
|
||||||
|
formatMarkdown();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape 关闭预览
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const previewModal = bootstrap.Modal.getInstance(document.getElementById('previewFileModal'));
|
||||||
|
if (previewModal) {
|
||||||
|
previewModal.hide();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器快捷键(只在编辑时处理)
|
||||||
|
if (isEditing && e.target.id === 'fileContent') {
|
||||||
|
// Tab 键已在 handleEditorKeydown 中处理
|
||||||
|
|
||||||
|
// Ctrl+/ 注释/取消注释
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleComment();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+D 复制行
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
|
||||||
|
e.preventDefault();
|
||||||
|
duplicateLine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换注释(简单实现)
|
||||||
|
function toggleComment() {
|
||||||
|
const textarea = document.getElementById('fileContent');
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const value = textarea.value;
|
||||||
|
|
||||||
|
// 获取选中的行
|
||||||
|
const lines = value.substring(0, start).split('\n').length - 1;
|
||||||
|
const endLines = value.substring(0, end).split('\n').length - 1;
|
||||||
|
|
||||||
|
const allLines = value.split('\n');
|
||||||
|
let hasComment = true;
|
||||||
|
|
||||||
|
// 检查是否所有行都已注释
|
||||||
|
for (let i = lines; i <= endLines; i++) {
|
||||||
|
if (allLines[i] && !allLines[i].trim().startsWith('#')) {
|
||||||
|
hasComment = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换注释
|
||||||
|
if (hasComment) {
|
||||||
|
// 取消注释
|
||||||
|
for (let i = lines; i <= endLines; i++) {
|
||||||
|
if (allLines[i] && allLines[i].trim().startsWith('#')) {
|
||||||
|
allLines[i] = allLines[i].replace(/^(\s*)#\s?/, '$1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 添加注释
|
||||||
|
for (let i = lines; i <= endLines; i++) {
|
||||||
|
if (allLines[i] && !allLines[i].trim().startsWith('#')) {
|
||||||
|
allLines[i] = '# ' + allLines[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.value = allLines.join('\n');
|
||||||
|
handleContentChange();
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制当前行
|
||||||
|
function duplicateLine() {
|
||||||
|
const textarea = document.getElementById('fileContent');
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const value = textarea.value;
|
||||||
|
|
||||||
|
// 找到当前行的开始和结束
|
||||||
|
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const lineEnd = value.indexOf('\n', end);
|
||||||
|
const actualLineEnd = lineEnd === -1 ? value.length : lineEnd;
|
||||||
|
|
||||||
|
const currentLine = value.substring(lineStart, actualLineEnd);
|
||||||
|
const newContent = value.substring(0, actualLineEnd) + '\n' + currentLine + value.substring(actualLineEnd);
|
||||||
|
|
||||||
|
textarea.value = newContent;
|
||||||
|
const newCursorPos = actualLineEnd + currentLine.length + 1;
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = newCursorPos;
|
||||||
|
|
||||||
|
handleContentChange();
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Prism.js for code highlighting -->
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" />
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||||
|
|
||||||
<!-- Bootstrap JS Bundle -->
|
<!-- Bootstrap JS Bundle -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -22,8 +22,46 @@ import math
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/file-manager", tags=["file_management"])
|
router = APIRouter(prefix="/api/v1/file-manager", tags=["file_management"])
|
||||||
|
|
||||||
PROJECTS_DIR = Path("projects")
|
# 支持多个目录:projects 和 prompt
|
||||||
PROJECTS_DIR.mkdir(exist_ok=True)
|
PROJECTS_DIR = Path(".")
|
||||||
|
SUPPORTED_DIRECTORIES = ["projects", "prompt"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_path(path: str) -> Path:
|
||||||
|
"""解析路径,确保在允许的目录内"""
|
||||||
|
# 如果路径为空,默认显示支持的目录列表
|
||||||
|
if not path:
|
||||||
|
return PROJECTS_DIR
|
||||||
|
|
||||||
|
# 确定路径是否以支持的目录开头
|
||||||
|
path_parts = Path(path).parts
|
||||||
|
|
||||||
|
if not path_parts or path_parts[0] not in SUPPORTED_DIRECTORIES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"路径必须以以下目录之一开头: {', '.join(SUPPORTED_DIRECTORIES)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
target_path = PROJECTS_DIR / path
|
||||||
|
|
||||||
|
# 规范化路径,防止目录遍历攻击
|
||||||
|
try:
|
||||||
|
resolved_path = target_path.resolve()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="无效路径")
|
||||||
|
|
||||||
|
# 检查路径是否在允许的目录内
|
||||||
|
try:
|
||||||
|
allowed_root = PROJECTS_DIR.resolve()
|
||||||
|
# 检查解析后的路径是否仍在允许的目录内
|
||||||
|
try:
|
||||||
|
resolved_path.relative_to(allowed_root)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=403, detail="访问被拒绝")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=403, detail="访问被拒绝")
|
||||||
|
|
||||||
|
return resolved_path
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list")
|
@router.get("/list")
|
||||||
@ -36,7 +74,30 @@ async def list_files(path: str = "", recursive: bool = False):
|
|||||||
recursive: 是否递归列出所有子目录
|
recursive: 是否递归列出所有子目录
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
target_path = PROJECTS_DIR / path
|
# 如果路径为空,返回支持的目录列表
|
||||||
|
if not path:
|
||||||
|
items = []
|
||||||
|
for dir_name in SUPPORTED_DIRECTORIES:
|
||||||
|
dir_path = PROJECTS_DIR / dir_name
|
||||||
|
if dir_path.exists() and dir_path.is_dir():
|
||||||
|
stat = dir_path.stat()
|
||||||
|
items.append({
|
||||||
|
"name": dir_name,
|
||||||
|
"path": dir_name,
|
||||||
|
"type": "directory",
|
||||||
|
"size": 0,
|
||||||
|
"modified": stat.st_mtime,
|
||||||
|
"created": stat.st_ctime
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"path": "",
|
||||||
|
"items": items,
|
||||||
|
"total": len(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
target_path = resolve_path(path)
|
||||||
|
|
||||||
if not target_path.exists():
|
if not target_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="路径不存在")
|
raise HTTPException(status_code=404, detail="路径不存在")
|
||||||
@ -52,7 +113,13 @@ async def list_files(path: str = "", recursive: bool = False):
|
|||||||
if item.name.startswith('.'):
|
if item.name.startswith('.'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
relative_path = item.relative_to(base_path)
|
# 计算相对于项目根目录的相对路径
|
||||||
|
try:
|
||||||
|
relative_path = item.relative_to(PROJECTS_DIR)
|
||||||
|
except ValueError:
|
||||||
|
# 如果无法计算相对路径,使用绝对路径
|
||||||
|
relative_path = item
|
||||||
|
|
||||||
stat = item.stat()
|
stat = item.stat()
|
||||||
|
|
||||||
item_info = {
|
item_info = {
|
||||||
@ -74,7 +141,7 @@ async def list_files(path: str = "", recursive: bool = False):
|
|||||||
pass
|
pass
|
||||||
return items
|
return items
|
||||||
|
|
||||||
items = scan_directory(target_path)
|
items = scan_directory(target_path, Path("."))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@ -94,13 +161,15 @@ async def upload_file(file: UploadFile = File(...), path: str = Form("")):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
file: 上传的文件
|
file: 上传的文件
|
||||||
path: 目标路径(相对于projects目录)
|
path: 目标路径(相对于支持目录)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
target_dir = PROJECTS_DIR / path
|
target_path = resolve_path(path) if path else resolve_path("projects")
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
if not target_path.exists() or not target_path.is_dir():
|
||||||
|
target_path = resolve_path("projects")
|
||||||
|
target_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
file_path = target_dir / file.filename
|
file_path = target_path / file.filename
|
||||||
|
|
||||||
# 如果文件已存在,检查是否覆盖
|
# 如果文件已存在,检查是否覆盖
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
@ -132,7 +201,7 @@ async def download_file(file_path: str):
|
|||||||
file_path: 文件相对路径
|
file_path: 文件相对路径
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
target_path = PROJECTS_DIR / file_path
|
target_path = resolve_path(file_path)
|
||||||
|
|
||||||
if not target_path.exists():
|
if not target_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="文件不存在")
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
@ -164,7 +233,7 @@ async def delete_item(path: str):
|
|||||||
path: 要删除的路径
|
path: 要删除的路径
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
target_path = PROJECTS_DIR / path
|
target_path = resolve_path(path)
|
||||||
|
|
||||||
if not target_path.exists():
|
if not target_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="路径不存在")
|
raise HTTPException(status_code=404, detail="路径不存在")
|
||||||
@ -194,7 +263,7 @@ async def create_folder(path: str, name: str):
|
|||||||
name: 新文件夹名称
|
name: 新文件夹名称
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
parent_path = PROJECTS_DIR / path
|
parent_path = resolve_path(path) if path else resolve_path("projects")
|
||||||
parent_path.mkdir(parents=True, exist_ok=True)
|
parent_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
new_folder = parent_path / name
|
new_folder = parent_path / name
|
||||||
@ -224,7 +293,7 @@ async def rename_item(old_path: str, new_name: str):
|
|||||||
new_name: 新名称
|
new_name: 新名称
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
old_full_path = PROJECTS_DIR / old_path
|
old_full_path = resolve_path(old_path)
|
||||||
|
|
||||||
if not old_full_path.exists():
|
if not old_full_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="文件或目录不存在")
|
raise HTTPException(status_code=404, detail="文件或目录不存在")
|
||||||
@ -236,11 +305,16 @@ async def rename_item(old_path: str, new_name: str):
|
|||||||
|
|
||||||
old_full_path.rename(new_full_path)
|
old_full_path.rename(new_full_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_relative_path = new_full_path.relative_to(PROJECTS_DIR)
|
||||||
|
except ValueError:
|
||||||
|
new_relative_path = new_full_path
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "重命名成功",
|
"message": "重命名成功",
|
||||||
"old_path": old_path,
|
"old_path": old_path,
|
||||||
"new_path": str(new_full_path.relative_to(PROJECTS_DIR))
|
"new_path": str(new_relative_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -257,8 +331,8 @@ async def move_item(source_path: str, target_path: str):
|
|||||||
target_path: 目标路径
|
target_path: 目标路径
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
source_full_path = PROJECTS_DIR / source_path
|
source_full_path = resolve_path(source_path)
|
||||||
target_full_path = PROJECTS_DIR / target_path
|
target_full_path = resolve_path(target_path)
|
||||||
|
|
||||||
if not source_full_path.exists():
|
if not source_full_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="源文件或目录不存在")
|
raise HTTPException(status_code=404, detail="源文件或目录不存在")
|
||||||
@ -289,8 +363,8 @@ async def copy_item(source_path: str, target_path: str):
|
|||||||
target_path: 目标路径
|
target_path: 目标路径
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
source_full_path = PROJECTS_DIR / source_path
|
source_full_path = resolve_path(source_path)
|
||||||
target_full_path = PROJECTS_DIR / target_path
|
target_full_path = resolve_path(target_path)
|
||||||
|
|
||||||
if not source_full_path.exists():
|
if not source_full_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="源文件或目录不存在")
|
raise HTTPException(status_code=404, detail="源文件或目录不存在")
|
||||||
@ -325,7 +399,7 @@ async def search_files(query: str, path: str = "", file_type: Optional[str] = No
|
|||||||
file_type: 文件类型过滤
|
file_type: 文件类型过滤
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
search_path = PROJECTS_DIR / path
|
search_path = resolve_path(path) if path else Path(".")
|
||||||
|
|
||||||
if not search_path.exists():
|
if not search_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="搜索路径不存在")
|
raise HTTPException(status_code=404, detail="搜索路径不存在")
|
||||||
@ -341,17 +415,24 @@ async def search_files(query: str, path: str = "", file_type: Optional[str] = No
|
|||||||
# 检查文件名是否包含关键词
|
# 检查文件名是否包含关键词
|
||||||
if query.lower() in item.name.lower():
|
if query.lower() in item.name.lower():
|
||||||
# 检查文件类型过滤
|
# 检查文件类型过滤
|
||||||
|
# 计算相对于项目根目录的相对路径
|
||||||
|
try:
|
||||||
|
relative_path = item.relative_to(PROJECTS_DIR)
|
||||||
|
except ValueError:
|
||||||
|
# 如果无法计算相对路径,使用绝对路径
|
||||||
|
relative_path = item
|
||||||
|
|
||||||
if file_type:
|
if file_type:
|
||||||
if item.suffix.lower() == file_type.lower():
|
if item.suffix.lower() == file_type.lower():
|
||||||
results.append({
|
results.append({
|
||||||
"name": item.name,
|
"name": item.name,
|
||||||
"path": str(item.relative_to(PROJECTS_DIR)),
|
"path": str(relative_path),
|
||||||
"type": "directory" if item.is_dir() else "file"
|
"type": "directory" if item.is_dir() else "file"
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
results.append({
|
results.append({
|
||||||
"name": item.name,
|
"name": item.name,
|
||||||
"path": str(item.relative_to(PROJECTS_DIR)),
|
"path": str(relative_path),
|
||||||
"type": "directory" if item.is_dir() else "file"
|
"type": "directory" if item.is_dir() else "file"
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -376,6 +457,160 @@ async def search_files(query: str, path: str = "", file_type: Optional[str] = No
|
|||||||
raise HTTPException(status_code=500, detail=f"搜索失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"搜索失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/read")
|
||||||
|
async def read_file(path: str = Query(...)):
|
||||||
|
"""
|
||||||
|
读取文件内容
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: 文件相对路径
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
target_path = resolve_path(path)
|
||||||
|
|
||||||
|
if not target_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
|
|
||||||
|
if not target_path.is_file():
|
||||||
|
raise HTTPException(status_code=400, detail="路径不是文件")
|
||||||
|
|
||||||
|
# 检查文件大小,限制读取10MB以内的文件
|
||||||
|
file_size = target_path.stat().st_size
|
||||||
|
max_size = 10 * 1024 * 1024 # 10MB
|
||||||
|
|
||||||
|
if file_size > max_size:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"文件过大 ({formatFileSize(file_size)}),最大支持 {formatFileSize(max_size)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 尝试不同的编码方式
|
||||||
|
content = None
|
||||||
|
encoding_used = None
|
||||||
|
|
||||||
|
for encoding in ['utf-8', 'gbk', 'gb2312', 'latin-1']:
|
||||||
|
try:
|
||||||
|
with open(target_path, 'r', encoding=encoding) as f:
|
||||||
|
content = f.read()
|
||||||
|
encoding_used = encoding
|
||||||
|
break
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if content is None:
|
||||||
|
# 如果所有编码都失败,尝试以二进制方式读取并转换为可打印字符
|
||||||
|
try:
|
||||||
|
with open(target_path, 'rb') as f:
|
||||||
|
binary_content = f.read()
|
||||||
|
content = binary_content.decode('utf-8', errors='replace')
|
||||||
|
encoding_used = 'utf-8 (with replacement)'
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"无法读取文件内容: {str(e)}")
|
||||||
|
|
||||||
|
# 获取文件信息
|
||||||
|
stat = target_path.stat()
|
||||||
|
mime_type, _ = mimetypes.guess_type(str(target_path))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"content": content,
|
||||||
|
"path": path,
|
||||||
|
"size": file_size,
|
||||||
|
"modified": stat.st_mtime,
|
||||||
|
"encoding": encoding_used,
|
||||||
|
"mime_type": mime_type or "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"读取文件失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/save")
|
||||||
|
async def save_file(request: Dict[str, str]):
|
||||||
|
"""
|
||||||
|
保存文件内容
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: 包含path和content字段的JSON对象
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"收到保存请求: {request}")
|
||||||
|
|
||||||
|
path = request.get("path")
|
||||||
|
content = request.get("content")
|
||||||
|
|
||||||
|
logger.info(f"解析参数: path={path}, content length={len(content) if content else 'None'}")
|
||||||
|
|
||||||
|
if not path or content is None:
|
||||||
|
raise HTTPException(status_code=400, detail="缺少必需的参数: path 和 content")
|
||||||
|
|
||||||
|
target_path = resolve_path(path)
|
||||||
|
|
||||||
|
# 确保父目录存在
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 检查文件大小,限制保存5MB以内的内容
|
||||||
|
content_size = len(content.encode('utf-8'))
|
||||||
|
max_size = 5 * 1024 * 1024 # 5MB
|
||||||
|
|
||||||
|
if content_size > max_size:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"内容过大 ({formatFileSize(content_size)}),最大支持 {formatFileSize(max_size)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建备份(如果文件已存在)
|
||||||
|
backup_path = None
|
||||||
|
if target_path.exists():
|
||||||
|
import datetime
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
backup_path = target_path.with_suffix(f".{timestamp}.bak")
|
||||||
|
try:
|
||||||
|
shutil.copy2(target_path, backup_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to create backup: {e}")
|
||||||
|
|
||||||
|
# 写入文件
|
||||||
|
try:
|
||||||
|
with open(target_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
except Exception as e:
|
||||||
|
# 如果写入失败,恢复备份
|
||||||
|
if backup_path and backup_path.exists():
|
||||||
|
try:
|
||||||
|
shutil.copy2(backup_path, target_path)
|
||||||
|
backup_path.unlink() # 删除备份文件
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=500, detail=f"保存文件失败: {str(e)}")
|
||||||
|
|
||||||
|
# 如果保存成功,删除备份文件
|
||||||
|
if backup_path and backup_path.exists():
|
||||||
|
try:
|
||||||
|
backup_path.unlink()
|
||||||
|
except:
|
||||||
|
logger.warning(f"Failed to remove backup file: {backup_path}")
|
||||||
|
|
||||||
|
# 获取保存后的文件信息
|
||||||
|
stat = target_path.stat()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "文件保存成功",
|
||||||
|
"path": path,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"modified": stat.st_mtime,
|
||||||
|
"encoding": "utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"保存文件失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/info/{file_path:path}")
|
@router.get("/info/{file_path:path}")
|
||||||
async def get_file_info(file_path: str):
|
async def get_file_info(file_path: str):
|
||||||
"""
|
"""
|
||||||
@ -385,7 +620,7 @@ async def get_file_info(file_path: str):
|
|||||||
file_path: 文件路径
|
file_path: 文件路径
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
target_path = PROJECTS_DIR / file_path
|
target_path = resolve_path(file_path)
|
||||||
|
|
||||||
if not target_path.exists():
|
if not target_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="路径不存在")
|
raise HTTPException(status_code=404, detail="路径不存在")
|
||||||
@ -439,7 +674,7 @@ async def download_folder_as_zip(request: Dict[str, str]):
|
|||||||
if not folder_path:
|
if not folder_path:
|
||||||
raise HTTPException(status_code=400, detail="路径不能为空")
|
raise HTTPException(status_code=400, detail="路径不能为空")
|
||||||
|
|
||||||
target_path = PROJECTS_DIR / folder_path
|
target_path = resolve_path(folder_path)
|
||||||
|
|
||||||
if not target_path.exists():
|
if not target_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||||
@ -541,7 +776,7 @@ async def download_multiple_items_as_zip(request: Dict[str, Any]):
|
|||||||
file_count = 0
|
file_count = 0
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
target_path = PROJECTS_DIR / path
|
target_path = resolve_path(path)
|
||||||
|
|
||||||
if not target_path.exists():
|
if not target_path.exists():
|
||||||
continue # 跳过不存在的文件
|
continue # 跳过不存在的文件
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user