feat: add mirror settings #29

This commit is contained in:
engigu
2026-03-18 10:53:41 +08:00
parent 7190cca30b
commit a03800f196
5 changed files with 359 additions and 100 deletions
+45
View File
@@ -117,3 +117,48 @@ func (c *MiseController) UnsetGlobal(ctx *gin.Context) {
}
utils.Success(ctx, nil)
}
// Envs 获取全局环境变量
func (c *MiseController) Envs(ctx *gin.Context) {
envs, err := c.service.Envs()
if err != nil {
utils.ServerError(ctx, "获取全局环境变量失败: "+err.Error())
return
}
utils.Success(ctx, envs)
}
// SetEnv 设置全局环境变量
func (c *MiseController) SetEnv(ctx *gin.Context) {
var req struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.BadRequest(ctx, "参数错误: "+err.Error())
return
}
if req.Key == "" {
utils.BadRequest(ctx, "参数 key 不能为空")
return
}
if err := c.service.SetEnv(req.Key, req.Value); err != nil {
utils.ServerError(ctx, "设置环境变量失败: "+err.Error())
return
}
utils.Success(ctx, nil)
}
// UnsetEnv 取消全局环境变量
func (c *MiseController) UnsetEnv(ctx *gin.Context) {
key := ctx.Query("key")
if key == "" {
utils.BadRequest(ctx, "参数 key 不能为空")
return
}
if err := c.service.UnsetEnv(key); err != nil {
utils.ServerError(ctx, "取消环境变量失败: "+err.Error())
return
}
utils.Success(ctx, nil)
}
+3
View File
@@ -212,6 +212,9 @@ func registerMiseRoutes(g *gin.RouterGroup, c *Controllers) {
mise.GET("/verify-cmd", c.Mise.VerifyCommand)
mise.POST("/use-global", c.Mise.UseGlobal)
mise.POST("/unset-global", c.Mise.UnsetGlobal)
mise.GET("/envs", c.Mise.Envs)
mise.POST("/envs", c.Mise.SetEnv)
mise.DELETE("/envs", c.Mise.UnsetEnv)
}
}
+47
View File
@@ -359,3 +359,50 @@ func (s *MiseService) UnsetGlobal(plugin, version string) error {
}
return nil
}
// Envs 获取全局环境变量
func (s *MiseService) Envs() (map[string]string, error) {
cmd := exec.Command("mise", "set")
cmd.Env = os.Environ()
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("mise set failed: %v, output: %s", err, string(output))
}
envs := make(map[string]string)
lines := strings.Split(string(output), "\n")
for _, line := range lines {
fields := strings.Fields(line)
// 期望格式: KEY VALUE SOURCE
// 跳过标题行或不合规行
if len(fields) < 3 || strings.ToLower(fields[0]) == "key" {
continue
}
// 仅显示来自全局配置文件的环境变量
if strings.Contains(strings.ToLower(fields[len(fields)-1]), "config.toml") {
envs[fields[0]] = fields[1]
}
}
return envs, nil
}
// SetEnv 设置全局环境变量
func (s *MiseService) SetEnv(key, value string) error {
cmd := exec.Command("mise", "set", "-g", fmt.Sprintf("%s=%s", key, value))
cmd.Env = os.Environ()
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("mise set -g failed: %v, output: %s", err, string(output))
}
return nil
}
// UnsetEnv 取消全局环境变量
func (s *MiseService) UnsetEnv(key string) error {
cmd := exec.Command("mise", "unset", "-g", key)
cmd.Env = os.Environ()
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("mise unset -g failed: %v, output: %s", err, string(output))
}
return nil
}
+4 -1
View File
@@ -269,7 +269,10 @@ export const api = {
versions: (plugin: string) => request<string[]>(`/mise/versions?plugin=${plugin}`),
verifyCommand: (plugin: string, version: string) => request<{ command: string }>(`/mise/verify-cmd?plugin=${plugin}&version=${version}`),
useGlobal: (plugin: string, version: string) => request<void>('/mise/use-global', { method: 'POST', body: JSON.stringify({ plugin, version }) }),
unsetGlobal: (plugin: string, version: string) => request<void>('/mise/unset-global', { method: 'POST', body: JSON.stringify({ plugin, version }) })
unsetGlobal: (plugin: string, version: string) => request<void>('/mise/unset-global', { method: 'POST', body: JSON.stringify({ plugin, version }) }),
getEnvs: () => request<Record<string, string>>('/mise/envs'),
setEnv: (key: string, value: string) => request<void>('/mise/envs', { method: 'POST', body: JSON.stringify({ key, value }) }),
unsetEnv: (key: string) => request<void>(`/mise/envs?key=${key}`, { method: 'DELETE' })
},
terminal: {
cmds: () => request<{ name: string, description: string }[]>('/terminal/cmds')
+260 -99
View File
@@ -10,8 +10,9 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import {
Plus, Globe, Search, RefreshCw, Loader2, Trash2,
Terminal as TerminalIcon, X, AlertCircle,
Check, ChevronsUpDown
Check, ChevronsUpDown, ArrowRight
} from 'lucide-vue-next'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { api, type MiseLanguage } from '@/api'
import { toast } from 'vue-sonner'
import XTerminal from '@/components/XTerminal.vue'
@@ -56,12 +57,67 @@ const filteredPlugins = computed(() => {
return availablePlugins.value.filter(p => p.toLowerCase().includes(s))
})
const filteredVersions = computed(() => {
const list = availableVersions.value
if (!versionSearch.value) return list
const s = versionSearch.value.toLowerCase()
return list.filter(v => v.toLowerCase().includes(s))
})
const activeTab = ref('runtimes')
// 全局变量相关
const globalEnvs = ref<Record<string, string>>({})
const loadingEnvs = ref(false)
const showAddEnvDialog = ref(false)
const newEnvKey = ref('')
const newEnvValue = ref('')
const ENV_PRESETS = [
{ label: 'npm (淘宝镜像)', key: 'NPM_CONFIG_REGISTRY', value: 'https://registry.npmmirror.com' },
{ label: 'Python (清华镜像)', key: 'PIP_INDEX_URL', value: 'https://pypi.tuna.tsinghua.edu.cn/simple' },
{ label: 'Go (goproxy.cn)', key: 'GOPROXY', value: 'https://goproxy.cn,direct' },
{ label: 'Rust (Sparse protocol)', key: 'CARGO_REGISTRY_CRATES_IO_PROTOCOL', value: 'sparse' },
{ label: 'Rust (中科大镜像)', key: 'CARGO_REGISTRIES_CRATES_IO_INDEX', value: 'https://mirrors.ustc.edu.cn/crates.io-index' },
]
async function loadGlobalEnvs() {
loadingEnvs.value = true
try {
globalEnvs.value = await api.mise.getEnvs()
} catch (e) {
toast.error('获取全局环境变量失败: ' + e)
} finally {
loadingEnvs.value = false
}
}
async function handleSetEnv(key: string, value: string) {
try {
await api.mise.setEnv(key, value)
toast.success(`环境变量 ${key} 设置成功`)
loadGlobalEnvs()
} catch (e) {
toast.error('设置失败: ' + e)
}
}
async function handleUnsetEnv(key: string) {
try {
await api.mise.unsetEnv(key)
toast.success(`环境变量 ${key} 已移除`)
loadGlobalEnvs()
} catch (e) {
toast.error('移除失败: ' + e)
}
}
function applyPreset(preset: { key: string, value: string }) {
newEnvKey.value = preset.key
newEnvValue.value = preset.value
showAddEnvDialog.value = true
}
function startAddEnv() {
if (!newEnvKey.value.trim()) return
handleSetEnv(newEnvKey.value.trim(), newEnvValue.value.trim())
showAddEnvDialog.value = false
newEnvKey.value = ''
newEnvValue.value = ''
}
const showTerminalDialog = ref(false)
const terminalCommand = ref('')
@@ -73,6 +129,13 @@ const filteredLanguages = computed(() => {
return languages.value.filter(l => l.plugin.toLowerCase().includes(q) || l.version.toLowerCase().includes(q))
})
const filteredVersions = computed(() => {
const list = availableVersions.value
if (!versionSearch.value) return list
const s = versionSearch.value.toLowerCase()
return list.filter(v => v.toLowerCase().includes(s))
})
async function loadLanguages() {
loading.value = true
errorMsg.value = ''
@@ -260,7 +323,10 @@ async function toggleDefault(lang: MiseLanguage) {
}
}
onMounted(loadLanguages)
onMounted(() => {
loadLanguages()
loadGlobalEnvs()
})
</script>
<template>
@@ -287,103 +353,172 @@ onMounted(loadLanguages)
<p class="text-sm font-medium">{{ errorMsg }}</p>
</div>
<!-- 列表部分 -->
<div class="rounded-lg border bg-card overflow-hidden">
<div class="flex flex-col sm:flex-row sm:items-center justify-between px-4 py-3 border-b bg-muted/30 gap-3">
<div class="relative w-full sm:w-64">
<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-8 text-sm" />
</div>
<div class="flex items-center gap-2 w-full sm:w-auto justify-end">
<Button variant="outline" class="h-9 px-3 text-sm flex-1 sm:flex-none"
@click="showSyncConfirm = true" :disabled="syncing || loading">
<RefreshCw class="h-4 w-4 sm:mr-2" :class="{ 'animate-spin': syncing }" />
<span class="hidden sm:inline">更新本地环境</span>
<span class="sm:hidden">同步</span>
</Button>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="loadLanguages"
:disabled="loading">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
</Button>
</div>
</div>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full sm:w-72 grid-cols-2 mb-4">
<TabsTrigger value="runtimes">安装列表</TabsTrigger>
<TabsTrigger value="envs">镜像加速</TabsTrigger>
</TabsList>
<div class="divide-y max-h-[600px] overflow-y-auto min-h-[200px]">
<div v-if="loading && languages.length === 0" class="text-center py-12 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin mx-auto mb-2 opacity-20" />
正在扫描运行环境...
</div>
<div v-else-if="filteredLanguages.length === 0 && !loading"
class="text-center py-12 text-muted-foreground">
<Globe class="h-12 w-12 mx-auto mb-2 opacity-10" />
{{ searchQuery ? '未找到匹配的语言' : '未发现已安装的语言' }}
</div>
<template v-else>
<div v-for="lang in filteredLanguages" :key="lang.plugin + lang.version"
class="flex flex-col sm:flex-row sm:items-center justify-between px-4 py-4 hover:bg-muted/50 transition-colors gap-4">
<div class="flex items-center gap-4 min-w-0">
<div
class="h-9 w-9 sm:h-10 sm:w-10 rounded-full bg-primary/10 flex items-center justify-center font-bold text-primary uppercase overflow-hidden shrink-0">
<template v-if="getLangIcon(lang.plugin)">
<div class="w-full h-full bg-white/80 p-2 flex items-center justify-center">
<img :src="getLangIcon(lang.plugin)" :alt="lang.plugin"
class="w-full h-full object-contain" />
</div>
</template>
<template v-else>
{{ lang.plugin.length > 2 ? lang.plugin.substring(0, 2) : lang.plugin }}
</template>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-bold capitalize truncate">{{ lang.plugin }}</span>
<Badge variant="outline" class="font-mono whitespace-nowrap">{{ lang.version }}
</Badge>
<Badge v-if="lang.isGlobal" variant="secondary"
class="bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20 text-[10px] h-5 px-1.5 font-normal">
默认
</Badge>
</div>
<div class="text-xs text-muted-foreground mt-1 space-y-0.5">
<div class="font-mono opacity-60 truncate" :title="lang.source">来源: {{ lang.source
}}
</div>
<div v-if="lang.installed_at" class="opacity-50">
添加日期: {{ lang.installed_at }}
</div>
</div>
</div>
<TabsContent value="runtimes" class="space-y-4 outline-none">
<!-- 列表部分 -->
<div class="rounded-lg border bg-card overflow-hidden">
<div
class="flex flex-col sm:flex-row sm:items-center justify-between px-4 py-3 border-b bg-muted/30 gap-3">
<div class="relative w-full sm:w-64">
<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-8 text-sm" />
</div>
<div
class="flex items-center gap-2 sm:ml-auto w-full sm:w-auto overflow-x-auto pb-1 sm:pb-0 hide-scrollbar">
<Button v-if="SUPPORTED_DEPS_LANGS.includes(lang.plugin)" variant="outline" size="sm"
class="whitespace-nowrap flex-1 sm:flex-none"
@click="$router.push(`/dependencies?language=${lang.plugin}&version=${lang.version}`)">
依赖管理
<div class="flex items-center gap-2 w-full sm:w-auto justify-end">
<Button variant="outline" class="h-9 px-3 text-sm flex-1 sm:flex-none"
@click="showSyncConfirm = true" :disabled="syncing || loading">
<RefreshCw class="h-4 w-4 sm:mr-2" :class="{ 'animate-spin': syncing }" />
<span class="hidden sm:inline">更新本地环境</span>
<span class="sm:hidden">同步</span>
</Button>
<Badge v-else variant="secondary"
class="h-8 opacity-60 flex-1 sm:flex-none justify-center whitespace-nowrap">
不支持管理
</Badge>
<Button variant="outline" size="sm" class="whitespace-nowrap flex-1 sm:flex-none"
@click="handleVerify(lang)">
环境验证
</Button>
<Button variant="outline" size="sm"
:class="cn('whitespace-nowrap flex-1 sm:flex-none', lang.isGlobal && 'text-amber-600 border-amber-500/50 bg-amber-500/5 hover:bg-amber-500/10')"
@click="toggleDefault(lang)">
{{ lang.isGlobal ? '取消默认' : '设为默认' }}
</Button>
<Button variant="ghost" size="icon"
class="text-destructive h-8 w-8 shrink-0 ml-auto sm:ml-0" @click="confirmDelete(lang)"
title="卸载">
<Trash2 class="h-4 w-4" />
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="loadLanguages"
:disabled="loading">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
</Button>
</div>
</div>
</template>
</div>
</div>
<div class="divide-y max-h-[600px] overflow-y-auto min-h-[200px]">
<div v-if="loading && languages.length === 0" class="text-center py-12 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin mx-auto mb-2 opacity-20" />
正在扫描运行环境...
</div>
<div v-else-if="filteredLanguages.length === 0 && !loading"
class="text-center py-12 text-muted-foreground">
<Globe class="h-12 w-12 mx-auto mb-2 opacity-10" />
{{ searchQuery ? '未找到匹配的语言' : '未发现已安装的语言' }}
</div>
<template v-else>
<div v-for="lang in filteredLanguages" :key="lang.plugin + lang.version"
class="flex flex-col sm:flex-row sm:items-center justify-between px-4 py-4 hover:bg-muted/50 transition-colors gap-4">
<div class="flex items-center gap-4 min-w-0">
<div
class="h-9 w-9 sm:h-10 sm:w-10 rounded-full bg-primary/10 flex items-center justify-center font-bold text-primary uppercase overflow-hidden shrink-0">
<template v-if="getLangIcon(lang.plugin)">
<div class="w-full h-full bg-white/80 p-2 flex items-center justify-center">
<img :src="getLangIcon(lang.plugin)" :alt="lang.plugin"
class="w-full h-full object-contain" />
</div>
</template>
<template v-else>
{{ lang.plugin.length > 2 ? lang.plugin.substring(0, 2) : lang.plugin }}
</template>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-bold capitalize truncate">{{ lang.plugin }}</span>
<Badge variant="outline" class="font-mono whitespace-nowrap">{{ lang.version }}
</Badge>
<Badge v-if="lang.isGlobal" variant="secondary"
class="bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20 text-[10px] h-5 px-1.5 font-normal">
默认
</Badge>
</div>
<div class="text-xs text-muted-foreground mt-1 space-y-0.5">
<div class="font-mono opacity-60 truncate" :title="lang.source">来源: {{ lang.source
}}
</div>
<div v-if="lang.installed_at" class="opacity-50">
添加日期: {{ lang.installed_at }}
</div>
</div>
</div>
</div>
<div
class="flex items-center gap-2 sm:ml-auto w-full sm:w-auto overflow-x-auto pb-1 sm:pb-0 hide-scrollbar">
<Button v-if="SUPPORTED_DEPS_LANGS.includes(lang.plugin)" variant="outline" size="sm"
class="whitespace-nowrap flex-1 sm:flex-none"
@click="$router.push(`/dependencies?language=${lang.plugin}&version=${lang.version}`)">
依赖管理
</Button>
<Badge v-else variant="secondary"
class="h-8 opacity-60 flex-1 sm:flex-none justify-center whitespace-nowrap">
不支持管理
</Badge>
<Button variant="outline" size="sm" class="whitespace-nowrap flex-1 sm:flex-none"
@click="handleVerify(lang)">
环境验证
</Button>
<Button variant="outline" size="sm"
:class="cn('whitespace-nowrap flex-1 sm:flex-none', lang.isGlobal && 'text-amber-600 border-amber-500/50 bg-amber-500/5 hover:bg-amber-500/10')"
@click="toggleDefault(lang)">
{{ lang.isGlobal ? '取消默认' : '设为默认' }}
</Button>
<Button variant="ghost" size="icon"
class="text-destructive h-8 w-8 shrink-0 ml-auto sm:ml-0" @click="confirmDelete(lang)"
title="卸载">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</template>
</div>
</div>
</TabsContent>
<TabsContent value="envs" class="space-y-4 outline-none">
<div class="rounded-lg border bg-card p-6">
<div class="flex items-start justify-between mb-8">
<div>
<h3 class="text-lg font-bold flex items-center gap-2">
<Globe class="h-5 w-5 text-primary" />
镜像源加速 (Mirror Acceleration)
</h3>
<p class="text-xs text-muted-foreground mt-1 max-w-2xl">
通过配置全局环境变量加速编程语言环境及依赖包 pip, npm, go get的安装速度
</p>
</div>
</div>
<!-- 常用预设网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="preset in ENV_PRESETS" :key="preset.label"
:class="cn(
'relative p-4 rounded-xl border transition-all group overflow-hidden',
globalEnvs[preset.key] ? 'bg-primary/5 border-primary shadow-sm' : 'bg-muted/30 hover:border-primary/50 cursor-pointer'
)" @click="applyPreset({ key: preset.key, value: globalEnvs[preset.key] || preset.value })">
<div class="flex items-start justify-between mb-2">
<div class="font-semibold text-sm">{{ preset.label }}</div>
<div v-if="globalEnvs[preset.key]"
class="h-5 w-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
<Check class="h-3 w-3" />
</div>
</div>
<div class="text-[10px] font-mono mb-3">
<div class="text-muted-foreground truncate">{{ preset.key }}</div>
<div v-if="globalEnvs[preset.key]"
class="mt-1 text-primary/80 font-medium truncate bg-primary/5 px-1.5 py-0.5 rounded-sm border border-primary/10"
:title="globalEnvs[preset.key] as string">
{{ globalEnvs[preset.key] }}
</div>
</div>
<div class="flex items-center justify-between mt-auto">
<span v-if="globalEnvs[preset.key]" class="text-[10px] font-medium text-primary">
已启用 (点击修改)
</span>
<span v-else class="text-[10px] font-medium text-muted-foreground group-hover:text-primary transition-colors flex items-center">
立即启用 <ArrowRight class="h-3 w-3 ml-1 group-hover:translate-x-1 transition-transform" />
</span>
<Button v-if="globalEnvs[preset.key]"
variant="ghost" size="icon"
class="h-6 w-6 text-destructive hover:bg-destructive/10"
@click.stop="handleUnsetEnv(preset.key)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
<!-- 安装对话框 (带搜索下拉) -->
<Dialog v-model:open="showInstallDialog">
@@ -504,6 +639,32 @@ onMounted(loadLanguages)
</DialogContent>
</Dialog>
<!-- 启用/编辑镜像加速对话框 -->
<Dialog v-model:open="showAddEnvDialog">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>设置镜像加速</DialogTitle>
<DialogDescription>
正在配置 <span class="font-bold text-primary">{{ newEnvKey }}</span>你可以根据需要调整镜像地址
</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label>环境变量名 (Readonly)</Label>
<Input v-model="newEnvKey" readonly class="bg-muted cursor-default" />
</div>
<div class="grid gap-2">
<Label>镜像源地址 (Value)</Label>
<Input v-model="newEnvValue" placeholder="请输入镜像地址" @keydown.enter="startAddEnv" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showAddEnvDialog = false">取消</Button>
<Button @click="startAddEnv">确认启用</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 终端对话框 -->
<Dialog v-model:open="showTerminalDialog">
<DialogContent