diff --git a/internal/controllers/dependency_controller.go b/internal/controllers/dependency_controller.go index 991acc5..1b4d085 100644 --- a/internal/controllers/dependency_controller.go +++ b/internal/controllers/dependency_controller.go @@ -6,6 +6,7 @@ import ( "github.com/engigu/baihu-panel/internal/models" "github.com/engigu/baihu-panel/internal/models/vo" "github.com/engigu/baihu-panel/internal/services" + "github.com/engigu/baihu-panel/internal/services/deps" "github.com/engigu/baihu-panel/internal/utils" "github.com/gin-gonic/gin" @@ -303,3 +304,96 @@ func (c *DependencyController) GetInstalled(ctx *gin.Context) { utils.Success(ctx, packages) } + +// GetBatchInstallCommand 获取批量安装依赖包的命令 +func (c *DependencyController) GetBatchInstallCommand(ctx *gin.Context) { + var req struct { + Items []struct { + Name string `json:"name" binding:"required"` + Version string `json:"version"` + Language string `json:"language" binding:"required"` + LangVersion string `json:"lang_version"` + } `json:"items" binding:"required,gt=0"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: items 不能为空且必须包含 name 和 language") + return + } + + var depsList []models.Dependency + for _, item := range req.Items { + depsList = append(depsList, models.Dependency{ + Name: item.Name, + Version: item.Version, + Language: item.Language, + LangVersion: item.LangVersion, + }) + } + + cmd, err := c.service.GetBatchInstallCommand(depsList) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"command": cmd}) +} + +// ParseAndImport 解析上传/粘贴的清单文件内容并批量导入至数据库 +func (c *DependencyController) ParseAndImport(ctx *gin.Context) { + var req struct { + Language string `json:"language" binding:"required"` + LangVersion string `json:"lang_version"` + Content string `json:"content" binding:"required"` + ImportDB bool `json:"import_db"` // 是否持久化到数据库做可视化管理 + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: language 和 content 必填") + return + } + + // 1. 解析文本清单内容 + parsedDeps, err := deps.ParseManifest(req.Language, req.Content) + if err != nil { + utils.ServerError(ctx, "清单文件解析失败: "+err.Error()) + return + } + + if len(parsedDeps) == 0 { + utils.BadRequest(ctx, "未解析到任何有效依赖包") + return + } + + // 2. 补全语言和版本属性 + for i := range parsedDeps { + parsedDeps[i].Language = req.Language + parsedDeps[i].LangVersion = req.LangVersion + } + + // 3. 根据需求决定是否导入数据库 + var finalDeps []models.Dependency + if req.ImportDB { + imported, err := c.service.ImportDependencies(parsedDeps) + if err != nil { + utils.ServerError(ctx, "导入依赖记录至数据库失败: "+err.Error()) + return + } + finalDeps = imported + } else { + finalDeps = parsedDeps + } + + // 4. 为这一批包生成合并批量安装命令 + cmd, err := c.service.GetBatchInstallCommand(finalDeps) + if err != nil { + utils.ServerError(ctx, "生成安装命令失败: "+err.Error()) + return + } + + utils.Success(ctx, gin.H{ + "dependencies": vo.ToDependencyVOListFromModels(finalDeps), + "command": cmd, + }) +} diff --git a/internal/router/api_routes.go b/internal/router/api_routes.go index e0dd5cc..6b716db 100644 --- a/internal/router/api_routes.go +++ b/internal/router/api_routes.go @@ -193,6 +193,8 @@ func registerDependencyRoutes(g *gin.RouterGroup, c *Controllers) { deps.POST("/reinstall/:id", c.Dependency.Reinstall) deps.POST("/reinstall-all", c.Dependency.ReinstallAll) deps.POST("/reinstall-all-cmd", c.Dependency.GetReinstallAllCommand) + deps.POST("/batch-install-cmd", c.Dependency.GetBatchInstallCommand) + deps.POST("/import", c.Dependency.ParseAndImport) deps.GET("/installed", c.Dependency.GetInstalled) } } diff --git a/internal/services/dependency_service.go b/internal/services/dependency_service.go index dc9ef77..58b0fb6 100644 --- a/internal/services/dependency_service.go +++ b/internal/services/dependency_service.go @@ -111,3 +111,30 @@ func (s *DependencyService) GetVerifyCommand(language, langVersion string) (stri } return m.GetVerifyCommand(langVersion) } + +// GetBatchInstallCommand 获取批量安装命令 +func (s *DependencyService) GetBatchInstallCommand(depsList []models.Dependency) (string, error) { + if len(depsList) == 0 { + return "", errors.New("依赖包列表不能为空") + } + + firstDep := depsList[0] + m := deps.GetManager(firstDep.Language) + if m == nil { + return "", errors.New("不支持的依赖类型: " + firstDep.Language) + } + + return m.GetBatchInstallCommand(depsList) +} + +// ImportDependencies 批量导入依赖并自动入库去重 +func (s *DependencyService) ImportDependencies(depsList []models.Dependency) ([]models.Dependency, error) { + var imported []models.Dependency + for i := range depsList { + dep := &depsList[i] + if err := s.Create(dep); err == nil { + imported = append(imported, *dep) + } + } + return imported, nil +} diff --git a/internal/services/deps/manager.go b/internal/services/deps/manager.go index f8db8e2..0f44b8c 100644 --- a/internal/services/deps/manager.go +++ b/internal/services/deps/manager.go @@ -16,6 +16,7 @@ type Manager interface { Uninstall(dep *models.Dependency) error GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) GetInstallCommand(dep *models.Dependency) (string, error) + GetBatchInstallCommand(deps []models.Dependency) (string, error) GetReinstallAllCommand(deps []models.Dependency) (string, error) GetVerifyCommand(langVersion string) (string, error) } @@ -74,6 +75,33 @@ func (m *BaseManager) GetInstallCommand(dep *models.Dependency) (string, error) return fullCmd + " && echo \"__INSTALL_SUCCESS__\" || echo \"__INSTALL_FAILED__\"", nil } +func (m *BaseManager) GetBatchInstallCommand(deps []models.Dependency) (string, error) { + if len(deps) == 0 { + return "echo \"没有需要安装的依赖\"", nil + } + + var packageSpecs []string + var langVersion string + var language string + for _, dep := range deps { + language = dep.Language + if dep.LangVersion != "" { + langVersion = dep.LangVersion + } + if dep.Version != "" { + packageSpecs = append(packageSpecs, dep.Name+m.Separator+dep.Version) + } else { + packageSpecs = append(packageSpecs, dep.Name) + } + } + + args := append([]string{}, m.InstallCmd...) + args = append(args, packageSpecs...) + + fullCmd := utils.BuildMiseCommandSimple(strings.Join(args, " "), language, langVersion) + return fullCmd + " && echo \"__INSTALL_SUCCESS__\" || echo \"__INSTALL_FAILED__\"", nil +} + func (m *BaseManager) GetReinstallAllCommand(deps []models.Dependency) (string, error) { if len(deps) == 0 { return "echo \"没有需要安装的依赖\"", nil diff --git a/internal/services/deps/parser.go b/internal/services/deps/parser.go new file mode 100644 index 0000000..199468b --- /dev/null +++ b/internal/services/deps/parser.go @@ -0,0 +1,105 @@ +package deps + +import ( + "encoding/json" + "regexp" + "strings" + + "github.com/engigu/baihu-panel/internal/models" +) + +// ParseManifest 根据语言解析依赖清单文件内容 +func ParseManifest(language, content string) ([]models.Dependency, error) { + lang := strings.ToLower(language) + if strings.Contains(lang, "python") { + return ParseRequirements(content), nil + } + if strings.Contains(lang, "node") { + return ParsePackageJson(content) + } + return []models.Dependency{}, nil +} + +// ParseRequirements 解析 Python requirements.txt +func ParseRequirements(content string) []models.Dependency { + var deps []models.Dependency + // 使用正则表达式按行分割,兼容 Windows 和 Linux 的换行符 + lines := regexp.MustCompile(`\r?\n`).Split(content, -1) + + // 用于分割包名和版本的正则 (支持 ==, >=, <=, ~=, >, <, @) + versionRegex := regexp.MustCompile(`[=><~@]+`) + + for _, line := range lines { + line = strings.TrimSpace(line) + // 忽略空行、注释行以及参数行 (以 - 开头的行如 -i, -r) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { + continue + } + + // 分割名称与版本 + parts := versionRegex.Split(line, 2) + name := strings.TrimSpace(parts[0]) + version := "" + if len(parts) > 1 { + // 清除可能存在的后续参数,比如 requests==2.31.0 --hash=sha256:... + versionPart := strings.TrimSpace(parts[1]) + + // 如果有逗号分隔的多个范围限制,比如 >=1.20,<2.0,只取第一个范围作为参考版本号 + if idx := strings.Index(versionPart, ","); idx != -1 { + versionPart = versionPart[:idx] + } + + versionFields := strings.Fields(versionPart) + if len(versionFields) > 0 { + version = strings.TrimSpace(versionFields[0]) + // 清除可能残留的首部版本符号 + version = strings.TrimLeft(version, "=><~@ ") + } + } + + if name != "" { + deps = append(deps, models.Dependency{ + Name: name, + Version: version, + Language: "python3", + }) + } + } + return deps +} + +// PackageJson 代表 package.json 的结构定义 +type PackageJson struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` +} + +// ParsePackageJson 解析 Node.js package.json +func ParsePackageJson(content string) ([]models.Dependency, error) { + var pkg PackageJson + if err := json.Unmarshal([]byte(content), &pkg); err != nil { + return nil, err + } + + var deps []models.Dependency + collect := func(m map[string]string, isDev bool) { + for name, versionRange := range m { + // 移除 npm 常见版本范围修饰符(如 ^1.2.3 或 ~2.3.0,保留底线版本号) + version := strings.TrimLeft(versionRange, "^~>=<* ") + remark := "" + if isDev { + remark = "devDependencies" + } + deps = append(deps, models.Dependency{ + Name: name, + Version: version, + Language: "node", + Remark: remark, + }) + } + } + + collect(pkg.Dependencies, false) + collect(pkg.DevDependencies, true) + return deps, nil +} diff --git a/internal/services/deps/parser_test.go b/internal/services/deps/parser_test.go new file mode 100644 index 0000000..d584ebe --- /dev/null +++ b/internal/services/deps/parser_test.go @@ -0,0 +1,87 @@ +package deps + +import ( + "testing" +) + +func TestParseRequirements(t *testing.T) { + content := ` +# This is a comment +requests==2.31.0 +numpy>=1.20,<2.0 +gunicorn +-r other-requirements.txt + pandas ~= 1.3.0 +` + deps := ParseRequirements(content) + if len(deps) != 4 { + t.Fatalf("expected 4 dependencies, got %d", len(deps)) + } + + expected := []struct { + name string + version string + }{ + {"requests", "2.31.0"}, + {"numpy", "1.20"}, + {"gunicorn", ""}, + {"pandas", "1.3.0"}, + } + + for i, exp := range expected { + if deps[i].Name != exp.name { + t.Errorf("expected name %s, got %s", exp.name, deps[i].Name) + } + if deps[i].Version != exp.version { + t.Errorf("expected version %s, got %s", exp.version, deps[i].Version) + } + if deps[i].Language != "python3" { + t.Errorf("expected language python3, got %s", deps[i].Language) + } + } +} + +func TestParsePackageJson(t *testing.T) { + content := `{ + "dependencies": { + "lodash": "^4.17.21", + "express": "~4.18.2" + }, + "devDependencies": { + "typescript": "^5.0.4" + } +}` + deps, err := ParsePackageJson(content) + if err != nil { + t.Fatalf("failed to parse package.json: %v", err) + } + + if len(deps) != 3 { + t.Fatalf("expected 3 dependencies, got %d", len(deps)) + } + + expected := []struct { + name string + version string + remark string + }{ + {"lodash", "4.17.21", ""}, + {"express", "4.18.2", ""}, + {"typescript", "5.0.4", "devDependencies"}, + } + + for i, exp := range expected { + if deps[i].Name != exp.name { + t.Errorf("expected name %s, got %s", exp.name, deps[i].Name) + } + if deps[i].Version != exp.version { + t.Errorf("expected version %s, got %s", exp.version, deps[i].Version) + } + if deps[i].Remark != exp.remark { + t.Errorf("expected remark %s, got %s", exp.remark, deps[i].Remark) + } + if deps[i].Language != "node" { + t.Errorf("expected language node, got %s", deps[i].Language) + } + } +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 8aea150..76098cc 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -266,6 +266,10 @@ export const api = { if (lang_version) query.set('lang_version', lang_version) return request<{ command: string }>(`/deps/reinstall-all-cmd?${query}`, { method: 'POST' }) }, + getBatchInstallCmd: (data: { items: { name: string; version?: string; language: string; lang_version?: string }[] }) => + request<{ command: string }>('/deps/batch-install-cmd', { method: 'POST', body: JSON.stringify(data) }), + import: (data: { language: string; lang_version?: string; content: string; import_db?: boolean }) => + request<{ dependencies: Dependency[]; command: string }>('/deps/import', { method: 'POST', body: JSON.stringify(data) }), getInstalled: (language: string, lang_version?: string) => { const query = new URLSearchParams({ language }) if (lang_version) query.set('lang_version', lang_version) diff --git a/web/src/views/dependencies/Dependencies.vue b/web/src/views/dependencies/Dependencies.vue index 1dbc72f..37e91e4 100644 --- a/web/src/views/dependencies/Dependencies.vue +++ b/web/src/views/dependencies/Dependencies.vue @@ -7,11 +7,13 @@ import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog' -import { Trash2, Package, Search, RefreshCw, Loader2, Download, FileText, RotateCw, ChevronLeft } from 'lucide-vue-next' +import { Trash2, Package, Search, RefreshCw, Loader2, Download, FileText, RotateCw, ChevronLeft, FileUp, Terminal as TerminalIcon } from 'lucide-vue-next' import { api, type Dependency } from '@/api' import TextOverflow from '@/components/TextOverflow.vue' import { Checkbox } from '@/components/ui/checkbox' +import { Textarea } from '@/components/ui/textarea' import { toast } from 'vue-sonner' +import XTerminal from '@/components/XTerminal.vue' const route = useRoute() const language = computed(() => route.query.language as string || '') @@ -25,6 +27,9 @@ const reinstalling = ref(null) const reinstallingAll = ref(false) const installedLangs = ref([]) +// 批量选择状态 +const selectedDeps = ref([]) + // 安装对话框 const showInstallDialog = ref(false) const newPkgName = ref('') @@ -36,6 +41,7 @@ const showDeleteDialog = ref(false) const isForce = ref(false) const depToDelete = ref(null) +// 详情/日志对话框 const showLogDialog = ref(false) const logContent = ref('') const logPkgName = ref('') @@ -49,7 +55,30 @@ const filteredDeps = computed(() => { const q = searchQuery.value.toLowerCase() return list.filter(d => d.name.toLowerCase().includes(q)) }) +// 批量选择逻辑 +const isAllSelected = computed(() => { + const list = filteredDeps.value + if (list.length === 0) return false + return list.every(d => selectedDeps.value.includes(d.id)) +}) +function handleSelectAll(checked: boolean) { + if (checked) { + selectedDeps.value = filteredDeps.value.map(d => d.id) + } else { + selectedDeps.value = [] + } +} + +function toggleSelect(id: string, checked: boolean) { + if (checked) { + if (!selectedDeps.value.includes(id)) { + selectedDeps.value.push(id) + } + } else { + selectedDeps.value = selectedDeps.value.filter(item => item !== id) + } +} async function loadDeps() { loading.value = true try { @@ -148,7 +177,7 @@ async function uninstallPackage() { } }, actionButtonStyle: { - backgroundColor: '#ef4444', // text-red-500 equivalent for background + backgroundColor: '#ef4444', color: 'white' } }) @@ -198,6 +227,94 @@ async function reinstallAll() { } } +// 终端与批量安装控制 +const showTerminalDialog = ref(false) +const terminalCommand = ref('') +const batchInstalling = ref(false) + +function openTerminalWithCommand(command: string) { + terminalCommand.value = command + showTerminalDialog.value = true +} + +function onTerminalSuccess() { + toast.success('安装指令执行成功') + loadDeps() +} + +function onTerminalFailed() { + toast.error('安装指令执行失败,请检查终端输出') + loadDeps() +} + +async function startBatchInstall() { + if (selectedDeps.value.length === 0) return + + const selectedPackages = deps.value.filter(d => selectedDeps.value.includes(d.id)) + if (selectedPackages.length === 0) return + + batchInstalling.value = true + try { + const reqItems = selectedPackages.map(d => ({ + name: d.name, + version: d.version || undefined, + language: d.language, + lang_version: d.lang_version || undefined + })) + + const res = await api.deps.getBatchInstallCmd({ items: reqItems }) + if (res.command) { + selectedDeps.value = [] // 重置选择 + openTerminalWithCommand(res.command) + } + } catch (e: any) { + toast.error('生成批量安装命令失败: ' + e.message) + } finally { + batchInstalling.value = false + } +} + +// 清单导入 +const showImportDialog = ref(false) +const manifestContent = ref('') +const importDb = ref(true) +const importingManifest = ref(false) + +function openImportDialog() { + manifestContent.value = '' + importDb.value = true + showImportDialog.value = true +} + +async function handleImportManifest() { + if (!manifestContent.value.trim()) { + toast.error('请输入描述清单内容') + return + } + + importingManifest.value = true + try { + const res = await api.deps.import({ + language: language.value || activeTab.value, + lang_version: langVersion.value || undefined, + content: manifestContent.value.trim(), + import_db: importDb.value + }) + + toast.success('依赖清单解析完成') + showImportDialog.value = false + + if (res.command) { + openTerminalWithCommand(res.command) + } + } catch (e: any) { + toast.error('导入描述清单失败: ' + e.message) + } finally { + importingManifest.value = false + await loadDeps() + } +} + function getTypeLabel(type: string) { const labels: Record = { python: 'Python', @@ -221,7 +338,10 @@ function getTypeLabel(type: string) { return labels[type] || type.charAt(0).toUpperCase() + type.slice(1) } -watch(activeTab, loadDeps) +watch(activeTab, () => { + selectedDeps.value = [] + loadDeps() +}) // 如果 URL 中带了环境参数,自动切 Tab onMounted(async () => { @@ -232,7 +352,7 @@ onMounted(async () => {