feat(deps): implement batch installation, manifest parsing/importing, and responsive mobile UI/UX improvements

This commit is contained in:
duorameng
2026-05-18 19:24:45 +08:00
parent 9b4826bbee
commit a31c4b9f03
9 changed files with 622 additions and 39 deletions
@@ -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,
})
}
+2
View File
@@ -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)
}
}
+27
View File
@@ -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
}
+28
View File
@@ -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
+105
View File
@@ -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
}
+87
View File
@@ -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)
}
}
}
+4
View File
@@ -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)
+273 -37
View File
@@ -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<string | null>(null)
const reinstallingAll = ref(false)
const installedLangs = ref<string[]>([])
// 批量选择状态
const selectedDeps = ref<string[]>([])
// 安装对话框
const showInstallDialog = ref(false)
const newPkgName = ref('')
@@ -36,6 +41,7 @@ const showDeleteDialog = ref(false)
const isForce = ref(false)
const depToDelete = ref<Dependency | null>(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<string, string> = {
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 () => {
</script>
<template>
<div class="space-y-4">
<div class="space-y-4 relative pb-16">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="flex items-center gap-3">
<Button v-if="language" variant="ghost" size="icon" @click="$router.back()" class="h-8 w-8">
@@ -258,43 +378,57 @@ onMounted(async () => {
<div class="mt-4">
<div class="rounded-lg border bg-card overflow-x-auto">
<!-- 工具栏 -->
<div class="flex items-center justify-between gap-3 px-3 sm:px-4 py-2 sm:py-3 border-b bg-muted/10">
<div class="flex items-center gap-2 shrink-0">
<Badge variant="secondary" class="h-6 sm:h-7 px-2 text-[11px] sm:text-xs bg-primary/10 text-primary border-primary/20">{{ filteredDeps.length }} 个包</Badge>
</div>
<div class="flex items-center gap-2 flex-1 justify-end">
<div class="relative flex-1 max-w-[200px]">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 px-3 sm:px-4 py-2.5 sm:py-3 border-b bg-muted/10">
<!-- 第一行包数和搜索框 -->
<div class="flex items-center justify-between gap-3 w-full sm:w-auto">
<Badge variant="secondary" class="h-7 px-2 text-[11px] sm:text-xs bg-primary/10 text-primary border-primary/20 shrink-0">{{ filteredDeps.length }} 个包</Badge>
<div class="relative flex-1 sm:flex-none max-w-[180px] sm:w-[200px]">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input v-model="searchQuery" placeholder="搜索包名..." class="h-9 pl-9 w-full text-sm bg-background focus:bg-background transition-all" />
</div>
<div class="flex items-center gap-1.5 sm:gap-2 shrink-0">
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0 shadow-sm" @click="loadDeps" :disabled="loading">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
</Button>
<Button variant="outline" size="sm" class="h-9 px-3 text-sm shrink-0 shadow-sm" @click="reinstallAll"
:disabled="reinstallingAll || filteredDeps.length === 0">
<RotateCw class="h-4 w-4 sm:mr-1.5" :class="{ 'animate-spin': reinstallingAll }" /> <span
class="hidden sm:inline">全部重装</span>
</Button>
<Button size="sm" class="h-9 px-3 text-sm shrink-0 shadow-sm" @click="openInstallDialog">
<Download class="h-4 w-4 sm:mr-1.5" /> <span class="hidden sm:inline">安装包</span>
</Button>
</div>
</div>
<!-- 第二行操作按钮 -->
<div class="flex items-center gap-1.5 sm:gap-2 justify-end w-full sm:w-auto overflow-x-auto pb-0.5 sm:pb-0 scrollbar-none">
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0 shadow-sm" @click="loadDeps" :disabled="loading">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
</Button>
<Button variant="outline" size="sm" class="h-9 px-2.5 sm:px-3 text-xs sm:text-sm shrink-0 shadow-sm" @click="reinstallAll"
:disabled="reinstallingAll || filteredDeps.length === 0">
<RotateCw class="h-3.5 w-3.5 mr-1" :class="{ 'animate-spin': reinstallingAll }" />
<span>全部重装</span>
</Button>
<Button variant="outline" size="sm" class="h-9 px-2.5 sm:px-3 text-xs sm:text-sm shrink-0 shadow-sm" @click="openImportDialog"
:disabled="activeTab !== 'python' && activeTab !== 'node'">
<FileUp class="h-3.5 w-3.5 mr-1" />
<span>导入清单</span>
</Button>
<Button size="sm" class="h-9 px-2.5 sm:px-3 text-xs sm:text-sm shrink-0 shadow-sm font-semibold" @click="openInstallDialog">
<Download class="h-3.5 w-3.5 mr-1" />
<span>安装包</span>
</Button>
</div>
</div>
<!-- 表头 -->
<div
class="flex items-center gap-4 px-4 h-10 border-b bg-muted/20 text-xs text-muted-foreground font-bold uppercase tracking-wider min-w-[400px]">
<span class="w-12 shrink-0 pl-1">序号</span>
class="flex items-center gap-2 sm:gap-4 px-3 sm:px-4 h-10 border-b bg-muted/20 text-xs text-muted-foreground font-bold uppercase tracking-wider">
<div class="w-6 sm:w-8 shrink-0 flex items-center justify-center">
<input
type="checkbox"
:checked="isAllSelected"
@change="(e) => handleSelectAll((e.target as HTMLInputElement).checked)"
class="appearance-none size-4 shrink-0 rounded-[5px] border border-zinc-300 dark:border-zinc-700/60 bg-zinc-50/50 dark:bg-zinc-900/30 hover:border-primary/60 checked:border-primary checked:bg-gradient-to-br checked:from-primary checked:to-indigo-600 checked:bg-[url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%223.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%2220%206%209%2017%204%2012%22%2F%3E%3C%2Fsvg%3E')] checked:bg-[size:10px_10px] checked:bg-center checked:bg-no-repeat focus:outline-none focus:ring-2 focus:ring-primary/20 checked:shadow-[0_2px_8px_rgba(99,102,241,0.35)] checked:scale-[1.05] active:scale-95 transition-all duration-200 cursor-pointer ease-out"
/>
</div>
<span class="w-12 hidden sm:block shrink-0 pl-1">序号</span>
<span class="flex-1">包名</span>
<span class="w-24 shrink-0 px-2">版本</span>
<span class="w-16 sm:w-24 shrink-0 px-1 sm:px-2">版本</span>
<span class="w-48 hidden md:block shrink-0 px-2 font-medium">备注说明</span>
<span class="w-32 text-center shrink-0">操作</span>
<span class="w-20 sm:w-32 text-center shrink-0">操作</span>
</div>
<!-- 列表 -->
<div class="divide-y max-h-[480px] overflow-y-auto min-w-[400px]">
<div class="divide-y max-h-[480px] overflow-y-auto">
<div v-if="loading" class="text-center py-8 text-muted-foreground">
<Loader2 class="h-5 w-5 animate-spin mx-auto mb-2" />
加载中...
@@ -304,27 +438,36 @@ onMounted(async () => {
{{ searchQuery ? '无匹配结果' : '暂无依赖包' }}
</div>
<div v-else v-for="(dep, index) in filteredDeps" :key="dep.id"
class="flex items-center gap-4 px-4 h-10 hover:bg-muted/30 transition-colors group">
<div class="w-12 shrink-0 text-[11px] text-muted-foreground tabular-nums">#{{ filteredDeps.length - index }}</div>
<span class="flex-1 font-mono text-[13px] truncate font-medium">
class="flex items-center gap-2 sm:gap-4 px-3 sm:px-4 h-10 hover:bg-muted/30 transition-colors group"
:class="{ 'bg-primary/5': selectedDeps.includes(dep.id) }">
<div class="w-6 sm:w-8 shrink-0 flex items-center justify-center">
<input
type="checkbox"
:checked="selectedDeps.includes(dep.id)"
@change="(e) => toggleSelect(dep.id, (e.target as HTMLInputElement).checked)"
class="appearance-none size-4 shrink-0 rounded-[5px] border border-zinc-300 dark:border-zinc-700/60 bg-zinc-50/50 dark:bg-zinc-900/30 hover:border-primary/60 checked:border-primary checked:bg-gradient-to-br checked:from-primary checked:to-indigo-600 checked:bg-[url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%223.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%2220%206%209%2017%204%2012%22%2F%3E%3C%2Fsvg%3E')] checked:bg-[size:10px_10px] checked:bg-center checked:bg-no-repeat focus:outline-none focus:ring-2 focus:ring-primary/20 checked:shadow-[0_2px_8px_rgba(99,102,241,0.35)] checked:scale-[1.05] active:scale-95 transition-all duration-200 cursor-pointer ease-out"
/>
</div>
<div class="w-12 hidden sm:block shrink-0 text-[11px] text-muted-foreground tabular-nums pl-1">#{{ filteredDeps.length - index }}</div>
<span class="flex-1 font-mono text-[12px] sm:text-[13px] truncate font-medium min-w-0">
<TextOverflow :text="dep.name" title="包名" />
</span>
<span class="w-24 shrink-0 px-2 text-[12px] text-muted-foreground font-mono">{{ dep.version || '-' }}</span>
<span class="w-16 sm:w-24 shrink-0 px-1 sm:px-2 text-[11px] sm:text-[12px] text-muted-foreground font-mono truncate">{{ dep.version || '-' }}</span>
<span class="w-48 text-[12px] text-muted-foreground truncate hidden md:block shrink-0 px-2">
<TextOverflow :text="dep.remark || '-'" title="备注" />
</span>
<span class="w-32 flex justify-center gap-1 shrink-0 opacity-80 group-hover:opacity-100">
<span class="w-20 sm:w-32 flex justify-center gap-0.5 sm:gap-1 shrink-0 opacity-80 group-hover:opacity-100">
<Button v-if="dep.log || dep.id" variant="ghost" size="icon"
class="h-7 w-7 text-blue-500 hover:text-blue-600 hover:bg-blue-50/10" @click="showLog(dep)"
class="h-6 w-6 sm:h-7 sm:w-7 text-blue-500 hover:text-blue-600 hover:bg-blue-50/10" @click="showLog(dep)"
title="查看安装日志">
<FileText class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon"
class="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50/10" @click="reinstallPackage(dep)"
class="h-6 w-6 sm:h-7 sm:w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50/10" @click="reinstallPackage(dep)"
:disabled="reinstalling === dep.id" title="重新安装">
<RotateCw class="h-3.5 w-3.5" :class="{ 'animate-spin': reinstalling === dep.id }" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive hover:bg-destructive/10"
<Button variant="ghost" size="icon" class="h-6 w-6 sm:h-7 sm:w-7 text-destructive hover:bg-destructive/10"
@click="confirmDelete(dep)" title="卸载并删除记录">
<Trash2 class="h-3.5 w-3.5" />
</Button>
@@ -333,7 +476,27 @@ onMounted(async () => {
</div>
</div>
<!-- 安装对话框 -->
<!-- 批量操作工具栏 -->
<transition name="fade-slide">
<div v-if="selectedDeps.length > 1"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-background/90 backdrop-blur-md border border-primary/20 shadow-xl rounded-full px-4 sm:px-6 py-2 sm:py-2.5 flex items-center justify-between gap-2.5 sm:gap-4 w-[calc(100%-2rem)] sm:w-auto max-w-md transition-all duration-300">
<span class="text-xs sm:text-sm font-medium text-foreground whitespace-nowrap">
已选 <span class="text-primary font-bold">{{ selectedDeps.length }}</span> 个包
</span>
<div class="flex items-center gap-1 sm:gap-2">
<Button size="sm" class="rounded-full shadow-sm h-8 sm:h-9 text-xs sm:text-sm px-3" @click="startBatchInstall" :disabled="batchInstalling">
<Loader2 v-if="batchInstalling" class="h-3.5 w-3.5 mr-1 animate-spin" />
<Download v-else class="h-3.5 w-3.5 mr-1" />
批量安装
</Button>
<Button variant="ghost" size="sm" class="rounded-full h-8 px-2 sm:px-3 text-xs text-muted-foreground hover:text-foreground" @click="selectedDeps = []">
取消
</Button>
</div>
</div>
</transition>
<!-- 安装单个包对话框 -->
<Dialog v-model:open="showInstallDialog">
<DialogContent class="sm:max-w-[400px]" @openAutoFocus.prevent>
<DialogHeader>
@@ -366,6 +529,67 @@ onMounted(async () => {
</DialogContent>
</Dialog>
<!-- 导入描述清单文件对话框 -->
<Dialog v-model:open="showImportDialog">
<DialogContent class="sm:max-w-[500px]" @openAutoFocus.prevent>
<DialogHeader>
<DialogTitle>导入依赖描述清单</DialogTitle>
<DialogDescription>
支持粘贴 Python requirements.txt Node.js package.json 的文本内容进行解析
</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-2">
<div class="flex flex-col gap-2">
<Label class="text-sm font-semibold">清单内容</Label>
<Textarea v-model="manifestContent" rows="10"
class="text-xs font-mono bg-muted/40 placeholder:font-sans placeholder:text-muted-foreground resize-none"
placeholder="在此粘贴 requirements.txt 或 package.json 文本..." />
</div>
<div class="flex items-center gap-2">
<Checkbox id="importDb" v-model:checked="importDb" />
<Label for="importDb" class="text-xs font-medium cursor-pointer select-none">
将解析出的包导入至面板列表中进行可视化生命周期维护
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showImportDialog = false">取消</Button>
<Button @click="handleImportManifest" :disabled="importingManifest">
<Loader2 v-if="importingManifest" class="h-4 w-4 mr-2 animate-spin" />
<FileUp v-else class="h-4 w-4 mr-2" />
解析并执行安装
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 实时终端日志会话对话框 -->
<Dialog v-model:open="showTerminalDialog" :closeOnOutsideClick="false">
<DialogContent class="sm:max-w-[700px] h-[480px] flex flex-col p-4 bg-[#1e1e1e] border-zinc-800" @openAutoFocus.prevent>
<DialogHeader class="shrink-0 border-b border-zinc-800 pb-2 mb-2">
<DialogTitle class="flex items-center gap-2 text-zinc-100">
<TerminalIcon class="h-5 w-5 text-primary" />
实时依赖安装控制台
</DialogTitle>
<DialogDescription class="text-zinc-400">
后台正在使用隔离环境运行安装指令可实时查看流式输出日志
</DialogDescription>
</DialogHeader>
<!-- Xterm.js 容器 -->
<div class="flex-1 bg-[#1e1e1e] rounded-lg overflow-hidden border border-zinc-800 relative">
<XTerminal v-if="showTerminalDialog" :initialCommand="terminalCommand"
@success="onTerminalSuccess" @failed="onTerminalFailed" />
</div>
<DialogFooter class="shrink-0 pt-2 border-t border-zinc-800 mt-2">
<Button variant="outline" size="sm" @click="showTerminalDialog = false" class="border-zinc-800 text-zinc-300 hover:bg-zinc-800">
关闭控制台
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 卸载确认 -->
<AlertDialog v-model:open="showDeleteDialog">
<AlertDialogContent>
@@ -410,3 +634,15 @@ onMounted(async () => {
</div>
</div>
</template>
<style scoped>
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.fade-slide-enter-from,
.fade-slide-leave-to {
transform: translate(-50%, 1.5rem) scale(0.95);
opacity: 0;
}
</style>
+2 -2
View File
@@ -346,11 +346,11 @@ onMounted(() => {
<p class="text-muted-foreground text-sm mt-0.5">管理编程语言及相关包依赖(Mise)</p>
</div>
<div class="flex flex-row items-center gap-2 shrink-0 w-full md:w-auto overflow-x-auto pb-1 sm:pb-0">
<div class="flex flex-row items-center justify-start sm:justify-end gap-2 shrink-0 w-full md:w-auto overflow-x-auto pb-1 sm:pb-0">
<Button @click="openInstallDialog" class="flex-1 sm:flex-none h-9 text-sm px-2 sm:px-4">
<Plus class="h-3.5 w-3.5 sm:mr-2" /> <span class="whitespace-nowrap">新增语言</span>
</Button>
<Tabs v-model="activeTab" class="flex-1 sm:w-auto">
<Tabs v-model="activeTab" class="flex-1 sm:flex-none">
<TabsList class="h-9 p-0.5 bg-muted/20 border border-border/40 rounded-lg w-full sm:w-auto flex">
<TabsTrigger value="runtimes" class="px-3 h-8 text-xs gap-1.5 font-medium transition-all flex-1 sm:flex-none">
<Boxes class="w-3.5 h-3.5 opacity-70" />