Add files via upload

This commit is contained in:
yumo666666
2025-05-25 23:22:04 +08:00
committed by GitHub
parent 79e88d04a2
commit e6c86005d2
78 changed files with 5183 additions and 0 deletions

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM python:3.10-slim
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
gcc \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件
COPY requirements.txt .
# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码
COPY . .
# 暴露端口
EXPOSE 8000
# 启动命令
CMD ["python", "main.py"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 yumo666
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

116
README.md Normal file
View File

@@ -0,0 +1,116 @@
<p align="center">
<img src="image/CloudFuse.svg" alt="CloudFuse Logo" width="120"/>
</p>
# CloudFuse - 云函数管理系统
CloudFuse 是一个基于 FastAPI 的现代化云函数管理平台,支持函数的动态上传、管理、调用、依赖自动安装、调用统计、实时日志监控等功能,适合自建 Serverless/FAAS 场景。
---
## 主要特性
- 动态上传/创建/编辑/删除函数(支持 Web 界面和 API
- 自动依赖管理:函数依赖自动检测与安装
- 函数调用统计:支持天/小时/总量统计
- 实时日志面板:只展示 logs/app.log自动轮转最多1000行
- 安全的沙箱式函数执行环境
- 支持多种文件管理操作(上传、下载、重命名、删除、目录树等)
- API 文档自动生成(/docs
- 支持 Docker 部署
- 热重载开发体验
---
## 目录结构
```
.
├── admin/ # 后台管理前端模板与静态资源
│ ├── static/
│ └── templates/
├── apps/ # 所有云函数目录
│ └── <function_name>/
│ ├── function.py
│ ├── config.json
│ ├── intro.md
│ ├── requirements.txt
│ └── ...
├── logs/ # 日志目录,仅有 app.log
│ └── app.log
├── main.py # FastAPI 启动入口
├── config.py # 配置文件含日志、路径、API等
├── requirements.txt # 依赖
└── README.md
```
---
## 快速开始
1. **安装依赖**
```bash
pip install -r requirements.txt
```
2. **启动服务**
```bash
python main.py
```
3. **访问管理后台**
- Web 管理页面http://localhost:8000/admin/
- API 文档http://localhost:8000/docs
---
## 函数管理
- 上传/创建函数:支持通过 Web 页面上传 zip/文件夹,或在线新建函数
- 函数目录要求:每个函数一个独立目录,需包含 function.py、config.json、intro.md
- 依赖自动安装:每次上传/保存函数时自动检测 requirements.txt 并安装新依赖
- 函数调用:通过 Web 或 API 直接调用,支持参数自动识别
---
## 日志系统
- 所有日志仅保存在 `logs/app.log`
- 自动轮转:只保留最新 1000 行,超出部分自动丢弃
- Web 日志面板:支持实时查看 logs/app.log 内容
---
## 文件管理
- 支持 apps 目录下的文件/文件夹上传、下载、重命名、删除、目录树浏览等操作
---
## 统计与监控
- 支持函数调用量的天/小时/总量统计
- 监控面板实时展示调用趋势、错误日志等
---
## Docker 部署
```bash
docker-compose up -d
```
---
## 常见问题
- 依赖安装失败:请检查 requirements.txt 是否正确,或手动 pip install
- 函数调用报错:请检查函数实现、参数类型、返回值格式
- 日志不显示:请确认 logs/app.log 是否有写入权限
---
## 贡献与许可
- 欢迎提交 PR 或 Issue
- 本项目采用 MIT 许可证

Binary file not shown.

Binary file not shown.

Binary file not shown.

1076
admin/admin.py Normal file

File diff suppressed because it is too large Load Diff

174
admin/static/js/apidebug.js Normal file
View File

@@ -0,0 +1,174 @@
document.addEventListener('DOMContentLoaded', function() {
let allApiConfigs = [];
let filteredApiConfigs = [];
let currentApi = null;
async function loadApiConfigs() {
const res = await fetch('/functions');
if (!res.ok) return;
const data = await res.json();
if (!data.functions) return;
allApiConfigs = [];
for (const fn of data.functions) {
if (!fn.name) continue;
try {
const res2 = await fetch(`/admin/get_function_file_content/${fn.name}?file_path=config.json`);
if (!res2.ok) continue;
const conf = await res2.json();
if (conf && conf.content) {
const cfg = JSON.parse(conf.content);
cfg._funcname = fn.name;
allApiConfigs.push(cfg);
}
} catch(e) {}
}
filteredApiConfigs = allApiConfigs;
renderApiList();
if (filteredApiConfigs.length > 0) showApiDetail(filteredApiConfigs[0]);
}
function renderApiList() {
const list = document.getElementById('api-list');
list.innerHTML = '';
filteredApiConfigs.forEach((api, idx) => {
const mainTitle = api.name && api.name.trim() ? api.name : api._funcname;
const subTitle = api.url || '';
const item = document.createElement('div');
item.className = 'apilist-item' + (currentApi && currentApi._funcname === api._funcname ? ' active' : '');
item.style = `padding:12px 14px;margin:6px 0;border-radius:8px;cursor:pointer;background:${currentApi && currentApi._funcname === api._funcname ? '#e3f0ff':'#fff'};border:1px solid #e3eaf2;font-size:15px;transition:background 0.2s;`;
item.innerHTML = `<span style=\"color:#2196F3;font-weight:bold;\">${mainTitle}</span><br><span style=\"font-size:13px;color:#888\">${subTitle}</span>`;
item.onclick = () => { currentApi = api; renderApiList(); showApiDetail(api); };
list.appendChild(item);
});
}
document.getElementById('api-search').oninput = function() {
const kw = this.value.trim().toLowerCase();
filteredApiConfigs = allApiConfigs.filter(api => api.name.toLowerCase().includes(kw) || (api.url||'').toLowerCase().includes(kw));
renderApiList();
if (filteredApiConfigs.length > 0) showApiDetail(filteredApiConfigs[0]);
else document.getElementById('api-detail-content').innerHTML = '';
};
function showApiDetail(api) {
let html = '';
const mainTitle = api._display_name && api._display_name.trim() ? api._display_name : api._funcname;
const fullUrl = `http://${window.location.host}${api.url||''}`;
html += `<div style=\"background:#fff;border-radius:16px;box-shadow:0 2px 12px rgba(33,150,243,0.06);padding:32px 36px 24px 36px;\">
<div style=\"display:flex;align-items:center;gap:16px;margin-bottom:8px;\">
<div style=\"font-size:22px;color:#2196F3;font-weight:bold;\">${api.name}</div>
<span class=\"route-chip\" onclick=\"copyRouteLink(this, '${fullUrl}')\">${api.url||''}</span>
</div>
<div style=\"margin:0 0 18px 0;\">
<span class=\"call-url-chip\" onclick=\"copyRouteLink(this, '${fullUrl}')\">${fullUrl}</span>
</div>
<div style=\"margin:18px 0 0 0;font-size:15px;color:#555;\">${api._display_name||''}</div>
<div style=\"margin:24px 0 0 0;\">
<div style=\"font-size:16px;font-weight:bold;color:#2196F3;margin-bottom:8px;\">请求体结构</div>
<div>${renderParamStruct(api.parameters||[])}</div>
</div>
<div style=\"margin:24px 0 0 0;\">
<div style=\"font-size:16px;font-weight:bold;color:#2196F3;margin-bottom:8px;\">接口调试</div>
<form id=\"apidebug-form\" onsubmit=\"sendApiDebug(event)\">
${renderParamInputs(api.parameters||[])}
<button type=\"submit\" style=\"margin-top:12px;background:#2196F3;color:#fff;border:none;border-radius:8px;padding:10px 28px;font-size:16px;cursor:pointer;\">发送请求</button>
</form>
</div>
<div id=\"api-body-panel\" style=\"margin:24px 0 0 0;\">
<div style=\"font-size:16px;font-weight:bold;color:#2196F3;margin-bottom:8px;display:flex;align-items:center;gap:16px;\">
<span>请求体JSON</span>
<button id=\"toggle-body-btn\" style=\"background:#e3f0ff;color:#2196F3;border:none;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:14px;\" onclick=\"toggleApiBody()\">展开</button>
<button id=\"copy-body-btn\" style=\"background:#e3f0ff;color:#2196F3;border:none;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:14px;display:none;\" onclick=\"copyApiBodyJson()\">复制</button>
</div>
<pre id=\"api-body-json\" style=\"background:#f7faff;border-radius:8px;padding:18px 14px;font-size:15px;min-height:48px;max-height:320px;overflow:auto;display:none;\"></pre>
</div>
<div style=\"margin:24px 0 0 0;\">
<div style=\"font-size:16px;font-weight:bold;color:#2196F3;margin-bottom:8px;\">响应</div>
<pre id=\"apidebug-response\" style=\"background:#f7faff;border-radius:8px;padding:18px 14px;font-size:15px;min-height:48px;\"></pre>
</div>
</div>`;
document.getElementById('api-detail-content').innerHTML = html;
updateApiBodyJson(api);
const form = document.getElementById('apidebug-form');
if (form) {
form.oninput = () => updateApiBodyJson(api);
}
}
function updateApiBodyJson(api) {
const form = document.getElementById('apidebug-form');
const params = {};
if (form && api.parameters) {
for (const p of api.parameters) {
const el = form.querySelector(`[name='${p.name}']`);
params[p.name] = el ? el.value : (p.default || (p.type === 'number' ? 0 : ''));
}
}
const jsonStr = JSON.stringify(params, null, 2);
const pre = document.getElementById('api-body-json');
if (pre) pre.textContent = jsonStr;
}
function toggleApiBody() {
const pre = document.getElementById('api-body-json');
const btn = document.getElementById('toggle-body-btn');
const copyBtn = document.getElementById('copy-body-btn');
if (pre.style.display === 'none') {
pre.style.display = 'block';
btn.textContent = '收起';
copyBtn.style.display = '';
} else {
pre.style.display = 'none';
btn.textContent = '展开';
copyBtn.style.display = 'none';
}
}
function copyApiBodyJson() {
const pre = document.getElementById('api-body-json');
if (pre) {
navigator.clipboard.writeText(pre.textContent);
const btn = document.getElementById('copy-body-btn');
btn.textContent = '已复制!';
setTimeout(() => { btn.textContent = '复制'; }, 1000);
}
}
function renderParamStruct(params) {
if (!params.length) return '<span style="color:#aaa;">无参数</span>';
return params.map(p => `<div style="margin-bottom:8px;"><span style="background:#e3f0ff;color:#2196F3;border-radius:6px;padding:2px 10px;font-size:14px;margin-right:8px;">${p.name}</span><span style="color:#888;">${p.type}</span> <span style="color:#bbb;">${p.required?'必填':'可选'}</span> <span style="color:#aaa;">${p.description||''}</span></div>`).join('');
}
function renderParamInputs(params) {
if (!params.length) return '<div style="color:#aaa;">无参数</div>';
return params.map(p => `<div style="margin-bottom:12px;"><label style="font-size:15px;color:#555;margin-bottom:4px;display:block;">${p.name} <span style="color:#888;font-size:13px;">(${p.type}${p.required?',必填':',可选'})</span></label><input name="${p.name}" type="text" placeholder="${p.description||''}" value="${p.default||''}" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid #e3eaf2;background:#f7faff;font-size:15px;"></div>`).join('');
}
window.sendApiDebug = async function(e) {
e.preventDefault();
if (!currentApi) return;
const form = document.getElementById('apidebug-form');
const params = {};
for (const el of form.elements) {
if (el.name) params[el.name] = el.value;
}
let url = currentApi.url;
let method = (currentApi.method||'GET').toUpperCase();
let options = { method, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') {
const usp = new URLSearchParams(params);
url += '?' + usp.toString();
} else {
options.body = JSON.stringify(params);
}
try {
const res = await fetch(url, options);
const data = await res.json();
document.getElementById('apidebug-response').textContent = JSON.stringify(data, null, 2);
} catch(e) {
document.getElementById('apidebug-response').textContent = '请求失败:'+e.message;
}
}
window.copyRouteLink = function(el, url) {
navigator.clipboard.writeText(url);
const old = el.textContent;
el.textContent = '已复制!';
el.style.background = '#b2e0ff';
setTimeout(() => {
el.textContent = old;
el.style.background = '';
}, 1000);
}
loadApiConfigs();
});

516
admin/static/js/file.js Normal file
View File

@@ -0,0 +1,516 @@
// 文件管理页主要 JS 逻辑
// 这里只做基础结构,后续可根据需要细化
// ...(可根据 index.html 相关 JS 拆分填充)
document.addEventListener('DOMContentLoaded', function() {
// ========== 文件管理页新版 ========== //
const fileTableContainer = document.getElementById('file-table-container');
const filePathNav = document.getElementById('file-path-nav');
const fileBtnBack = document.getElementById('file-btn-back');
const fileBtnAdd = document.getElementById('file-btn-add');
const fileBtnRefresh = document.getElementById('file-btn-refresh');
const fileBtnUpload = document.getElementById('file-btn-upload');
const fileFolderUploadInput = document.getElementById('file-folder-upload');
let fileCurrentPath = '';
let fileTableData = [];
let fileSelected = new Set();
// File Edit Dialog elements
const editDialog = document.getElementById('edit-dialog');
const editDialogMask = document.getElementById('file-dialog-mask'); // Re-using existing mask
const currentFileTitle = document.getElementById('current-file-title');
const fileEditor = document.getElementById('file-editor');
const editSaveBtn = document.getElementById('edit-save-btn');
const editCancelBtn = document.getElementById('edit-cancel-btn');
let currentEditingFilePath = null; // To store the full path of the file being edited
function renderFilePathNav() {
const segs = fileCurrentPath ? fileCurrentPath.split('/') : [];
let html = `<span class='file-path-seg${segs.length === 0 ? ' active' : ''}' onclick="fileJumpTo('')">apps</span>`;
let path = '';
for (let i = 0; i < segs.length; ++i) {
path += (path ? '/' : '') + segs[i];
const isLast = i === segs.length - 1;
html += `<span class='file-path-arrow'>&gt;</span><span class='file-path-seg${isLast ? ' active' : ''}' ${isLast ? '' : `onclick=\"fileJumpTo('${path}')\"`}>${segs[i]}</span>`;
}
filePathNav.innerHTML = html;
}
async function loadFileTable(path = '') {
fileCurrentPath = path;
fileSelected.clear();
renderFilePathNav();
fileTableContainer.innerHTML = '<div style="color:#888;padding:18px;">加载中...</div>';
try {
const res = await fetch('/admin/list_dir?path=' + encodeURIComponent(path));
if (!res.ok) throw new Error('接口错误');
fileTableData = await res.json();
renderFileTable();
} catch(e) {
fileTableContainer.innerHTML = '<div style="color:red;padding:18px;">加载失败</div>';
}
}
function renderFileTable() {
let html = `<table style="width:100%;background:#fff;border-radius:8px;box-shadow:0 2px 8px #f0f0f0;font-size:15px;overflow:hidden;">
<thead><tr style="background:#f7faff;">
<th style="width:36px;"><input type='checkbox' id='file-check-all'></th>
<th style="text-align:left;">名称</th>
<th style="width:80px;">类型</th>
<th style="width:100px;">大小</th>
<th style="width:180px;">修改时间</th>
<th style="width:220px;">操作</th>
</tr></thead><tbody>`;
for (const item of fileTableData) {
const isFolder = item.type === 'folder';
const icon = isFolder ? '📁' : '📄';
const ext = item.name.split('.').pop().toLowerCase();
// Only allow editing for specific file extensions
const canEdit = !isFolder && ['py','json','txt','md','yaml','yml'].includes(ext);
const rowId = 'row-' + item.name.replace(/[^a-zA-Z0-9_-]/g, '_');
html += `<tr class='file-row' id='${rowId}'>
<td><input type='checkbox' class='file-check' data-name='${item.name}'></td>
<td class='file-name-cell' data-name='${item.name}' data-type='${item.type}' data-can-edit='${canEdit}'>
<button class='file-main-btn' onclick="event.stopPropagation();${isFolder ? `fileJumpTo('${fileCurrentPath ? fileCurrentPath + '/' : ''}${item.name}')` : (canEdit ? `fileEdit('${item.name}')` : `fileDownload('${item.name}')`)}">
<span style='font-size:18px;'>${icon}</span> <span>${item.name}</span>
</button>
</td>
<td>${isFolder ? '目录' : '文件'}</td>
<td>${isFolder ? '-' : (item.size == null ? '-' : formatSize(item.size))}</td>
<td>${item.mtime || '-'}</td>
<td>
<button class='file-op-btn' title='下载' onclick="fileDownload('${item.name}')">⬇️</button>
<button class='file-op-btn' title='删除' onclick="fileDelete('${item.name}')">🗑️</button>
<button class='file-op-btn' title='重命名' onclick="fileRename('${item.name}')">✏️</button>
</td>
</tr>`;
}
html += '</tbody></table>';
fileTableContainer.innerHTML = html;
document.getElementById('file-check-all').onclick = function() {
document.querySelectorAll('.file-check').forEach(cb => { cb.checked = this.checked; });
};
// 行选中交互
document.querySelectorAll('.file-row').forEach(row => {
row.onclick = function(e) {
// 如果点击的是按钮或checkbox不处理
if (e.target.closest('.file-op-btn') || e.target.closest('.file-main-btn') || e.target.classList.contains('file-check')) return;
row.classList.toggle('selected');
const name = row.querySelector('.file-name-cell').getAttribute('data-name');
const cb = row.querySelector('.file-check');
cb.checked = row.classList.contains('selected');
};
});
}
function formatSize(size) {
if (size < 1024) return size + ' B';
if (size < 1024*1024) return (size/1024).toFixed(1) + ' KB';
if (size < 1024*1024*1024) return (size/1024/1024).toFixed(1) + ' MB';
return (size/1024/1024/1024).toFixed(1) + ' GB';
}
window.fileJumpTo = function(path) { loadFileTable(path); };
// New function to handle file editing
window.fileEdit = async function(name) {
const filePath = fileCurrentPath ? fileCurrentPath + '/' + name : name;
currentEditingFilePath = filePath;
currentFileTitle.textContent = `编辑文件: ${filePath}`;
fileEditor.value = '加载中...';
editDialogMask.style.display = 'flex';
editDialog.style.display = 'flex';
try {
const res = await fetch('/admin/get_file_content?path=' + encodeURIComponent(filePath));
if (!res.ok) throw new Error(`Error fetching file: ${res.status} ${res.statusText}`);
const data = await res.json();
if (data.content !== undefined) {
fileEditor.value = data.content;
} else {
fileEditor.value = '无法获取文件内容。';
}
} catch (error) {
console.error('Failed to load file content:', error);
fileEditor.value = '加载文件内容失败: ' + error.message;
}
};
window.hideEditDialog = function() {
editDialogMask.style.display = 'none';
editDialog.style.display = 'none';
currentEditingFilePath = null;
fileEditor.value = '';
};
// Add event listeners for save and cancel buttons in edit dialog
editCancelBtn.onclick = window.hideEditDialog;
editSaveBtn.onclick = async () => {
if (!currentEditingFilePath) return;
const content = fileEditor.value;
const formData = new FormData();
formData.append('path', currentEditingFilePath);
formData.append('content', content);
try {
// Temporarily disable save button and show saving status
editSaveBtn.textContent = '保存中...';
editSaveBtn.disabled = true;
const res = await fetch('/admin/save_file', {
method: 'POST',
body: formData // Use FormData for file content
});
if (res.ok) {
showSuccessNotification('保存成功');
window.hideEditDialog();
// Optionally refresh the file list to update modified time
loadFileTable(fileCurrentPath);
} else {
const errorText = await res.text();
alert('保存失败: ' + res.status + ' ' + res.statusText + ', Details: ' + errorText);
}
} catch (error) {
console.error('Failed to save file:', error);
alert('保存文件失败: ' + error.message);
} finally {
// Re-enable save button and restore text
editSaveBtn.textContent = '保存';
editSaveBtn.disabled = false;
}
};
// Function to show a custom success notification
function showSuccessNotification(message) {
const notification = document.createElement('div');
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.left = '50%';
notification.style.transform = 'translateX(-50%)';
notification.style.background = '#fff';
notification.style.color = '#333';
notification.style.padding = '12px 24px';
notification.style.borderRadius = '24px';
notification.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
notification.style.zIndex = '3000';
notification.style.display = 'flex';
notification.style.alignItems = 'center';
notification.style.gap = '10px';
notification.style.fontSize = '17px';
notification.style.opacity = '0';
notification.style.transition = 'opacity 0.3s ease-in-out';
notification.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#4CAF50"/>
<path d="M8 12.3L11 15.3L16 9.3" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="checkmark"/>
</svg>
<span>${message}</span>
`;
document.body.appendChild(notification);
// Fade in
setTimeout(() => {
notification.style.opacity = '1';
}, 10);
// Fade out and remove after 3 seconds
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => {
notification.remove();
}, 300);
}, 3000);
}
window.fileDownload = function(name) {
const path = fileCurrentPath ? fileCurrentPath + '/' + name : name;
window.open('/admin/download_file?path=' + encodeURIComponent(path), '_blank');
};
window.fileDelete = function(name) {
// 这里只做简单实现,实际可弹窗确认
if (!confirm('确定要删除 ' + name + ' 吗?')) return;
const path = fileCurrentPath ? fileCurrentPath + '/' + name : name;
fetch('/admin/delete_file?path=' + encodeURIComponent(path), {method:'DELETE'}).then(res => {
if(res.ok) loadFileTable(fileCurrentPath);
else alert('删除失败');
});
};
window.fileRename = function(name) {
const newName = prompt('输入新名称', name);
if (!newName || newName === name) return;
const path = fileCurrentPath ? fileCurrentPath + '/' + name : name;
const form = new FormData();
form.append('path', path);
form.append('new_name', newName);
fetch('/admin/rename_file', {method:'POST', body: form}).then(res => {
if(res.ok) {
showSuccessNotification('重命名成功');
loadFileTable(fileCurrentPath);
}
else alert('重命名失败');
});
};
fileBtnBack.onclick = () => {
if (!fileCurrentPath) return;
const segs = fileCurrentPath.split('/');
segs.pop();
loadFileTable(segs.join('/'));
};
fileBtnRefresh.onclick = () => loadFileTable(fileCurrentPath);
fileBtnAdd.onclick = () => {
let types = fileCurrentPath === ''
? [
{type: 'project', label: '项目'},
{type: 'folder', label: '文件夹'},
{type: 'file', label: '文件'}
]
: [
{type: 'folder', label: '文件夹'},
{type: 'file', label: '文件'}
];
let typeBtns = types.map((t, i) =>
`<button type='button' class='file-create-type-btn${i===0?' selected':''}' data-type='${t.type}'>${t.label}</button>`
).join('');
showFileDialog(`
<div class='file-create-dialog-title'>新建</div>
<div class='file-create-type-group'>${typeBtns}</div>
<div class='file-create-input-wrap'>
<input id='create-name' class='file-create-input' placeholder='名称' autocomplete='off'>
</div>
<div class='file-create-btn-row'>
<button class='file-create-btn cancel' type='button'>取消</button>
<button class='file-create-btn confirm' type='button'>创建</button>
</div>
`);
setTimeout(() => {
const btns = document.querySelectorAll('.file-create-type-btn');
btns.forEach(btn => {
btn.onclick = function() {
btns.forEach(b => b.classList.remove('selected'));
this.classList.add('selected');
};
});
document.querySelector('.file-create-btn.cancel').onclick = hideFileDialog;
document.querySelector('.file-create-btn.confirm').onclick = async () => {
const nameInput = document.getElementById('create-name');
const name = nameInput ? nameInput.value.trim() : '';
const selectedBtn = document.querySelector('.file-create-type-btn.selected');
const type = selectedBtn ? selectedBtn.getAttribute('data-type') : types[0].type;
const typeText = types.find(t => t.type === type)?.label || '';
if (!name) {
nameInput.focus();
nameInput.placeholder = '名称不能为空';
return;
}
if (type === 'project') {
showProjectDetailDialog(name);
return;
}
const data = { path: fileCurrentPath, name, type };
const res = await fetch('/admin/create_file_or_folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.ok) {
const result = await res.json();
if (result.status === 'success') {
showSuccessNotification(`${typeText} "${name}" 创建成功`);
loadFileTable(fileCurrentPath);
hideFileDialog();
} else {
alert(`${typeText} "${name}" 创建失败: ` + (result.detail || JSON.stringify(result)));
}
} else {
const errorText = await res.text();
alert(`${typeText} "${name}" 创建失败: ` + res.status + ' ' + res.statusText + ', Details: ' + errorText);
}
};
}, 10);
};
// ========== 迁移 showFileDialog、hideFileDialog、showProjectDetailDialog ==========
const fileDialogMask = document.getElementById('file-dialog-mask');
const fileDialog = document.getElementById('file-dialog');
function showFileDialog(html, onok, oncancel) {
fileDialog.innerHTML = html;
fileDialogMask.style.display = 'flex';
fileDialog.style.display = 'block';
const okBtn = fileDialog.querySelector('.file-dialog-ok');
const cancelBtn = fileDialog.querySelector('.file-dialog-cancel');
if (okBtn) okBtn.onclick = () => { if(onok) onok(); hideFileDialog(); };
if (cancelBtn) cancelBtn.onclick = hideFileDialog;
fileDialogMask.onclick = function(e) {
if (e.target === fileDialogMask) {
hideFileDialog();
}
};
}
function hideFileDialog() {
fileDialogMask.style.display = 'none';
fileDialog.style.display = 'none';
}
function showProjectDetailDialog(projectName) {
let inputParams = [];
let outputParams = [];
function syncParams() {
document.querySelectorAll('#input-param-list > div').forEach((div, i) => {
const [nameInput, typeSelect] = div.querySelectorAll('input,select');
if (inputParams[i]) {
inputParams[i].name = nameInput.value;
inputParams[i].type = typeSelect.value;
}
});
document.querySelectorAll('#output-param-list > div').forEach((div, i) => {
const [nameInput, typeSelect] = div.querySelectorAll('input,select');
if (outputParams[i]) {
outputParams[i].name = nameInput.value;
outputParams[i].type = typeSelect.value;
}
});
}
function render() {
const descInput = document.getElementById('project-desc');
const savedDesc = descInput ? descInput.value : '';
let html = `
<div class='file-create-dialog-title'>新建项目:${projectName}</div>
<div style='margin-bottom:18px;'>
<label style='font-weight:bold;font-size:20px;'>函数描述:</label><br>
<input id='project-desc' class='file-create-input' placeholder='输入函数描述' style='margin-top:10px;margin-bottom:0;' value='${savedDesc}'>
</div>
<div style='margin-bottom:18px;'>
<label style='font-weight:bold;font-size:20px;'>输入参数:</label><br>
<div id='input-param-list'>
${inputParams.map((p, i) => `
<div style='display:flex;align-items:center;gap:10px;margin-bottom:10px;'>
<input class='file-create-input' style='flex:2;padding:10px 16px;font-size:16px;' placeholder='参数名称' value='${p.name||''}'>
<select class='file-create-input' style='flex:1;padding:10px 16px;font-size:16px;background:#fff;'>
<option value='str' ${p.type === 'str' ? 'selected' : ''}>字符串 (str)</option>
<option value='int' ${p.type === 'int' ? 'selected' : ''}>整数 (int)</option>
<option value='float' ${p.type === 'float' ? 'selected' : ''}>浮点数 (float)</option>
<option value='bool' ${p.type === 'bool' ? 'selected' : ''}>布尔值 (bool)</option>
<option value='list' ${p.type === 'list' ? 'selected' : ''}>列表 (list)</option>
<option value='dict' ${p.type === 'dict' ? 'selected' : ''}>字典 (dict)</option>
</select>
<button class='file-create-btn cancel param-del-btn' style='padding:6px 16px;font-size:15px;' data-idx='${i}'>删除</button>
</div>
`).join('')}
</div>
<button class='file-create-btn confirm' id='add-input-param' style='width:100%;margin-top:8px;'>添加输入参数</button>
</div>
<div style='margin-bottom:18px;'>
<label style='font-weight:bold;font-size:20px;'>返回参数:</label><br>
<div id='output-param-list'>
${outputParams.map((p, i) => `
<div style='display:flex;align-items:center;gap:10px;margin-bottom:10px;'>
<input class='file-create-input' style='flex:2;padding:10px 16px;font-size:16px;' placeholder='参数名称' value='${p.name||''}'>
<select class='file-create-input' style='flex:1;padding:10px 16px;font-size:16px;background:#fff;'>
<option value='str' ${p.type === 'str' ? 'selected' : ''}>字符串 (str)</option>
<option value='int' ${p.type === 'int' ? 'selected' : ''}>整数 (int)</option>
<option value='float' ${p.type === 'float' ? 'selected' : ''}>浮点数 (float)</option>
<option value='bool' ${p.type === 'bool' ? 'selected' : ''}>布尔值 (bool)</option>
<option value='list' ${p.type === 'list' ? 'selected' : ''}>列表 (list)</option>
<option value='dict' ${p.type === 'dict' ? 'selected' : ''}>字典 (dict)</option>
</select>
<button class='file-create-btn cancel param-del-btn' style='padding:6px 16px;font-size:15px;' data-idx='${i}'>删除</button>
</div>
`).join('')}
</div>
<button class='file-create-btn confirm' id='add-output-param' style='width:100%;margin-top:8px;'>添加返回参数</button>
</div>
<div class='file-create-btn-row'>
<button class='file-create-btn cancel' type='button'>取消</button>
<button class='file-create-btn confirm' id='project-create-btn' type='button'>创建</button>
</div>
`;
showFileDialog(html);
fileDialog.onclick = function(e) {
e.stopPropagation();
if (e.target.classList.contains('param-del-btn')) {
syncParams();
const idx = parseInt(e.target.getAttribute('data-idx'));
const isInput = e.target.closest('#input-param-list') !== null;
if (isInput) inputParams.splice(idx, 1);
else outputParams.splice(idx, 1);
render();
}
if (e.target.id === 'add-input-param') {
syncParams();
inputParams.push({name:'',type:'str'});
render();
}
if (e.target.id === 'add-output-param') {
syncParams();
outputParams.push({name:'',type:'str'});
render();
}
};
document.querySelector('.file-create-btn.cancel').onclick = hideFileDialog;
document.getElementById('project-create-btn').onclick = async () => {
syncParams();
const desc = document.getElementById('project-desc').value.trim();
const inputList = inputParams.filter(p => p.name);
const outputList = outputParams.filter(p => p.name);
function genPyType(t) {
return {str:'str',int:'int',float:'float',bool:'bool',list:'list',dict:'dict'}[t]||'str';
}
function genDefaultValue(t) {
return {str: "''", int: '0', float: '0.0', bool: 'False', list: '[]', dict: '{}'}[t]||"''";
}
const paramStr = inputList.map(p => `${p.name}: ${genPyType(p.type)}`).join(', ');
const docParams = inputList.map(p => ` ${p.name}: ${p.type}`).join('\n');
const docReturns = outputList.map(p => ` ${p.name}: ${p.type}`).join('\n');
const funcCode = `def ${projectName}(${paramStr}):\n """\n ${desc}\n 参数:\n${docParams}\n 返回:\n${docReturns}\n """\n # TODO: 实现你的业务逻辑\n return {}`;
const config = {
url: `/function/${projectName}`,
method: 'GET',
name: desc,
parameters: inputList.map(p => ({
name: p.name,
type: p.type,
required: true,
description: '',
default: ''
}))
};
const data = {
function_name: projectName,
files: {
function: funcCode,
config: JSON.stringify(config, null, 2),
intro: desc
}
};
const res = await fetch('/admin/create_function', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.ok) {
const result = await res.json();
if (result.status === 'success') {
showSuccessNotification(`项目 "${projectName}" 创建成功`);
loadFileTable(fileCurrentPath);
hideFileDialog();
} else {
alert(`项目 "${projectName}" 创建失败: ` + (result.detail || JSON.stringify(result)));
}
} else {
const errorText = await res.text();
alert(`项目 "${projectName}" 创建失败: ` + res.status + ' ' + res.statusText + ', Details: ' + errorText);
}
};
}
render();
}
// 页面加载时自动加载根目录
loadFileTable('');
});

28
admin/static/js/log.js Normal file
View File

@@ -0,0 +1,28 @@
document.addEventListener('DOMContentLoaded', function() {
let logWs = null;
let logBuffer = '';
function startLogStream() {
if (logWs && logWs.readyState === WebSocket.OPEN) return;
if (logWs) logWs.close();
logBuffer = '';
logWs = new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/admin/logs/stream');
logWs.onopen = function() {
logBuffer = '连接成功,等待输出...\n';
renderLogStream();
};
logWs.onmessage = function(e) {
logBuffer += e.data;
renderLogStream();
};
logWs.onclose = function(){};
logWs.onerror = function(error){};
}
function renderLogStream() {
const preLog = document.getElementById('log-cards');
if (preLog) {
preLog.textContent = logBuffer;
preLog.scrollTop = preLog.scrollHeight;
}
}
startLogStream();
});

View File

@@ -0,0 +1,24 @@
document.addEventListener('DOMContentLoaded', function() {
async function fetchStats() {
try {
const res1 = await fetch('/functions');
let totalFunctions = '--';
if (res1.ok) {
const data = await res1.json();
totalFunctions = data.functions ? data.functions.length : '--';
}
document.getElementById('total-functions').textContent = totalFunctions;
const res2 = await fetch('/admin/call_stats_data');
let totalCalls = '--';
if (res2.ok) {
const data = await res2.json();
totalCalls = data.total !== undefined ? data.total : '--';
}
document.getElementById('total-calls').textContent = totalCalls;
} catch(e) {
document.getElementById('total-functions').textContent = '--';
document.getElementById('total-calls').textContent = '--';
}
}
fetchStats();
});

1
admin/static/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.4 MiB

1182
admin/static/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{% extends 'base.html' %}
{% block content %}
<div class="page-top-bar">
<span class="page-title">接口调试</span>
</div>
<div style="display:flex;height:calc(100vh - 60px - 20px);background:#f7faff;">
<!-- 左侧API列表 -->
<div id="apilist-panel" style="width:270px;background:#f3f7fd;border-right:1px solid #e3eaf2;display:flex;flex-direction:column;">
<div style="padding:18px 18px 8px 18px;">
<input id="api-search" type="text" placeholder="搜索 API" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid #e3eaf2;background:#fff;outline:none;font-size:15px;">
</div>
<div id="api-list" style="flex:1;overflow-y:auto;padding:0 8px 8px 8px;"></div>
</div>
<!-- 右侧详情区 -->
<div id="apidetail-panel" style="flex:1;display:flex;flex-direction:column;align-items:stretch;overflow-y:auto;padding:0 0 0 0;">
<div id="api-detail-content" style="margin:32px auto 0 auto;width:90%;max-width:900px;"></div>
</div>
</div>
{% endblock %}
{% block body_end %}
<script src="/static/js/apidebug.js"></script>
{% endblock %}

31
admin/templates/base.html Normal file
View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<title>CloudFuse 云函数管理系统</title>
<meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
<link rel="stylesheet" href="/static/style.css">
{% block head %}{% endblock %}
</head>
<body>
<div class="main-layout">
<div class="sidebar">
<div class="sidebar-logo">
<img src="/static/logo.svg" alt="logo" style="width:40px;height:40px;">
</div>
<div class="sidebar-title">CloudFuse</div>
<div class="sidebar-menu">
<a class="sidebar-menu-item {% if page=='sysinfo' %}active{% endif %}" href="/admin/sysinfo">基础信息</a>
<a class="sidebar-menu-item {% if page=='apidebug' %}active{% endif %}" href="/admin/apidebug">接口调试</a>
<a class="sidebar-menu-item {% if page=='log' %}active{% endif %}" href="/admin/log">日志信息</a>
<a class="sidebar-menu-item {% if page=='file' %}active{% endif %}" href="/admin/file">文件管理</a>
</div>
<div class="sidebar-footer">© 2024 YourName</div>
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
</div>
{% block body_end %}{% endblock %}
</body>
</html>

41
admin/templates/file.html Normal file
View File

@@ -0,0 +1,41 @@
{% extends 'base.html' %}
{% block content %}
<div class="page-top-bar">
<span class="page-title">文件管理</span>
</div>
<div style="padding:0 36px;">
<div id="file-toolbar" style="display:flex;align-items:center;gap:10px;margin:18px 0 10px 0;">
<button id="file-btn-back" class="file-toolbar-btn" title="返回上一级"><span>⬅️</span></button>
<button id="file-btn-add" class="file-toolbar-btn" title="新建文件夹/文件"><span></span></button>
<button id="file-btn-refresh" class="file-toolbar-btn" title="刷新"><span>🔄</span></button>
<button id="file-btn-upload" class="file-toolbar-btn" title="上传"><span></span></button>
<div id="file-path-nav" class="file-path-box"></div>
</div>
<div id="file-table-container"></div>
</div>
<input type="file" id="file-folder-upload" webkitdirectory directory multiple style="display:none">
<div id="file-dialog-mask" style="display:none;position:fixed;left:0;top:0;width:100vw;height:100vh;background:rgba(0,0,0,0.18);z-index:2000;align-items:center;justify-content:center;"></div>
<div id="file-dialog" style="display:none;position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:#fff;border-radius:12px;box-shadow:0 4px 32px rgba(0,0,0,0.13);z-index:2001;min-width:320px;max-width:90vw;padding:32px 28px 24px 28px;"></div>
<div id="edit-dialog" class="edit-dialog" style="display: none;">
<div class="edit-dialog-content">
<button class="edit-dialog-close" onclick="hideEditDialog()">×</button>
<div class="file-list-panel" style="display: none;">
<div class="file-list-title">文件列表</div>
<div class="file-list-content"></div>
</div>
<div class="file-content-panel">
<div id="current-file-title" style="font-weight: bold; font-size: 16px; margin-bottom: 10px;"></div>
<div id="file-content-area" style="flex: 1; display: flex; flex-direction: column; min-height: 0;">
<textarea id="file-editor" class="file-editor" placeholder="加载中..."></textarea>
</div>
<div class="dialog-buttons">
<button id="edit-save-btn" style="background:#2196F3;color:#fff;border:none;border-radius:6px;padding:8px 22px;font-size:15px;">保存</button>
<button id="edit-cancel-btn" style="background:#e3f0ff;color:#2196F3;border:none;border-radius:6px;padding:8px 22px;font-size:15px;">取消</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block body_end %}
<script src="/static/js/file.js"></script>
{% endblock %}

