Files
TaskPool/web/src/pages/Languages.tsx
T
admin e6956aa001 Initial commit: TaskPool React panel
- React frontend with route-level code splitting
- Backend rebranded from Baihu to TaskPool
- DB brand migration script and local compatibility
2026-07-26 08:43:52 +08:00

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>
)
}