新增多个核心功能模块(后端 Rust 命令 + 前端 Panel 全部接线完成): - 服务器注册表:servers.rs + ServerManagerPanel、ProjectServerPanel(项目关联服务器) - SSH Config 自动同步:ssh_config.rs - 服务器软件管理:server_software.rs + ServerSoftwareBlock - 宿主机应用管理:host_apps.rs + HostAppsPanel - 服务配置管理:service_config.rs + ServiceConfigBlock - 项目部署信息:deploy_info.rs + DeployInfoPanel - API密钥管理:api_keys.rs + ApiKeysPanel - 云产品管理:cloud_products.rs + CloudProductsPanel(含 Docker应用、云数据库实例子模块) - 源码库:source_library.rs + SourceLibraryPage(分类管理 + 项目详情) 导航栏新增「源码库」入口;蓝图 manifest 同步补录 7 个新模块
531 lines
20 KiB
Rust
531 lines
20 KiB
Rust
use crate::db;
|
||
use crate::models::{DirEntry, SourceCategory, SourceProject, SourceVersion};
|
||
use std::fs;
|
||
use std::path::Path;
|
||
use uuid::Uuid;
|
||
|
||
// ── 分类 CRUD ────────────────────────────────────────────────────────────────
|
||
|
||
#[tauri::command]
|
||
pub fn get_source_categories() -> Result<Vec<SourceCategory>, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
let mut stmt = conn
|
||
.prepare("SELECT id, name, icon, color, sort_order FROM source_categories ORDER BY sort_order, name")
|
||
.map_err(|e| e.to_string())?;
|
||
let rows = stmt
|
||
.query_map([], SourceCategory::from_row)
|
||
.map_err(|e| e.to_string())?;
|
||
rows.map(|r| r.map_err(|e| e.to_string())).collect()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn create_source_category(name: String, icon: Option<String>, color: Option<String>) -> Result<SourceCategory, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
let id = Uuid::new_v4().to_string();
|
||
let icon = icon.unwrap_or_else(|| "📁".into());
|
||
let color = color.unwrap_or_else(|| "#6B7280".into());
|
||
conn.execute(
|
||
"INSERT INTO source_categories (id, name, icon, color) VALUES (?1, ?2, ?3, ?4)",
|
||
rusqlite::params![id, name, icon, color],
|
||
).map_err(|e| e.to_string())?;
|
||
Ok(SourceCategory { id, name, icon, color, sort_order: 0 })
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn update_source_category(id: String, name: String, icon: String, color: String) -> Result<(), String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
conn.execute(
|
||
"UPDATE source_categories SET name=?2, icon=?3, color=?4 WHERE id=?1",
|
||
rusqlite::params![id, name, icon, color],
|
||
).map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn delete_source_category(id: String) -> Result<(), String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
conn.execute("UPDATE source_projects SET category_id=NULL WHERE category_id=?1", rusqlite::params![id])
|
||
.map_err(|e| e.to_string())?;
|
||
conn.execute("DELETE FROM source_categories WHERE id=?1", rusqlite::params![id])
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
// ── 项目 CRUD ────────────────────────────────────────────────────────────────
|
||
|
||
#[tauri::command]
|
||
pub fn get_source_projects(category_id: Option<String>, search: Option<String>) -> Result<Vec<SourceProject>, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
|
||
let mut sql = String::from(
|
||
"SELECT p.*, c.name AS category_name,
|
||
(SELECT COUNT(*) FROM source_versions v WHERE v.project_id = p.id) AS version_count
|
||
FROM source_projects p
|
||
LEFT JOIN source_categories c ON c.id = p.category_id
|
||
WHERE 1=1"
|
||
);
|
||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
|
||
|
||
if let Some(ref cid) = category_id {
|
||
sql.push_str(" AND p.category_id = ?");
|
||
params.push(Box::new(cid.clone()));
|
||
}
|
||
if let Some(ref q) = search {
|
||
sql.push_str(" AND (p.name LIKE ? OR p.description LIKE ? OR p.language LIKE ?)");
|
||
let like = format!("%{}%", q);
|
||
params.push(Box::new(like.clone()));
|
||
params.push(Box::new(like.clone()));
|
||
params.push(Box::new(like));
|
||
}
|
||
sql.push_str(" ORDER BY p.updated_at DESC");
|
||
|
||
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
||
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||
let rows = stmt
|
||
.query_map(param_refs.as_slice(), SourceProject::from_row)
|
||
.map_err(|e| e.to_string())?;
|
||
rows.map(|r| r.map_err(|e| e.to_string())).collect()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn create_source_project(
|
||
name: String,
|
||
description: Option<String>,
|
||
category_id: Option<String>,
|
||
language: Option<String>,
|
||
repo_url: Option<String>,
|
||
introduction: Option<String>,
|
||
) -> Result<SourceProject, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
let id = Uuid::new_v4().to_string();
|
||
conn.execute(
|
||
"INSERT INTO source_projects (id, name, description, category_id, language, repo_url, introduction)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||
rusqlite::params![id, name, description, category_id, language, repo_url, introduction],
|
||
).map_err(|e| e.to_string())?;
|
||
|
||
let mut stmt = conn.prepare(
|
||
"SELECT p.*, c.name AS category_name, 0 AS version_count
|
||
FROM source_projects p LEFT JOIN source_categories c ON c.id = p.category_id
|
||
WHERE p.id = ?1"
|
||
).map_err(|e| e.to_string())?;
|
||
stmt.query_row(rusqlite::params![id], SourceProject::from_row)
|
||
.map_err(|e| e.to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn update_source_project(
|
||
id: String,
|
||
name: String,
|
||
description: Option<String>,
|
||
category_id: Option<String>,
|
||
language: Option<String>,
|
||
repo_url: Option<String>,
|
||
introduction: Option<String>,
|
||
notes: Option<String>,
|
||
) -> Result<(), String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
conn.execute(
|
||
"UPDATE source_projects SET name=?2, description=?3, category_id=?4, language=?5,
|
||
repo_url=?6, introduction=?7, notes=?8, updated_at=datetime('now') WHERE id=?1",
|
||
rusqlite::params![id, name, description, category_id, language, repo_url, introduction, notes],
|
||
).map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn delete_source_project(id: String) -> Result<(), String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
// 先获取所有版本的文件路径,清理托管目录中的文件
|
||
let mut stmt = conn.prepare("SELECT file_path FROM source_versions WHERE project_id=?1")
|
||
.map_err(|e| e.to_string())?;
|
||
let paths: Vec<String> = stmt.query_map(rusqlite::params![id], |r| r.get(0))
|
||
.map_err(|e| e.to_string())?
|
||
.filter_map(|r| r.ok())
|
||
.collect();
|
||
for p in &paths {
|
||
let path = Path::new(p);
|
||
if path.is_dir() {
|
||
let _ = fs::remove_dir_all(path);
|
||
} else if path.is_file() {
|
||
let _ = fs::remove_file(path);
|
||
}
|
||
}
|
||
// CASCADE 会删除 source_versions
|
||
conn.execute("DELETE FROM source_projects WHERE id=?1", rusqlite::params![id])
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
// ── 版本管理 ─────────────────────────────────────────────────────────────────
|
||
|
||
#[tauri::command]
|
||
pub fn get_source_versions(project_id: String) -> Result<Vec<SourceVersion>, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
let mut stmt = conn
|
||
.prepare("SELECT * FROM source_versions WHERE project_id=?1 ORDER BY is_primary DESC, imported_at DESC")
|
||
.map_err(|e| e.to_string())?;
|
||
let rows = stmt
|
||
.query_map(rusqlite::params![project_id], SourceVersion::from_row)
|
||
.map_err(|e| e.to_string())?;
|
||
rows.map(|r| r.map_err(|e| e.to_string())).collect()
|
||
}
|
||
|
||
/// 导入一个版本——支持两种模式:
|
||
/// 1. 目录模式:file_path 指向已有源码目录,原地引用
|
||
/// 2. 文件模式:file_path 指向 .zip/.tar.gz 等压缩包,复制到托管目录保管
|
||
#[tauri::command]
|
||
pub fn add_source_version(
|
||
app_handle: tauri::AppHandle,
|
||
project_id: String,
|
||
project_name: String,
|
||
version: String,
|
||
file_path: String,
|
||
) -> Result<SourceVersion, String> {
|
||
use tauri::Manager;
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
let id = Uuid::new_v4().to_string();
|
||
|
||
let src = Path::new(&file_path);
|
||
if !src.exists() {
|
||
return Err(format!("路径不存在: {}", file_path));
|
||
}
|
||
|
||
let is_dir = src.is_dir();
|
||
let (stored_path, file_size, dir_tree_json, archive_path);
|
||
|
||
if is_dir {
|
||
// ── 目录模式:原地引用,扫描目录树 ──
|
||
stored_path = file_path.clone();
|
||
file_size = calc_dir_size(&file_path);
|
||
let dir_tree = scan_dir_tree_internal(&file_path);
|
||
dir_tree_json = serde_json::to_string(&dir_tree).unwrap_or_else(|_| "[]".into());
|
||
archive_path = None::<String>;
|
||
|
||
// 自动提取 README
|
||
if let Some(ref readme_content) = extract_readme_internal(&file_path) {
|
||
let _ = conn.execute(
|
||
"UPDATE source_projects SET readme=?2, updated_at=datetime('now') WHERE id=?1 AND (readme IS NULL OR readme='')",
|
||
rusqlite::params![project_id, readme_content],
|
||
);
|
||
}
|
||
// 自动检测语言
|
||
if let Some(ref lang) = detect_language_internal(&file_path) {
|
||
let _ = conn.execute(
|
||
"UPDATE source_projects SET language=?2 WHERE id=?1 AND (language IS NULL OR language='')",
|
||
rusqlite::params![project_id, lang],
|
||
);
|
||
}
|
||
} else {
|
||
// ── 文件模式:复制压缩包到托管目录 ──
|
||
let data_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?;
|
||
let lib_root = data_dir.join("source-library");
|
||
|
||
let safe_name: String = project_name.chars()
|
||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
|
||
.collect();
|
||
let dest_dir = lib_root.join(&safe_name);
|
||
fs::create_dir_all(&dest_dir).map_err(|e| format!("创建托管目录失败: {}", e))?;
|
||
|
||
let file_name = src.file_name()
|
||
.map(|n| n.to_string_lossy().to_string())
|
||
.unwrap_or_else(|| format!("{}.archive", version));
|
||
|
||
let dest_file = dest_dir.join(&file_name);
|
||
|
||
// 复制文件到托管目录
|
||
fs::copy(src, &dest_file).map_err(|e| format!("复制文件失败: {}", e))?;
|
||
|
||
file_size = dest_file.metadata().map(|m| m.len() as i64).unwrap_or(0);
|
||
stored_path = dest_file.to_string_lossy().to_string();
|
||
archive_path = Some(file_path.clone()); // 记录原始路径
|
||
|
||
// 文件模式的目录树:显示压缩包文件名本身
|
||
let entry = DirEntry {
|
||
name: file_name,
|
||
type_: "file".into(),
|
||
children_count: None,
|
||
};
|
||
dir_tree_json = serde_json::to_string(&vec![entry]).unwrap_or_else(|_| "[]".into());
|
||
}
|
||
|
||
// 检查是否为首个版本
|
||
let count: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM source_versions WHERE project_id=?1", rusqlite::params![project_id], |r| r.get(0))
|
||
.unwrap_or(0);
|
||
let is_primary = if count == 0 { 1 } else { 0 };
|
||
|
||
conn.execute(
|
||
"INSERT INTO source_versions (id, project_id, version, file_path, archive_path, file_size, is_primary, dir_tree)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||
rusqlite::params![id, project_id, version, stored_path, archive_path, file_size, is_primary, dir_tree_json],
|
||
).map_err(|e| e.to_string())?;
|
||
|
||
// 更新项目 updated_at
|
||
let _ = conn.execute("UPDATE source_projects SET updated_at=datetime('now') WHERE id=?1", rusqlite::params![project_id]);
|
||
|
||
Ok(SourceVersion {
|
||
id,
|
||
project_id,
|
||
version,
|
||
file_path: stored_path,
|
||
archive_path,
|
||
file_size,
|
||
is_primary: is_primary != 0,
|
||
dir_tree: Some(dir_tree_json),
|
||
notes: None,
|
||
imported_at: None,
|
||
})
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn set_primary_version(version_id: String, project_id: String) -> Result<(), String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
conn.execute("UPDATE source_versions SET is_primary=0 WHERE project_id=?1", rusqlite::params![project_id])
|
||
.map_err(|e| e.to_string())?;
|
||
conn.execute("UPDATE source_versions SET is_primary=1 WHERE id=?1", rusqlite::params![version_id])
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn delete_source_version(version_id: String) -> Result<(), String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
let path: String = conn
|
||
.query_row("SELECT file_path FROM source_versions WHERE id=?1", rusqlite::params![version_id], |r| r.get(0))
|
||
.map_err(|e| e.to_string())?;
|
||
let p = Path::new(&path);
|
||
if p.is_dir() {
|
||
let _ = fs::remove_dir_all(p);
|
||
} else if p.is_file() {
|
||
let _ = fs::remove_file(p);
|
||
}
|
||
conn.execute("DELETE FROM source_versions WHERE id=?1", rusqlite::params![version_id])
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn update_version_notes(version_id: String, notes: String) -> Result<(), String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
conn.execute("UPDATE source_versions SET notes=?2 WHERE id=?1", rusqlite::params![version_id, notes])
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 在文件管理器中打开文件/目录所在位置
|
||
#[tauri::command]
|
||
pub fn reveal_source_in_explorer(path: String) -> Result<(), String> {
|
||
let p = Path::new(&path);
|
||
if !p.exists() {
|
||
return Err(format!("路径不存在: {}", path));
|
||
}
|
||
// 如果是文件,打开其父目录并选中;如果是目录,直接打开
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
if p.is_file() {
|
||
// explorer /select,"path" 会打开并选中文件
|
||
let _ = std::process::Command::new("explorer")
|
||
.arg("/select,")
|
||
.arg(&path)
|
||
.spawn();
|
||
} else {
|
||
let _ = std::process::Command::new("explorer")
|
||
.arg(&path)
|
||
.spawn();
|
||
}
|
||
}
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
if p.is_file() {
|
||
let _ = std::process::Command::new("open").arg("-R").arg(&path).spawn();
|
||
} else {
|
||
let _ = std::process::Command::new("open").arg(&path).spawn();
|
||
}
|
||
}
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
let dir = if p.is_file() { p.parent().unwrap_or(p) } else { p };
|
||
let _ = std::process::Command::new("xdg-open").arg(dir).spawn();
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 复制文件/目录到指定目标路径
|
||
#[tauri::command]
|
||
pub fn copy_source_to(source: String, dest_dir: String) -> Result<String, String> {
|
||
let src = Path::new(&source);
|
||
if !src.exists() {
|
||
return Err(format!("源路径不存在: {}", source));
|
||
}
|
||
let dest = Path::new(&dest_dir);
|
||
fs::create_dir_all(dest).map_err(|e| format!("创建目标目录失败: {}", e))?;
|
||
|
||
if src.is_file() {
|
||
let file_name = src.file_name().ok_or("无法获取文件名")?;
|
||
let dest_file = dest.join(file_name);
|
||
fs::copy(src, &dest_file).map_err(|e| format!("复制文件失败: {}", e))?;
|
||
Ok(dest_file.to_string_lossy().to_string())
|
||
} else {
|
||
// 复制整个目录
|
||
let dir_name = src.file_name().ok_or("无法获取目录名")?;
|
||
let dest_sub = dest.join(dir_name);
|
||
copy_dir_recursive(src, &dest_sub)?;
|
||
Ok(dest_sub.to_string_lossy().to_string())
|
||
}
|
||
}
|
||
|
||
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), String> {
|
||
fs::create_dir_all(dest).map_err(|e| format!("创建目录失败: {}", e))?;
|
||
for entry in fs::read_dir(src).map_err(|e| format!("读取目录失败: {}", e))? {
|
||
let entry = entry.map_err(|e| e.to_string())?;
|
||
let src_path = entry.path();
|
||
let dest_path = dest.join(entry.file_name());
|
||
if src_path.is_dir() {
|
||
copy_dir_recursive(&src_path, &dest_path)?;
|
||
} else {
|
||
fs::copy(&src_path, &dest_path).map_err(|e| format!("复制失败: {}", e))?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ── 目录树扫描 ───────────────────────────────────────────────────────────────
|
||
|
||
#[tauri::command]
|
||
pub fn scan_source_dir_tree(path: String) -> Result<Vec<DirEntry>, String> {
|
||
Ok(scan_dir_tree_internal(&path))
|
||
}
|
||
|
||
fn scan_dir_tree_internal(path: &str) -> Vec<DirEntry> {
|
||
let root = Path::new(path);
|
||
if !root.is_dir() {
|
||
return vec![];
|
||
}
|
||
|
||
let ignore_dirs = [
|
||
"node_modules", ".git", "target", "vendor", "__pycache__",
|
||
".next", ".nuxt", "dist", "build", ".idea", ".vscode",
|
||
"venv", ".venv", "env",
|
||
];
|
||
|
||
let mut entries: Vec<DirEntry> = Vec::new();
|
||
|
||
if let Ok(read_dir) = fs::read_dir(root) {
|
||
let mut items: Vec<_> = read_dir.filter_map(|e| e.ok()).collect();
|
||
items.sort_by(|a, b| {
|
||
let a_dir = a.path().is_dir();
|
||
let b_dir = b.path().is_dir();
|
||
b_dir.cmp(&a_dir).then_with(|| a.file_name().cmp(&b.file_name()))
|
||
});
|
||
|
||
for entry in items {
|
||
let name = entry.file_name().to_string_lossy().to_string();
|
||
let path = entry.path();
|
||
|
||
if path.is_dir() {
|
||
if ignore_dirs.contains(&name.as_str()) {
|
||
continue;
|
||
}
|
||
let children_count = fs::read_dir(&path)
|
||
.map(|rd| rd.count() as i32)
|
||
.unwrap_or(0);
|
||
entries.push(DirEntry {
|
||
name: format!("{}/", name),
|
||
type_: "dir".into(),
|
||
children_count: Some(children_count),
|
||
});
|
||
} else {
|
||
entries.push(DirEntry {
|
||
name,
|
||
type_: "file".into(),
|
||
children_count: None,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
entries
|
||
}
|
||
|
||
// ── README 提取 ──────────────────────────────────────────────────────────────
|
||
|
||
fn extract_readme_internal(path: &str) -> Option<String> {
|
||
let root = Path::new(path);
|
||
let candidates = ["README.md", "readme.md", "README.MD", "Readme.md", "README", "readme"];
|
||
for name in &candidates {
|
||
let file = root.join(name);
|
||
if file.is_file() {
|
||
if let Ok(content) = fs::read_to_string(&file) {
|
||
if content.len() > 50 * 1024 {
|
||
return Some(content[..50 * 1024].to_string());
|
||
}
|
||
return Some(content);
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
// ── 语言检测 ─────────────────────────────────────────────────────────────────
|
||
|
||
fn detect_language_internal(path: &str) -> Option<String> {
|
||
let root = Path::new(path);
|
||
let checks: &[(&str, &str)] = &[
|
||
("Cargo.toml", "Rust"),
|
||
("go.mod", "Go"),
|
||
("package.json", "TypeScript"),
|
||
("pom.xml", "Java"),
|
||
("build.gradle", "Java"),
|
||
("build.gradle.kts", "Kotlin"),
|
||
("requirements.txt", "Python"),
|
||
("setup.py", "Python"),
|
||
("pyproject.toml", "Python"),
|
||
("Gemfile", "Ruby"),
|
||
("composer.json", "PHP"),
|
||
("Package.swift", "Swift"),
|
||
("CMakeLists.txt", "C++"),
|
||
("Makefile", "C"),
|
||
];
|
||
|
||
for (file, lang) in checks {
|
||
if root.join(file).exists() {
|
||
if *file == "package.json" {
|
||
if root.join("tsconfig.json").exists() {
|
||
return Some("TypeScript".into());
|
||
}
|
||
return Some("JavaScript".into());
|
||
}
|
||
return Some(lang.to_string());
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
// ── 工具函数 ─────────────────────────────────────────────────────────────────
|
||
|
||
fn calc_dir_size(path: &str) -> i64 {
|
||
let mut total: u64 = 0;
|
||
if let Ok(entries) = walkdir(Path::new(path)) {
|
||
total = entries;
|
||
}
|
||
total as i64
|
||
}
|
||
|
||
fn walkdir(path: &Path) -> Result<u64, ()> {
|
||
let mut size = 0u64;
|
||
if path.is_dir() {
|
||
if let Ok(rd) = fs::read_dir(path) {
|
||
for entry in rd.flatten() {
|
||
let p = entry.path();
|
||
if p.is_dir() {
|
||
size += walkdir(&p).unwrap_or(0);
|
||
} else if let Ok(meta) = p.metadata() {
|
||
size += meta.len();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Ok(size)
|
||
}
|