14
admin/templates/log.html Normal file
View File

@@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block content %}
<div class="page-top-bar">
<span class="page-title">日志信息</span>
</div>
<div style="padding:0 36px;max-width:1400px;margin:auto;">
<div id="log-realtime-panel">
<pre id="log-cards" class="log-content-pre"></pre>
</div>
</div>
{% endblock %}
{% block body_end %}
<script src="/static/js/log.js"></script>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends 'base.html' %}
{% block content %}
<div class="page-top-bar">
<span class="page-title">基础信息</span>
</div>
<div style="padding:0 36px;">
<div class="sysinfo-top">
<div class="sysinfo-card">
<div class="sysinfo-logo">
<img src="/static/logo.svg" alt="logo" style="width:48px;height:48px;">
</div>
<div class="sysinfo-meta">
<div class="sysinfo-title">CloudFuse 云函数管理系统</div>
<div class="sysinfo-author">作者YourName</div>
<div class="sysinfo-version">版本v1.0.0</div>
</div>
</div>
</div>
<div class="stats-cards">
<div class="stats-card">
<div class="stats-card-title">总函数个数</div>
<div class="stats-card-value" id="total-functions">--</div>
</div>
<div class="stats-card">
<div class="stats-card-title">总调用次数</div>
<div class="stats-card-value" id="total-calls">--</div>
</div>
</div>
</div>
{% endblock %}
{% block body_end %}
<script src="/static/js/sysinfo.js"></script>
{% endblock %}

