feat(deps): implement batch installation, manifest parsing/importing, and responsive mobile UI/UX improvements
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user