优化guideline提示词,新增admin后台修改prompt功能

This commit is contained in:
朱潮 2025-12-02 10:10:00 +08:00
parent c5b68a91f3
commit bb640e6d6e
3 changed files with 1155 additions and 35 deletions

View File

@ -45,10 +45,12 @@
- **限制条件**: [需要遵守的规则和约束]
- **可用资源**: [可以利用的工具和资源]
### ⚡ 行动计划
### ⚡ 执行步骤
1. [第一步的具体行动]
2. [第二步的具体行动]
3. [第三步的具体行动]
4. [第四步的具体行动]
5. [第五步的具体行动]
## 输出语言 (Language)
{language}

View File

@ -477,6 +477,174 @@
margin-bottom: 1rem;
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>
</head>
<body>
@ -814,6 +982,99 @@
</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>
// 全局变量
const API_BASE = `${window.location.protocol}//${window.location.host}/api/v1`;
@ -828,6 +1089,12 @@
let currentItemToRename = '';
let batchMode = false;
// 文件编辑相关变量
let currentEditingFile = '';
let originalFileContent = '';
let fileEditStatus = 'ready'; // ready, modified, saving, error
let showLineNumbers = false;
// 初始化
document.addEventListener('DOMContentLoaded', function() {
// 检查登录状态
@ -1503,13 +1770,15 @@
// 加载文件列表
async function loadFileList(path = '') {
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();
if (data.success) {
currentPath = path;
currentPath = cleanPath;
renderFileList(data.items);
updateBreadcrumb(path);
updateBreadcrumb(cleanPath);
}
} catch (error) {
console.error('加载文件列表失败:', error);
@ -1542,11 +1811,31 @@
}
return a.name.localeCompare(b.name);
}).forEach(item => {
const fileItem = createFileItem(item);
// 确保路径是相对路径格式
const cleanItem = {
...item,
path: sanitizePath(item.path)
};
const fileItem = createFileItem(cleanItem);
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) {
const div = document.createElement('div');
@ -1572,11 +1861,15 @@
<div class="file-actions">
<div class="btn-group" role="group">
${item.type === 'file' ?
`<button class="btn btn-outline-primary btn-sm" onclick="downloadFile('${item.path}')"><i class="bi bi-download"></i></button>` :
`<button class="btn btn-outline-primary btn-sm" onclick="downloadFolderAsZip('${item.path}')"><i class="bi bi-file-zip"></i></button>`
(isEditableFile(item.name) ?
`<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-danger btn-sm" onclick="deleteItem('${item.path}')"><i class="bi bi-trash"></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}')" title="删除"><i class="bi bi-trash"></i></button>
</div>
</div>
`;
@ -1697,7 +1990,8 @@
// 搜索文件
async function searchFiles(query) {
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();
if (data.success) {
@ -2219,9 +2513,598 @@
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>
<!-- Bootstrap JS Bundle -->
<!-- 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 -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -22,8 +22,46 @@ import math
router = APIRouter(prefix="/api/v1/file-manager", tags=["file_management"])
PROJECTS_DIR = Path("projects")
PROJECTS_DIR.mkdir(exist_ok=True)
# 支持多个目录projects 和 prompt
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")
@ -36,7 +74,30 @@ async def list_files(path: str = "", recursive: bool = False):
recursive: 是否递归列出所有子目录
"""
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():
raise HTTPException(status_code=404, detail="路径不存在")
@ -52,7 +113,13 @@ async def list_files(path: str = "", recursive: bool = False):
if item.name.startswith('.'):
continue
relative_path = item.relative_to(base_path)
# 计算相对于项目根目录的相对路径
try:
relative_path = item.relative_to(PROJECTS_DIR)
except ValueError:
# 如果无法计算相对路径,使用绝对路径
relative_path = item
stat = item.stat()
item_info = {
@ -74,7 +141,7 @@ async def list_files(path: str = "", recursive: bool = False):
pass
return items
items = scan_directory(target_path)
items = scan_directory(target_path, Path("."))
return {
"success": True,
@ -94,13 +161,15 @@ async def upload_file(file: UploadFile = File(...), path: str = Form("")):
Args:
file: 上传的文件
path: 目标路径相对于projects目录
path: 目标路径相对于支持目录
"""
try:
target_dir = PROJECTS_DIR / path
target_dir.mkdir(parents=True, exist_ok=True)
target_path = resolve_path(path) if path else resolve_path("projects")
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():
@ -132,7 +201,7 @@ async def download_file(file_path: str):
file_path: 文件相对路径
"""
try:
target_path = PROJECTS_DIR / file_path
target_path = resolve_path(file_path)
if not target_path.exists():
raise HTTPException(status_code=404, detail="文件不存在")
@ -164,7 +233,7 @@ async def delete_item(path: str):
path: 要删除的路径
"""
try:
target_path = PROJECTS_DIR / path
target_path = resolve_path(path)
if not target_path.exists():
raise HTTPException(status_code=404, detail="路径不存在")
@ -194,7 +263,7 @@ async def create_folder(path: str, name: str):
name: 新文件夹名称
"""
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)
new_folder = parent_path / name
@ -224,7 +293,7 @@ async def rename_item(old_path: str, new_name: str):
new_name: 新名称
"""
try:
old_full_path = PROJECTS_DIR / old_path
old_full_path = resolve_path(old_path)
if not old_full_path.exists():
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)
try:
new_relative_path = new_full_path.relative_to(PROJECTS_DIR)
except ValueError:
new_relative_path = new_full_path
return {
"success": True,
"message": "重命名成功",
"old_path": old_path,
"new_path": str(new_full_path.relative_to(PROJECTS_DIR))
"new_path": str(new_relative_path)
}
except Exception as e:
@ -257,8 +331,8 @@ async def move_item(source_path: str, target_path: str):
target_path: 目标路径
"""
try:
source_full_path = PROJECTS_DIR / source_path
target_full_path = PROJECTS_DIR / target_path
source_full_path = resolve_path(source_path)
target_full_path = resolve_path(target_path)
if not source_full_path.exists():
raise HTTPException(status_code=404, detail="源文件或目录不存在")
@ -289,8 +363,8 @@ async def copy_item(source_path: str, target_path: str):
target_path: 目标路径
"""
try:
source_full_path = PROJECTS_DIR / source_path
target_full_path = PROJECTS_DIR / target_path
source_full_path = resolve_path(source_path)
target_full_path = resolve_path(target_path)
if not source_full_path.exists():
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: 文件类型过滤
"""
try:
search_path = PROJECTS_DIR / path
search_path = resolve_path(path) if path else Path(".")
if not search_path.exists():
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():
# 检查文件类型过滤
# 计算相对于项目根目录的相对路径
try:
relative_path = item.relative_to(PROJECTS_DIR)
except ValueError:
# 如果无法计算相对路径,使用绝对路径
relative_path = item
if file_type:
if item.suffix.lower() == file_type.lower():
results.append({
"name": item.name,
"path": str(item.relative_to(PROJECTS_DIR)),
"path": str(relative_path),
"type": "directory" if item.is_dir() else "file"
})
else:
results.append({
"name": item.name,
"path": str(item.relative_to(PROJECTS_DIR)),
"path": str(relative_path),
"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)}")
@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}")
async def get_file_info(file_path: str):
"""
@ -385,7 +620,7 @@ async def get_file_info(file_path: str):
file_path: 文件路径
"""
try:
target_path = PROJECTS_DIR / file_path
target_path = resolve_path(file_path)
if not target_path.exists():
raise HTTPException(status_code=404, detail="路径不存在")
@ -439,7 +674,7 @@ async def download_folder_as_zip(request: Dict[str, str]):
if not folder_path:
raise HTTPException(status_code=400, detail="路径不能为空")
target_path = PROJECTS_DIR / folder_path
target_path = resolve_path(folder_path)
if not target_path.exists():
raise HTTPException(status_code=404, detail="文件夹不存在")
@ -541,7 +776,7 @@ async def download_multiple_items_as_zip(request: Dict[str, Any]):
file_count = 0
for path in paths:
target_path = PROJECTS_DIR / path
target_path = resolve_path(path)
if not target_path.exists():
continue # 跳过不存在的文件