142
apps/README.md Normal file
View File

@@ -0,0 +1,142 @@
# 云函数开发与接入指南
本说明文档介绍如何为 CloudFuse 云函数管理系统添加和开发新函数。
---
## 1. 函数目录结构
每个函数需独立一个目录,目录名即为函数名,结构如下:
```
function_name/
├── function.py # 函数实现(必需,需有同名函数)
├── config.json # 函数API配置必需
├── intro.md # 简要介绍(必需)
├── requirements.txt # 依赖(可选,自动安装)
├── readme.md # 详细文档(可选)
└── xlsx_files/ # Excel文件目录可选
```
---
## 2. 必需文件说明
### function.py
- 必须包含一个与目录同名的函数。
- 支持参数类型注解和默认值。
示例:
```python
def example_function(param1: str, param2: int = 0):
"""示例函数"""
return {"result": f"处理 {param1}{param2}"}
```
### config.json
- 定义API接口信息、参数类型、描述等。
示例:
```json
{
"url": "/function/example_function",
"method": "GET",
"name": "示例函数的简要描述",
"parameters": [
{"name": "param1", "type": "string", "required": true, "description": "第一个参数", "default": ""},
{"name": "param2", "type": "number", "required": false, "description": "第二个参数", "default": "0"}
]
}
```
### intro.md
- 简要介绍函数用途和特点。
---
## 3. 可选文件说明
- **requirements.txt**:列出依赖包,系统自动检测并安装。
- **readme.md**:详细文档,建议包含用法、参数、返回值、示例等。
- **xlsx_files/**如需处理Excel文件可放于此。
---
## 4. 支持的参数类型
- string
- number
- boolean
- array
- object
---
## 5. 示例
### 简单回显函数
```python
def echo_message(message: str = "Hello World"):
return {"message": message}
```
```json
{
"url": "/function/echo_message",
"method": "GET",
"name": "函数中文名",
"parameters": [
{"name": "message", "type": "string", "required": true, "description": "要回显的消息内容", "default": "Hello World"}
]
}
```
### Excel处理函数
```python
import pandas as pd
import random
import os
def get_random_xlsx_line(filename: str):
file_path = os.path.join('xlsx_files', filename)
if not os.path.exists(file_path):
return {"error": "文件不存在"}
df = pd.read_excel(file_path)
random_row = df.iloc[random.randint(0, len(df)-1)]
return random_row.to_dict()
```
---
## 6. 开发与调试建议
- 本地先测试函数逻辑,确保可用。
- 检查必需文件和参数类型。
- 返回值必须可JSON序列化建议用dict。
- 依赖请写入 requirements.txt系统自动安装。
- 错误处理要明确,建议 try-except。
- 上传/保存后可在Web界面直接测试。
---
## 7. 上传与管理
- 推荐通过 Web 管理界面上传/新建函数,系统自动校验、安装依赖、刷新路由。
- 也可手动将函数目录放入 apps/,重启服务或点击刷新。
---
## 8. 常见问题
- **依赖未安装**:请检查 requirements.txt 格式和包名。
- **参数报错**:请检查 config.json 参数类型和函数签名。
- **函数不可用**:请确保函数名与目录名一致,且有 function.py。
- **返回值异常**:请确保返回值可被 JSON 序列化。
---
## 9. 最佳实践
- 遵循 PEP8 规范,适当注释
- 合理设计参数和默认值
- 明确错误处理和返回格式
- 文档齐全,便于协作和维护

6
apps/calculate/README.md Normal file
View File

@@ -0,0 +1,6 @@
GET /function/calculate
参数:
- num1: 第一个数字必需默认0
- num2: 第二个数字必需默认0
- operation: 运算符必需可选值add/subtract/multiply/divide默认add
返回:计算结果和运算说明

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,28 @@
{
"url": "/function/calculate",
"method": "GET",
"name": "计算器函数",
"parameters": [
{
"name": "num1",
"type": "number",
"required": true,
"description": "第一个数字",
"default": "0"
},
{
"name": "num2",
"type": "number",
"required": true,
"description": "第二个数字",
"default": "0"
},
{
"name": "operation",
"type": "string",
"required": true,
"description": "运算符(add/subtract/multiply/divide)",
"default": "add"
}
]
}

View File

@@ -0,0 +1,32 @@
def calculate(num1: float, num2: float, operation: str = "add"):
"""
多参数示例函数,执行基本的数学运算
Args:
num1 (float): 第一个数字
num2 (float): 第二个数字
operation (str): 运算符(add/subtract/multiply/divide)
Returns:
dict: 包含计算结果和运算说明
"""
operations = {
"add": (lambda x, y: x + y, ""),
"subtract": (lambda x, y: x - y, ""),
"multiply": (lambda x, y: x * y, ""),
"divide": (lambda x, y: x / y if y != 0 else "除数不能为0", "")
}
try:
if operation not in operations:
return {"error": "不支持的运算符"}
func, op_name = operations[operation]
result = func(float(num1), float(num2))
return {
"result": result,
"description": f"{num1} {op_name} {num2} = {result}"
}
except Exception as e:
return {"error": str(e)}

6
apps/calculate/intro.md Normal file
View File

@@ -0,0 +1,6 @@
GET /function/calculate
参数:
- num1: 第一个数字必需默认0
- num2: 第二个数字必需默认0
- operation: 运算符必需可选值add/subtract/multiply/divide默认add
返回:计算结果和运算说明

View File

@@ -0,0 +1 @@
# 仅使用Python标准库

View File

@@ -0,0 +1,4 @@
GET /function/echo_message
参数:
- message: 要回显的消息必需默认Hello!
返回:原始消息和时间戳

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,14 @@
{
"url": "/function/echo_message",
"method": "GET",
"name": "一个参数的示例函数,回显消息的示例函数",
"parameters": [
{
"name": "message",
"type": "string",
"required": true,
"description": "要回显的消息内容",
"default": "Hello Word!"
}
]
}

View File

@@ -0,0 +1,15 @@
def echo_message(message: str):
"""
单参数示例函数,回显输入的消息
Args:
message (str): 要回显的消息
Returns:
dict: 包含原始消息和时间戳
"""
from datetime import datetime
return {
"original_message": message,
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}

View File

@@ -0,0 +1,4 @@
GET /function/echo_message
参数:
- message: 要回显的消息必需默认Hello!
返回:原始消息和时间戳

View File

@@ -0,0 +1 @@
# 仅使用Python标准库

View File

@@ -0,0 +1,6 @@
{
"url": "/function/example_function",
"method": "GET",
"name": "无参数示例函数 - 返回Hello World",
"parameters": []
}

View File

@@ -0,0 +1,5 @@
def example_function():
"""
示例函数返回Hello World
"""
return {"message": "Hello World"}

View File

@@ -0,0 +1,3 @@
GET /function/example_function
返回Hello World 示例响应
参数:无

View File

@@ -0,0 +1,8 @@
# 示例函数说明
## 接口说明
请求方式GET
接口地址:/function/example_function
## 返回值
- 返回一个包含消息的JSON对象{"message": "这是一个示例函数"}

2
apps/functions/README.md Normal file
View File

@@ -0,0 +1,2 @@
GET /functions
返回:系统中所有可用函数的列表及其配置信息

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,6 @@
{
"url": "/functions",
"method": "GET",
"name": "获取所有可用函数列表",
"parameters": []
}

View File

@@ -0,0 +1,30 @@
import os
import json
from config import PATHS
from fastapi import HTTPException
def functions():
"""
获取所有可用函数列表
"""
try:
functions = []
apps_dir = PATHS["APPS_DIR"]
for item in os.listdir(apps_dir):
if os.path.isdir(os.path.join(apps_dir, item)) and not item.startswith('__'):
config_path = os.path.join(apps_dir, item, 'config.json')
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
functions.append({
"name": item,
"url": config["url"],
"method": config["method"],
"display_name": config.get("name", item),
"parameters": config["parameters"]
})
return {"functions": functions}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

2
apps/functions/intro.md Normal file
View File

@@ -0,0 +1,2 @@
GET /functions
返回:系统中所有可用函数的列表及其配置信息

View File

@@ -0,0 +1,14 @@
{
"url": "/function/get_random_xlsx_line",
"method": "GET",
"name": "从Excel文件中随机获取一行数据",
"parameters": [
{
"name": "filename",
"type": "string",
"required": true,
"description": "Excel文件名称需位于xlsx_files目录下",
"default": "中国近现代史单选.xlsx"
}
]
}

View File

@@ -0,0 +1,33 @@
import pandas as pd
import random
import os
def get_random_xlsx_line(filename: str):
"""
从Excel文件中随机获取一行数据
Args:
filename (str): Excel文件名
Returns:
dict: 包含随机行数据的字典
"""
try:
file_path = os.path.join("apps", "get_random_xlsx_line", "xlsx_files", filename)
df = pd.read_excel(file_path)
if df.empty:
return {"error": "Excel文件为空"}
# 获取随机行
random_row = df.iloc[random.randint(0, len(df)-1)]
# 只返回题目内容(第一列)
if len(df.columns) > 0:
question = random_row[df.columns[0]]
return {"data": question}
else:
return {"error": "Excel文件格式不正确"}
except Exception as e:
return {"error": str(e)}

View File

@@ -0,0 +1,3 @@
GET /function/get_random_xlsx_line
参数filename=中国近现代史单选.xlsx
返回:随机一行内容(单列返回字符串,多列返回数组)

View File

@@ -0,0 +1,11 @@
# 随机返回Excel文件中的一行数据
## 接口说明
请求方式GET
接口地址:/function/get_random_xlsx_line
参数:
- filename: Excel文件名称必须位于xlsx_files目录下
## 返回值
- 单列Excel返回随机一行的字符串
- 多列Excel返回随机一行的所有列值数组格式

View File

@@ -0,0 +1,2 @@
pandas>=1.3.0
openpyxl>=3.0.0

View File

@@ -0,0 +1,3 @@
GET /function/hello_world
参数:无
返回Hello World消息

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,6 @@
{
"url": "/function/hello_world",
"method": "GET",
"name": "简单的Hello World示例函数",
"parameters": []
}

View File

@@ -0,0 +1,8 @@
def hello_world():
"""
无参数的示例函数返回Hello World
"""
return {
"message": "Hello World!",
"status": "success"
}

View File

@@ -0,0 +1,3 @@
GET /function/hello_world
参数:无
返回Hello World消息

View File

@@ -0,0 +1 @@
# 无需额外依赖

6
call_stats.json Normal file
View File

@@ -0,0 +1,6 @@
{
"total": 0,
"functions": {},
"history_day": {},
"history_hour": {}
}

87
config.py Normal file
View File

@@ -0,0 +1,87 @@
import os
import logging
from datetime import datetime
# 基础配置
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
LOGS_DIR = os.path.join(BASE_DIR, 'logs')
os.makedirs(LOGS_DIR, exist_ok=True)
# 自定义日志轮转Handler不再生成日期log只保留最新1000行
class RotatingLineFileHandler(logging.FileHandler):
def __init__(self, filename, max_lines=1000, encoding=None):
super().__init__(filename, encoding=encoding)
self.filename = filename
self.max_lines = max_lines
def emit(self, record):
super().emit(record)
self.rotate_if_needed()
def rotate_if_needed(self):
try:
with open(self.filename, 'r', encoding='utf-8') as f:
lines = f.readlines()
if len(lines) > self.max_lines:
# 只保留最新max_lines行丢弃旧的
with open(self.filename, 'w', encoding='utf-8') as f:
f.writelines(lines[-self.max_lines:])
except Exception as e:
print(f'Log rotation error: {e}')
# API配置
API_CONFIG = {
"HOST": "127.0.0.1",
"PORT": 8000,
"DEBUG": True
}
# 路径配置
PATHS = {
"BASE_DIR": BASE_DIR,
"APPS_DIR": os.path.join(BASE_DIR, "apps"),
"ROUTES_FILE": os.path.join(BASE_DIR, "routes.txt"),
"STATIC_DIR": os.path.join(BASE_DIR, "admin", "static"),
"TEMPLATES_DIR": os.path.join(BASE_DIR, "admin", "templates"),
"LOGS_DIR": LOGS_DIR
}
# 日志配置
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
},
"file": {
"()": RotatingLineFileHandler,
"filename": os.path.join(LOGS_DIR, "app.log"),
"max_lines": 1000,
"formatter": "default",
"encoding": "utf-8"
}
},
"loggers": {
"watchfiles": {
"handlers": [],
"propagate": False,
"level": "WARNING"
},
"uvicorn.error": {
"handlers": [],
"propagate": False,
"level": "WARNING"
}
},
"root": {
"handlers": ["console", "file"],
"level": "INFO",
}
}

62
hot_reload.py Normal file
View File

@@ -0,0 +1,62 @@
import time
import os
import subprocess
import sys
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
class AppReloader(FileSystemEventHandler):
def __init__(self):
self.process = None
self.start_app()
def start_app(self):
# 终止现有进程
if self.process:
self.process.terminate()
self.process.wait()
# 启动新进程
print("\n=== 正在启动服务器 ===")
self.process = subprocess.Popen([sys.executable, "main.py"])
print("=== 服务器已启动 ===\n")
def on_modified(self, event):
if event.src_path.endswith('.py') or event.src_path.endswith('.md'):
print(f"\n检测到文件变化: {event.src_path}")
self.start_app()
def on_created(self, event):
if event.src_path.endswith('.py') or event.src_path.endswith('.md'):
print(f"\n检测到新文件: {event.src_path}")
self.start_app()
def main():
# 创建观察者
observer = Observer()
event_handler = AppReloader()
# 监视apps目录
observer.schedule(event_handler, 'apps', recursive=True)
# 监视main.py
observer.schedule(event_handler, '.', recursive=False)
observer.start()
print("=== 热重载监视器已启动 ===")
print("监视目录: ./apps")
print("监视文件: ./main.py")
print("按Ctrl+C退出\n")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
if event_handler.process:
event_handler.process.terminate()
print("\n=== 服务已停止 ===")
observer.join()
if __name__ == "__main__":
main()

1
image/CloudFuse.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.4 MiB

1007
logs/app.log Normal file

File diff suppressed because it is too large Load Diff

258
main.py Normal file
View File

@@ -0,0 +1,258 @@
import sys
import os
from datetime import datetime
import re # Import re module for regex
# ANSI escape code regex
ANSI_ESCAPE = re.compile(r'\x1B(?:[@-Z\\-_]|\\[0-?][ -/][@-~])')
# Tee class to duplicate stdout/stderr
class Tee(object):
def __init__(self, *files):
self.files = files
# Store original stdout/stderr to forward isatty() calls
self.original_stdout = sys.__stdout__ # Use sys.__stdout__ for the actual original stream
self.original_stderr = sys.__stderr__ # Use sys.__stderr__ for the actual original stream
def filter_ansi(self, text):
# More robust ANSI escape code removal
# Source: https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
ansi_escape = re.compile(r'(\x1b[^m]*m)')
return ansi_escape.sub('', text)
def write(self, obj):
for f in self.files:
# If writing to the log file, filter ANSI codes
if f is log_file: # Directly check if it's the log file object
content_to_write = self.filter_ansi(obj)
# Ensure newlines for file logging consistency
if not content_to_write.endswith('\n'):
content_to_write += '\n'
else:
content_to_write = obj
f.write(content_to_write)
f.flush()
def flush(self):
for f in self.files:
f.flush()
def isatty(self):
# Forward the isatty() call to the original stdout/stderr
# Check if original_stdout is a file-like object before calling isatty
if hasattr(self.original_stdout, 'isatty'):
return self.original_stdout.isatty()
return False # Default to False if not a proper terminal
# 日志目录和文件
log_dir = os.path.join(os.path.dirname(__file__), "logs")
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, "app.log")
# 日志归档函数
import threading, time
def rotate_log_daily():
while True:
now = datetime.now()
next_day = datetime(now.year, now.month, now.day) # 今天0点
next_day = next_day.replace(day=now.day+1) if now.hour >= 0 else next_day
seconds = (next_day - now).total_seconds()
time.sleep(seconds)
# 归档
archive_name = os.path.join(log_dir, now.strftime("%Y-%m-%d.log"))
if os.path.exists(log_path):
with open(log_path, "rb") as src, open(archive_name, "ab") as dst:
dst.write(src.read())
open(log_path, "w").close() # 清空
threading.Thread(target=rotate_log_daily, daemon=True).start()
# Redirect stdout and stderr to both the log file and original stdout
log_file = open(log_path, "a", encoding="utf-8", buffering=1)
original_stdout = sys.stdout
sys.stdout = Tee(original_stdout, log_file)
sys.stderr = Tee(sys.stderr, log_file)
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse
import time
from utils.logger import logger
from config import API_CONFIG, PATHS
from admin.admin import router as admin_router, FunctionManager
import importlib
from typing import List, Dict
import json
import asyncio
from datetime import datetime
# 定义生命周期管理器
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动时执行
try:
logger.info("Checking dependencies on startup...")
new_deps = await FunctionManager.check_and_install_dependencies()
if new_deps:
logger.info(f"Installed new dependencies on startup: {new_deps}")
else:
logger.info("No new dependencies needed")
except Exception as e:
logger.error(f"Error checking dependencies on startup: {e}")
raise e
yield # 这里是应用运行的地方
# 关闭时执行(如果需要)
# 清理代码可以放在这里
# 使用生命周期管理器创建应用
app = FastAPI(title="API Service", version="1.0.0", lifespan=lifespan)
# CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 添加可信主机中间件
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["*"] # 在生产环境中应该限制允许的主机
)
# 请求计时中间件
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
# 全局错误处理
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error(f"Global error: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "message": str(exc)}
)
# 静态文件
app.mount("/static", StaticFiles(directory=PATHS["STATIC_DIR"]), name="static")
# 路由
app.include_router(admin_router, prefix="/admin")
# 获取函数列表的路由
@app.get("/functions")
async def get_functions():
try:
module = importlib.import_module("apps.functions.function")
return module.functions()
except Exception as e:
logger.error(f"Error getting functions list: {e}")
raise HTTPException(status_code=500, detail=str(e))
# 动态函数调用
@app.get("/function/{function_name}")
async def call_function(function_name: str, request: Request):
try:
module = importlib.import_module(f"apps.{function_name}.function")
func = getattr(module, function_name)
logger.info(f"Calling function: {function_name}")
# 获取所有查询参数
query_params = dict(request.query_params)
# 获取函数参数信息
import inspect
sig = inspect.signature(func)
# 准备函数参数
kwargs = {}
for param_name, param in sig.parameters.items():
if param_name in query_params:
# 根据参数类型转换值
param_type = param.annotation if param.annotation != inspect.Parameter.empty else str
try:
if param_type == float:
kwargs[param_name] = float(query_params[param_name])
elif param_type == int:
kwargs[param_name] = int(query_params[param_name])
else:
kwargs[param_name] = query_params[param_name]
except ValueError as e:
raise HTTPException(status_code=400,
detail=f"Invalid value for parameter {param_name}: {str(e)}")
elif param.default == inspect.Parameter.empty:
# 如果参数没有默认值且未提供,则报错
raise HTTPException(status_code=400,
detail=f"Missing required parameter: {param_name}")
# 调用函数
result = func(**kwargs)
# 统计调用次数(支持天/小时)
try:
now = datetime.now()
day_str = now.strftime('%Y-%m-%d')
hour_str = now.strftime('%Y-%m-%d-%H')
stats_file = os.path.join(PATHS["BASE_DIR"], "call_stats.json")
# 兼容旧数据结构
if os.path.exists(stats_file):
with open(stats_file, "r", encoding="utf-8") as f:
stats = json.load(f)
if "history_day" not in stats or not isinstance(stats["history_day"], dict):
stats["history_day"] = {}
if "history_hour" not in stats or not isinstance(stats["history_hour"], dict):
stats["history_hour"] = {}
else:
stats = {"total": 0, "functions": {}, "history_day": {}, "history_hour": {}}
# 总数
stats["total"] = stats.get("total", 0) + 1
stats["functions"][function_name] = stats["functions"].get(function_name, 0) + 1
# 按天
if day_str not in stats["history_day"] or not isinstance(stats["history_day"][day_str], dict):
stats["history_day"][day_str] = {"total": 0}
stats["history_day"][day_str]["total"] = stats["history_day"][day_str].get("total", 0) + 1
stats["history_day"][day_str][function_name] = stats["history_day"][day_str].get(function_name, 0) + 1
# 按小时
if hour_str not in stats["history_hour"] or not isinstance(stats["history_hour"][hour_str], dict):
stats["history_hour"][hour_str] = {"total": 0}
stats["history_hour"][hour_str]["total"] = stats["history_hour"][hour_str].get("total", 0) + 1
stats["history_hour"][hour_str][function_name] = stats["history_hour"][hour_str].get(function_name, 0) + 1
with open(stats_file, "w", encoding="utf-8") as f:
json.dump(stats, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"Error updating call stats: {e}")
return result
except Exception as e:
logger.error(f"Error calling function {function_name}: {e}")
raise HTTPException(status_code=404, detail=f"Function error: {str(e)}")
# 添加错误处理中间件
@app.middleware("http")
async def error_handling_middleware(request: Request, call_next):
try:
response = await call_next(request)
return response
except Exception as e:
logger.error(f"Unhandled error: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "message": str(e)}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0", # 修改为监听所有网络接口
port=API_CONFIG["PORT"],
reload=API_CONFIG["DEBUG"]
)

43
requirements.txt Normal file
View File

@@ -0,0 +1,43 @@
# Web框架和核心依赖
fastapi>=0.68.0,<1.0.0
uvicorn>=0.15.0,<1.0.0
python-multipart>=0.0.5 # 用于文件上传
aiofiles>=0.7.0 # 异步文件操作
requests>=2.26.0,<3.0.0 # HTTP请求
pydantic>=1.8.2,<2.0.0 # FastAPI数据验证
# 数据处理
numpy>=1.21.0,<2.0.0 # 数值计算
pandas>=1.3.0,<2.0.0 # 数据处理和Excel文件支持
openpyxl>=3.0.7 # Excel文件支持
xlrd>=2.0.1 # Excel文件读取旧格式
# 文件处理
python-docx>=0.8.11 # 生成Word文档
reportlab>=3.6.12 # 生成PDF文件
pdf2image>=1.16.0 # 将PDF转换为图片
markdown2>=2.4.4 # 将Markdown转换为HTML
imgkit>=1.2.2 # 将HTML转换为图片需要安装wkhtmltopdf
# 工具和辅助库
python-dateutil>=2.8.2 # 日期处理
pytz>=2021.3 # 时区支持
# 测试和开发工具
pytest>=6.2.5,<7.0.0 # 测试框架
pluggy>=1.0.0 # pytest插件管理
iniconfig>=1.1.1 # pytest配置
watchdog>=2.1.6 # 文件监控
colorama>=0.4.6 # 终端颜色支持
certifi>=2024.8.30 # SSL证书
charset-normalizer>=2.0.0,<3.0.0 # 字符集支持
h11>=0.12.0 # HTTP/1.1协议支持
httpcore>=0.13.7 # HTTP核心库
rfc3986>=1.5.0 # URL解析
six>=1.16.0 # Python 2和3兼容
sniffio>=1.3.1 # 异步IO支持
atomicwrites>=1.4.1 # 原子写入
attrs>=21.2.0 # 属性管理
setuptools>=57.5.0 # 包管理
toml>=0.10.2 # TOML文件支持
typing_extensions>=3.10.0 # 类型扩展

9
routes.txt Normal file
View File

@@ -0,0 +1,9 @@
/function/calculate
/function/echo_message
/function/example_function
/function/functions
/function/hello_world
/functions
/function/get_random_xlsx_line
/function/static
/function/cs

Binary file not shown.

Binary file not shown.

Binary file not shown.

6
utils/logger.py Normal file
View File

@@ -0,0 +1,6 @@
import logging.config
from config import LOGGING
# 配置日志
logging.config.dictConfig(LOGGING)
logger = logging.getLogger(__name__)