dev-manager-tauri/src-tauri/src/commands/source_library.rs
lanrtop 9660bcbc48 feat: 服务器管理体系 + 云产品扩展 + 源码库 + API密钥管理
新增多个核心功能模块(后端 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 个新模块
2026-04-17 22:25:06 +09:00

531 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}