e6956aa001
- React frontend with route-level code splitting - Backend rebranded from Baihu to TaskPool - DB brand migration script and local compatibility
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { useNavigate } from '@tanstack/react-router'
|
|
import {
|
|
Boxes,
|
|
CheckCircle2,
|
|
Info,
|
|
Package,
|
|
Plus,
|
|
RefreshCw,
|
|
Terminal as TerminalIcon,
|
|
Trash2,
|
|
Zap,
|
|
} from 'lucide-react'
|
|
import { api, type MiseLanguage } from '@/api'
|
|
import { useMiseList } from '@/api/hooks'
|
|
import { Button, Card, Input, Modal, PageHeader, Select, Tabs } from '@/components/ui'
|
|
import { toast } from '@/lib/toast'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
type DisplayLang = MiseLanguage & { sourceText: string }
|
|
|
|
function sourceText(source: MiseLanguage['source']) {
|
|
if (!source) return '-'
|
|
if (typeof source === 'string') return source
|
|
return source.path || source.type || '-'
|
|
}
|
|
|
|
export default function Languages() {
|
|
const navigate = useNavigate()
|
|
const [tab, setTab] = useState<'runtimes' | 'envs'>('runtimes')
|
|
const [installOpen, setInstallOpen] = useState(false)
|
|
const [plugins, setPlugins] = useState<string[]>([])
|
|
const [versions, setVersions] = useState<string[]>([])
|
|
const [plugin, setPlugin] = useState('python')
|
|
const [version, setVersion] = useState('')
|
|
const [cmdOpen, setCmdOpen] = useState(false)
|
|
const [command, setCommand] = useState('')
|
|
const [envs, setEnvs] = useState<Record<string, string>>({})
|
|
const [envKey, setEnvKey] = useState('')
|
|
const [envValue, setEnvValue] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [message, setMessage] = useState('')
|
|
const [showTip, setShowTip] = useState(() => localStorage.getItem('taskpool_show_mise_extension_tip') !== '0')
|
|
|
|
const { data, isLoading, refetch, isFetching } = useMiseList()
|
|
const list: DisplayLang[] = useMemo(
|
|
() =>
|
|
(Array.isArray(data) ? data : []).map((item) => ({
|
|
...item,
|
|
sourceText: sourceText(item.source),
|
|
})),
|
|
[data],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (tab !== 'envs') return
|
|
api.mise
|
|
.getEnvs()
|
|
.then((res) => setEnvs(res || {}))
|
|
.catch(() => setEnvs({}))
|
|
}, [tab])
|
|
|
|
useEffect(() => {
|
|
if (!installOpen) return
|
|
api.mise
|
|
.plugins()
|
|
.then((res) => {
|
|
const arr = Array.isArray(res) ? res : []
|
|
setPlugins(arr)
|
|
if (arr.length && !arr.includes(plugin)) setPlugin(arr[0]!)
|
|
})
|
|
.catch(() => setPlugins(['python', 'node', 'go', 'php', 'ruby']))
|
|
}, [installOpen])
|
|
|
|
useEffect(() => {
|
|
if (!installOpen || !plugin) return
|
|
setVersions([])
|
|
setVersion('')
|
|
api.mise
|
|
.versions(plugin)
|
|
.then((res) => {
|
|
const arr = Array.isArray(res) ? res : []
|
|
setVersions(arr)
|
|
if (arr[0]) setVersion(arr[0])
|
|
})
|
|
.catch(() => setVersions([]))
|
|
}, [installOpen, plugin])
|
|
|
|
async function sync() {
|
|
setBusy(true)
|
|
try {
|
|
await api.mise.sync()
|
|
setMessage('本地环境同步成功')
|
|
toast.success('本地环境同步成功')
|
|
refetch()
|
|
} catch (e: any) {
|
|
setMessage(e?.message || '同步失败')
|
|
toast.error(e?.message || '同步失败')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
function openInstallCmd() {
|
|
if (!plugin || !version) return
|
|
setCommand(`mise install ${plugin}@${version}`)
|
|
setInstallOpen(false)
|
|
setCmdOpen(true)
|
|
}
|
|
|
|
function uninstallCmd(lang: DisplayLang) {
|
|
setCommand(`mise uninstall ${lang.plugin}@${lang.version}`)
|
|
setCmdOpen(true)
|
|
}
|
|
|
|
async function verify(lang: DisplayLang) {
|
|
try {
|
|
const res = await api.mise.verifyCommand(lang.plugin, lang.version)
|
|
setCommand(res.command)
|
|
setCmdOpen(true)
|
|
} catch (e: any) {
|
|
setMessage(e?.message || '获取验证命令失败')
|
|
}
|
|
}
|
|
|
|
async function toggleDefault(lang: DisplayLang) {
|
|
setBusy(true)
|
|
try {
|
|
if (lang.is_global) {
|
|
await api.mise.unsetGlobal(lang.plugin, lang.version)
|
|
setMessage(`已取消 ${lang.plugin} 的全局默认`)
|
|
} else {
|
|
await api.mise.useGlobal(lang.plugin, lang.version)
|
|
setMessage(`已将 ${lang.plugin} ${lang.version} 设为全局默认`)
|
|
}
|
|
refetch()
|
|
} catch (e: any) {
|
|
setMessage(e?.message || '操作失败')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function saveEnv() {
|
|
if (!envKey.trim()) return
|
|
setBusy(true)
|
|
try {
|
|
await api.mise.setEnv(envKey.trim(), envValue)
|
|
setEnvKey('')
|
|
setEnvValue('')
|
|
const res = await api.mise.getEnvs()
|
|
setEnvs(res || {})
|
|
setMessage('环境变量已保存')
|
|
} catch (e: any) {
|
|
setMessage(e?.message || '保存失败')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function removeEnv(key: string) {
|
|
setBusy(true)
|
|
try {
|
|
await api.mise.unsetEnv(key)
|
|
const res = await api.mise.getEnvs()
|
|
setEnvs(res || {})
|
|
} catch (e: any) {
|
|
setMessage(e?.message || '删除失败')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
function closeTip() {
|
|
setShowTip(false)
|
|
localStorage.setItem('taskpool_show_mise_extension_tip', '0')
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="语言依赖"
|
|
description="管理编程语言及相关包依赖 (Mise)"
|
|
actions={
|
|
<>
|
|
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
|
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
|
|
刷新
|
|
</Button>
|
|
<Button variant="secondary" onClick={sync} disabled={busy}>
|
|
同步
|
|
</Button>
|
|
<Button onClick={() => setInstallOpen(true)}>
|
|
<Plus size={14} />
|
|
新增语言
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{message ? (
|
|
<div className="mb-3 rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)] px-3 py-2 text-sm">
|
|
{message}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mb-4 space-y-2">
|
|
<div className="flex items-start gap-2 rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
|
|
<Info size={16} className="mt-0.5 shrink-0" />
|
|
<span>
|
|
<b>设为默认</b>:将选定版本设为系统全局默认,未指定环境的任务将调用此版本。
|
|
</span>
|
|
</div>
|
|
{showTip ? (
|
|
<div className="flex items-start justify-between gap-2 rounded-lg border border-blue-500/20 bg-blue-500/10 px-3 py-2 text-sm text-blue-600 dark:text-blue-400">
|
|
<div className="flex items-start gap-2">
|
|
<Info size={16} className="mt-0.5 shrink-0" />
|
|
<span>Mise 也可管理其它工具插件,可在终端安装后通过同步展示。</span>
|
|
</div>
|
|
<button className="text-xs opacity-70 hover:opacity-100" onClick={closeTip}>
|
|
关闭
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<Tabs
|
|
value={tab}
|
|
onValueChange={(v) => setTab(v as 'runtimes' | 'envs')}
|
|
items={[
|
|
{ value: 'runtimes', label: '运行时' },
|
|
{ value: 'envs', label: '加速/环境' },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{tab === 'runtimes' ? (
|
|
<Card className="overflow-hidden">
|
|
{isLoading ? (
|
|
<div className="p-6 text-[var(--text-muted)]">加载中...</div>
|
|
) : list.length === 0 ? (
|
|
<div className="p-8 text-center text-sm text-[var(--text-muted)]">
|
|
<Boxes className="mx-auto mb-2 opacity-50" size={28} />
|
|
暂无语言运行时,点击「新增语言」安装
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-[var(--border)]">
|
|
{list.map((lang, idx) => (
|
|
<div
|
|
key={`${lang.plugin}-${lang.version}-${idx}`}
|
|
className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-medium">
|
|
{lang.plugin} {lang.version}
|
|
</span>
|
|
{lang.is_global ? (
|
|
<span className="inline-flex items-center gap-1 rounded bg-green-500/15 px-1.5 py-0.5 text-xs text-green-500">
|
|
<CheckCircle2 size={12} />
|
|
默认
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-1 text-xs text-[var(--text-muted)]">
|
|
来源: {lang.sourceText} · {lang.install_path || '-'}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() =>
|
|
navigate({
|
|
to: '/dependencies',
|
|
search: { language: lang.plugin, version: lang.version },
|
|
})
|
|
}
|
|
>
|
|
<Package size={14} />
|
|
依赖
|
|
</Button>
|
|
<Button size="sm" variant="secondary" onClick={() => toggleDefault(lang)} disabled={busy}>
|
|
{lang.is_global ? '取消默认' : '设为默认'}
|
|
</Button>
|
|
<Button size="sm" variant="secondary" onClick={() => verify(lang)}>
|
|
<TerminalIcon size={14} />
|
|
验证
|
|
</Button>
|
|
<Button size="sm" variant="danger" onClick={() => uninstallCmd(lang)}>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<Card className="space-y-3 p-4">
|
|
<div className="flex items-center gap-2 text-sm font-medium">
|
|
<Zap size={16} />
|
|
Mise 环境变量(镜像加速等)
|
|
</div>
|
|
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
|
<Input placeholder="KEY" value={envKey} onChange={(e) => setEnvKey(e.target.value)} />
|
|
<Input placeholder="VALUE" value={envValue} onChange={(e) => setEnvValue(e.target.value)} />
|
|
<Button onClick={saveEnv} disabled={busy || !envKey.trim()}>
|
|
保存
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
<Card className="overflow-hidden">
|
|
{Object.keys(envs).length === 0 ? (
|
|
<div className="p-6 text-sm text-[var(--text-muted)]">暂无环境变量</div>
|
|
) : (
|
|
<div className="divide-y divide-[var(--border)]">
|
|
{Object.entries(envs).map(([k, v]) => (
|
|
<div key={k} className="flex items-center justify-between gap-3 p-4 text-sm">
|
|
<div className="min-w-0">
|
|
<div className="font-mono font-medium">{k}</div>
|
|
<div className="truncate text-xs text-[var(--text-muted)]">{v}</div>
|
|
</div>
|
|
<Button size="sm" variant="danger" onClick={() => removeEnv(k)} disabled={busy}>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
<Modal
|
|
open={installOpen}
|
|
title="安装语言运行时"
|
|
onClose={() => setInstallOpen(false)}
|
|
footer={
|
|
<>
|
|
<Button variant="secondary" onClick={() => setInstallOpen(false)}>
|
|
取消
|
|
</Button>
|
|
<Button onClick={openInstallCmd} disabled={!plugin || !version}>
|
|
生成安装命令
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<Select value={plugin} onChange={(e) => setPlugin(e.target.value)}>
|
|
{(plugins.length ? plugins : ['python', 'node', 'go', 'php', 'ruby']).map((p) => (
|
|
<option key={p} value={p}>
|
|
{p}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<Select value={version} onChange={(e) => setVersion(e.target.value)}>
|
|
{versions.length === 0 ? <option value="">加载版本中...</option> : null}
|
|
{versions.map((v) => (
|
|
<option key={v} value={v}>
|
|
{v}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={cmdOpen}
|
|
title="终端命令"
|
|
onClose={() => setCmdOpen(false)}
|
|
size="lg"
|
|
footer={
|
|
<>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(command)
|
|
setMessage('命令已复制')
|
|
} catch {
|
|
setMessage('复制失败')
|
|
}
|
|
}}
|
|
>
|
|
复制
|
|
</Button>
|
|
<Button
|
|
onClick={async () => {
|
|
setCmdOpen(false)
|
|
await sync()
|
|
}}
|
|
>
|
|
关闭并同步
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<pre className={cn('overflow-auto rounded-md bg-[var(--bg-primary)] p-3 font-mono text-xs')}>{command}</pre>
|
|
<p className="mt-2 text-xs text-[var(--text-muted)]">请在「终端命令」页面或系统终端执行上述命令。</p>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|