refactor(web): extract common config components for task and repo dialogs
This commit is contained in:
@@ -8,15 +8,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import DirTreeSelect from '@/components/DirTreeSelect.vue'
|
||||
import { X, Globe, GitBranch, Shield, Zap, Clock, Download, Plus, Search, Check, ChevronsUpDown, Loader2, AlertCircle, Terminal } from 'lucide-vue-next'
|
||||
import { api, type Task, type RepoConfig, type Agent, type MiseLanguage } from '@/api'
|
||||
import { Globe, GitBranch, Shield, Zap, Download, AlertCircle, Terminal } from 'lucide-vue-next'
|
||||
import { api, type Task, type RepoConfig, type Agent } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getCronDescription } from '@/utils/cron'
|
||||
|
||||
import { parseBaihuCommand, parseQlCommand } from '@/utils/repo-parser'
|
||||
import TaskNotificationConfig from './components/TaskNotificationConfig.vue'
|
||||
import TaskAdvancedConfig from './components/TaskAdvancedConfig.vue'
|
||||
import TaskCronConfig from './components/TaskCronConfig.vue'
|
||||
import TaskTagsConfig from './components/TaskTagsConfig.vue'
|
||||
|
||||
const notificationConfigRef = ref<InstanceType<typeof TaskNotificationConfig> | null>(null)
|
||||
|
||||
@@ -31,17 +33,7 @@ const emit = defineEmits<{
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const cronPresets = [
|
||||
{ label: '每5秒', value: '*/5 * * * * *' },
|
||||
{ label: '每30秒', value: '*/30 * * * * *' },
|
||||
{ label: '每分钟', value: '0 * * * * *' },
|
||||
{ label: '每5分钟', value: '0 */5 * * * *' },
|
||||
{ label: '每小时', value: '0 0 * * * *' },
|
||||
{ label: '每天0点', value: '0 0 0 * * *' },
|
||||
{ label: '每天8点', value: '0 0 8 * * *' },
|
||||
{ label: '每周一', value: '0 0 0 * * 1' },
|
||||
{ label: '每月1号', value: '0 0 0 1 * *' },
|
||||
]
|
||||
|
||||
|
||||
const proxyOptions = [
|
||||
{ label: '不使用代理', value: 'none' },
|
||||
@@ -70,11 +62,10 @@ const repoConfig = ref<RepoConfig>({
|
||||
repo_source: '',
|
||||
proxy: ''
|
||||
})
|
||||
const cleanType = ref('none')
|
||||
const cleanKeep = ref(30)
|
||||
|
||||
const allAgents = ref<Agent[]>([])
|
||||
const selectedAgentId = ref<string>('local')
|
||||
const tagInput = ref('')
|
||||
|
||||
|
||||
const autoAddCron = computed({
|
||||
get: () => !!repoConfig.value.auto_add_cron,
|
||||
@@ -91,66 +82,7 @@ const pullQlConfig = computed({
|
||||
})
|
||||
|
||||
// === 语言环境相关 ===
|
||||
const installedLangs = ref<MiseLanguage[]>([])
|
||||
const loadingLangs = ref(false)
|
||||
const selectedLangs = ref<{ name: string; version: string; availableVersions: string[] }[]>([])
|
||||
const availablePlugins = ref<string[]>([])
|
||||
const pluginSearch = ref('')
|
||||
const versionSearch = ref('')
|
||||
|
||||
const filteredPlugins = computed(() => {
|
||||
if (!pluginSearch.value) return availablePlugins.value
|
||||
const s = pluginSearch.value.toLowerCase()
|
||||
return availablePlugins.value.filter((p: string) => p.toLowerCase().includes(s))
|
||||
})
|
||||
|
||||
function getFilteredVersions(versions: string[]) {
|
||||
if (!versionSearch.value) return versions
|
||||
const s = versionSearch.value.toLowerCase()
|
||||
return versions.filter((v: string) => v.toLowerCase().includes(s))
|
||||
}
|
||||
|
||||
async function fetchInstalledLangs() {
|
||||
loadingLangs.value = true
|
||||
try {
|
||||
installedLangs.value = await api.mise.list()
|
||||
const plugins = new Set<string>()
|
||||
installedLangs.value.forEach((l: MiseLanguage) => plugins.add(l.plugin))
|
||||
availablePlugins.value = Array.from(plugins).sort()
|
||||
} catch (e) {
|
||||
console.error('Fetch installed langs failed', e)
|
||||
} finally {
|
||||
loadingLangs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
import { getLangIcon } from '@/utils/icons'
|
||||
function updateAvailableVersions(lang: { name: string; version: string; availableVersions: string[] }) {
|
||||
if (lang.name) {
|
||||
lang.availableVersions = installedLangs.value
|
||||
.filter((l: MiseLanguage) => l.plugin === lang.name)
|
||||
.map((l: MiseLanguage) => l.version)
|
||||
.sort((a: string, b: string) => b.localeCompare(a, undefined, { numeric: true }))
|
||||
} else {
|
||||
lang.availableVersions = []
|
||||
}
|
||||
}
|
||||
|
||||
function addLang() {
|
||||
selectedLangs.value.push({ name: '', version: '', availableVersions: [] })
|
||||
}
|
||||
|
||||
function removeLang(index: number) {
|
||||
selectedLangs.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function updateLangName(index: number, name: string) {
|
||||
const lang = selectedLangs.value[index]
|
||||
if (!lang) return
|
||||
lang.name = name
|
||||
lang.version = '' // reset version
|
||||
updateAvailableVersions(lang)
|
||||
}
|
||||
|
||||
const showQlImportDialog = ref(false)
|
||||
const qlCommandInput = ref('')
|
||||
@@ -220,7 +152,6 @@ function submitBaihuImport() {
|
||||
version: l.version || '',
|
||||
availableVersions: []
|
||||
}))
|
||||
selectedLangs.value.forEach(l => updateAvailableVersions(l))
|
||||
}
|
||||
|
||||
// toast.success('命令解析成功,已自动填充表单')
|
||||
@@ -251,38 +182,9 @@ function submitQlImport() {
|
||||
showQlImportDialog.value = false
|
||||
}
|
||||
|
||||
const cronDescription = computed(() => {
|
||||
if (!form.value.schedule) return ''
|
||||
return getCronDescription(form.value.schedule, (navigator as any).language)
|
||||
})
|
||||
|
||||
function addTag() {
|
||||
const val = tagInput.value.trim()
|
||||
if (!val) return
|
||||
const currentTags = form.value.tags ? form.value.tags.split(',').filter(Boolean) : []
|
||||
if (!currentTags.includes(val)) {
|
||||
currentTags.push(val)
|
||||
form.value.tags = currentTags.join(',')
|
||||
}
|
||||
tagInput.value = ''
|
||||
}
|
||||
|
||||
function removeTag(tagToRemove: string) {
|
||||
const currentTags = form.value.tags ? form.value.tags.split(',').filter(Boolean) : []
|
||||
form.value.tags = currentTags.filter((t: string) => t !== tagToRemove).join(',')
|
||||
}
|
||||
|
||||
|
||||
const concurrencyEnabled = computed({
|
||||
get: () => repoConfig.value.concurrency === 1,
|
||||
set: (val: boolean) => {
|
||||
repoConfig.value.concurrency = val ? 1 : 0
|
||||
}
|
||||
})
|
||||
|
||||
function onConcurrencyChange(val: boolean) {
|
||||
concurrencyEnabled.value = val
|
||||
}
|
||||
|
||||
const isSingleFile = computed({
|
||||
get: () => !!repoConfig.value.single_file,
|
||||
@@ -291,11 +193,6 @@ const isSingleFile = computed({
|
||||
}
|
||||
})
|
||||
|
||||
const cleanConfig = computed(() => {
|
||||
if (!cleanType.value || cleanType.value === 'none' || cleanKeep.value <= 0) return ''
|
||||
return JSON.stringify({ type: cleanType.value, keep: cleanKeep.value })
|
||||
})
|
||||
|
||||
watch(() => props.open, async (val: boolean) => {
|
||||
if (val) {
|
||||
form.value = {
|
||||
@@ -308,20 +205,7 @@ watch(() => props.open, async (val: boolean) => {
|
||||
post_command: props.task?.post_command ?? '',
|
||||
...props.task
|
||||
}
|
||||
// 解析清理配置
|
||||
if (props.task?.clean_config) {
|
||||
try {
|
||||
const config = JSON.parse(props.task.clean_config)
|
||||
cleanType.value = config.type || 'none'
|
||||
cleanKeep.value = config.keep || 30
|
||||
} catch {
|
||||
cleanType.value = 'none'
|
||||
cleanKeep.value = 30
|
||||
}
|
||||
} else {
|
||||
cleanType.value = 'none'
|
||||
cleanKeep.value = 30
|
||||
}
|
||||
|
||||
// 解析仓库配置
|
||||
// 解析仓库配置
|
||||
const defaultConfig: RepoConfig = {
|
||||
@@ -347,12 +231,7 @@ watch(() => props.open, async (val: boolean) => {
|
||||
if (configStr) {
|
||||
try {
|
||||
const parsed = JSON.parse(configStr)
|
||||
// 兼容旧字段: 优先使用 $task_concurrency, 若无则默认 1
|
||||
let concurrency = 1
|
||||
if (parsed['$task_concurrency'] !== undefined) {
|
||||
concurrency = parsed['$task_concurrency'] === 1 ? 1 : 0
|
||||
}
|
||||
repoConfig.value = { ...defaultConfig, ...parsed, concurrency }
|
||||
repoConfig.value = { ...defaultConfig, ...parsed }
|
||||
} catch {
|
||||
repoConfig.value = defaultConfig
|
||||
}
|
||||
@@ -374,12 +253,6 @@ watch(() => props.open, async (val: boolean) => {
|
||||
selectedAgentId.value = 'local'
|
||||
// 加载 Agent 列表
|
||||
await loadAgents()
|
||||
if (selectedAgentId.value === 'local') {
|
||||
await fetchInstalledLangs()
|
||||
selectedLangs.value.forEach((lang: { name: string; version: string; availableVersions: string[] }) => {
|
||||
updateAvailableVersions(lang)
|
||||
})
|
||||
}
|
||||
// 加载通知配置
|
||||
await notificationConfigRef.value?.loadConfig(props.isEdit ? props.task?.id : undefined)
|
||||
}
|
||||
@@ -400,14 +273,13 @@ async function save() {
|
||||
}
|
||||
|
||||
try {
|
||||
form.value.clean_config = cleanConfig.value
|
||||
form.value.type = 'repo'
|
||||
// 确保 concurrency 字段被正确保存到 config 中
|
||||
// 注意:我们将 concurrency 存储在 config 的 $task_concurrency 字段中
|
||||
// 同时也保留在 repoConfig 对象中以便回显
|
||||
let existingConfig = {}
|
||||
if (form.value.config) {
|
||||
try { existingConfig = JSON.parse(form.value.config) } catch {}
|
||||
}
|
||||
const configToSave: any = {
|
||||
...repoConfig.value,
|
||||
'$task_concurrency': concurrencyEnabled.value ? 1 : 0
|
||||
...existingConfig,
|
||||
...repoConfig.value
|
||||
}
|
||||
|
||||
// 保存语言环境
|
||||
@@ -481,28 +353,7 @@ async function save() {
|
||||
<Input v-model="form.remark" placeholder="输入同步任务备注" class="sm:col-span-3 h-9 bg-muted/30 border-muted-foreground/20 focus:bg-background transition-all" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3">
|
||||
<Label class="sm:text-right text-xs text-muted-foreground uppercase tracking-wider pt-2.5">任务标签</Label>
|
||||
<div class="sm:col-span-3 space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<Input v-model="tagInput" placeholder="输入标签按回车..." class="h-9 bg-muted/30 border-muted-foreground/20 pr-12" @keydown.enter.prevent="addTag" />
|
||||
<Button type="button" variant="ghost" size="sm" class="absolute right-1 top-1 h-7 px-2 text-xs hover:bg-primary/10 hover:text-primary transition-colors" @click="addTag">
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="form.tags" class="flex flex-wrap gap-1.5 pt-1">
|
||||
<span v-for="tag in form.tags.split(',').filter(Boolean)" :key="tag"
|
||||
class="flex items-center gap-1.5 bg-primary/5 text-primary px-2.5 py-1 rounded-full text-[11px] font-medium border border-primary/10 group transition-all hover:bg-primary/10">
|
||||
{{ tag }}
|
||||
<button type="button" class="text-primary/40 hover:text-destructive transition-colors shrink-0" @click.prevent="removeTag(tag)">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TaskTagsConfig v-model="form.tags" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -684,87 +535,7 @@ async function save() {
|
||||
<p>同步后生成的任务将自动继承此运行环境。如果不指定语言版本,某些依赖特定语言的脚本(如 js, py)将无法顺利解析和运行!</p>
|
||||
</div>
|
||||
|
||||
<div v-for="(clang, idx) in selectedLangs" :key="idx"
|
||||
class="flex gap-2 p-2 rounded-lg bg-muted/20 border border-muted-foreground/10 group/lang relative overflow-hidden">
|
||||
<div class="absolute left-0 top-0 bottom-0 w-0.5 bg-primary/20 group-hover/lang:bg-primary transition-colors" />
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" role="combobox" class="justify-between flex-1 h-8 text-xs font-normal hover:bg-background/50">
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<div v-if="clang.name && getLangIcon(clang.name)" class="w-4 h-4 shrink-0 rounded-sm bg-white p-0.5 border shadow-sm">
|
||||
<img :src="getLangIcon(clang.name)" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<span class="font-medium">{{ clang.name || "选择插件..." }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-1 h-3 w-3 opacity-40" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0 w-[240px]" align="start">
|
||||
<div class="p-2 border-b bg-muted/30">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="pluginSearch" placeholder="搜索已安装语言..." class="h-8 pl-8 text-xs bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea class="h-48 p-1">
|
||||
<div v-if="loadingLangs" class="flex items-center justify-center py-6">
|
||||
<Loader2 class="h-5 w-5 animate-spin text-primary/50" />
|
||||
</div>
|
||||
<div v-else-if="filteredPlugins.length === 0" class="py-6 text-center text-xs text-muted-foreground">
|
||||
未找到匹配项
|
||||
</div>
|
||||
<button v-else v-for="p in filteredPlugins" :key="p" @click="updateLangName(idx, p)"
|
||||
class="w-full flex items-center px-3 py-2 text-xs rounded-md hover:bg-accent text-left transition-all group/item mb-0.5">
|
||||
<div class="mr-3 h-5 w-5 shrink-0 flex items-center justify-center transition-transform group-hover/item:scale-110">
|
||||
<img v-if="getLangIcon(p)" :src="getLangIcon(p)" class="w-full h-full object-contain p-0.5 bg-white rounded border" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-primary/10 rounded-sm text-[8px] font-bold border">
|
||||
{{ p.substring(0, 2) }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="flex-1" :class="{ 'font-bold text-primary': clang.name === p }">{{ p }}</span>
|
||||
<Check v-if="clang.name === p" class="h-3 w-3 text-primary" />
|
||||
</button>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild :disabled="!clang.name">
|
||||
<Button variant="ghost" role="combobox" class="justify-between w-28 h-8 text-xs font-normal hover:bg-background/50" :disabled="!clang.name">
|
||||
<span class="truncate">{{ clang.version || "版本..." }}</span>
|
||||
<ChevronsUpDown class="h-3 w-3 opacity-40 ml-1" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0 w-[160px]" align="start">
|
||||
<div class="p-2 border-b bg-muted/30">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="versionSearch" placeholder="搜索版本..." class="h-8 pl-8 text-xs bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea class="h-48 p-1">
|
||||
<div v-if="getFilteredVersions(clang.availableVersions).length === 0" class="py-6 text-center text-xs text-muted-foreground">
|
||||
无可用版本
|
||||
</div>
|
||||
<button v-else v-for="v in getFilteredVersions(clang.availableVersions)" :key="v" @click="clang.version = v"
|
||||
class="w-full flex items-center px-3 py-2 text-xs rounded-md hover:bg-accent text-left mb-0.5 font-mono">
|
||||
<span class="flex-1 truncate" :class="{ 'font-bold text-primary': clang.version === v }">{{ v }}</span>
|
||||
<Check v-if="clang.version === v" class="h-3 w-3 text-primary" />
|
||||
</button>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0"
|
||||
@click="removeLang(idx)">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" class="w-full h-9 text-xs border-dashed border-muted-foreground/30 text-muted-foreground hover:text-primary hover:border-primary/50 transition-all bg-muted/10 hover:bg-primary/5"
|
||||
@click="addLang">
|
||||
<Plus class="h-4 w-4 mr-2" /> 必须添加运行语言和版本
|
||||
</Button>
|
||||
<TaskLangConfig v-model="selectedLangs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -778,78 +549,10 @@ async function save() {
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 pl-3 border-l border-muted">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold">定时规则</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<Input v-model="form.schedule" placeholder="* * * * * *" class="h-9 font-mono text-[13px] bg-muted/30 border-muted-foreground/20 focus:ring-1 focus:ring-primary/50" />
|
||||
<div v-if="cronDescription" class="mt-2.5 p-2 rounded-lg bg-primary/5 border border-primary/10 text-[11px] text-primary font-medium flex items-center gap-2 animate-in fade-in slide-in-from-top-1 duration-300">
|
||||
<Zap class="h-3 w-3" />
|
||||
{{ cronDescription }}
|
||||
</div>
|
||||
<div class="mt-2.5 space-y-2">
|
||||
<div class="flex items-center gap-1.5 text-[10px] text-muted-foreground/70 uppercase font-bold tracking-tighter">
|
||||
<Clock class="h-3 w-3" /> 格式指导: 秒 分 时 日 月 周
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="preset in cronPresets" :key="preset.value"
|
||||
class="px-2 py-1 text-[10px] rounded-md bg-muted/50 border border-muted-foreground/10 hover:border-primary/50 hover:bg-primary/5 hover:text-primary transition-all font-medium"
|
||||
@click.prevent="form.schedule = preset.value">
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TaskCronConfig v-model="form.schedule" />
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold">随机延迟</Label>
|
||||
<div class="sm:col-span-3 flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input :model-value="form.random_range" @update:model-value="(v: string | number) => form.random_range = Number(v || 0)" type="number" :min="0" class="w-20 h-9 bg-muted/30 text-center" />
|
||||
<span class="text-xs font-semibold text-muted-foreground">秒</span>
|
||||
</div>
|
||||
<div class="flex-1 text-[11px] text-muted-foreground leading-snug p-2 rounded-lg bg-blue-500/5 border border-blue-500/10 italic">
|
||||
避免高频并发,在基准时间点后延迟 0~{{ form.random_range || 0 }}s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold">执行超时</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input :model-value="form.timeout" @update:model-value="(v: string | number) => form.timeout = Number(v || 0)" type="number" :min="0" class="w-20 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-[11px] font-semibold text-muted-foreground">分钟超时</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold">日志清理</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Select :model-value="cleanType" @update:model-value="(v: any) => cleanType = String(v || 'none')">
|
||||
<SelectTrigger class="w-28 h-9 text-xs bg-muted/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">保留日志</SelectItem>
|
||||
<SelectItem value="day">按天清理</SelectItem>
|
||||
<SelectItem value="count">按条清理</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="cleanType && cleanType !== 'none'" class="flex items-center gap-2">
|
||||
<Input :model-value="cleanKeep" @update:model-value="v => cleanKeep = Number(v || 30)" type="number" class="w-20 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-[11px] font-semibold text-muted-foreground">{{ cleanType === 'day' ? '天' : '条' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold">运行策略</Label>
|
||||
<div class="sm:col-span-3 space-y-4">
|
||||
|
||||
<TaskAdvancedConfig v-model="form" :show-retry="false">
|
||||
<template #run-strategy-prepend>
|
||||
<div class="p-3 rounded-xl bg-muted/20 border border-muted-foreground/10 space-y-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-xs font-semibold">
|
||||
@@ -862,21 +565,8 @@ async function save() {
|
||||
{{ autoAddCron ? '同步后将自动识别脚本中的 new Env("xxx") 和 cron 信息并注册任务。' : '仅拉取脚本,不自动注册任务。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-3 rounded-xl bg-muted/20 border border-muted-foreground/10 space-y-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-xs font-semibold">
|
||||
<Zap :class="cn('h-3.5 w-3.5', concurrencyEnabled ? 'text-primary' : 'text-muted-foreground')" />
|
||||
并发控制
|
||||
</div>
|
||||
<Switch :model-value="concurrencyEnabled" @update:model-value="onConcurrencyChange" />
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{{ concurrencyEnabled ? '允许同时开启多个同步副本。' : '当前同步未结束时,新触发将被静默忽略。' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TaskAdvancedConfig>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -9,15 +9,18 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import DirTreeSelect from '@/components/DirTreeSelect.vue'
|
||||
import { Plus, ChevronDown, X, Search, Check, ChevronsUpDown, AlertCircle, Terminal, Zap, Loader2, Lock, Variable } from 'lucide-vue-next'
|
||||
import { Plus, X, ChevronDown, Search, AlertCircle, Terminal, Zap, Lock, Variable } from 'lucide-vue-next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import TagInput from '@/components/TagInput.vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api, type Task, type EnvVar, type Agent, type MiseLanguage } from '@/api'
|
||||
import { api, type Task, type EnvVar, type Agent } from '@/api'
|
||||
import { PATHS, TRIGGER_TYPE } from '@/constants'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { getCronDescription } from '@/utils/cron'
|
||||
|
||||
import TaskNotificationConfig from './components/TaskNotificationConfig.vue'
|
||||
import TaskAdvancedConfig from './components/TaskAdvancedConfig.vue'
|
||||
import TaskCronConfig from './components/TaskCronConfig.vue'
|
||||
import TaskLangConfig from './components/TaskLangConfig.vue'
|
||||
import TaskTagsConfig from './components/TaskTagsConfig.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -30,22 +33,9 @@ const emit = defineEmits<{
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const cronPresets = [
|
||||
{ label: '每5秒', value: '*/5 * * * * *' },
|
||||
{ label: '每30秒', value: '*/30 * * * * *' },
|
||||
{ label: '每分钟', value: '0 * * * * *' },
|
||||
{ label: '每5分钟', value: '0 */5 * * * *' },
|
||||
{ label: '每小时', value: '0 0 * * * *' },
|
||||
{ label: '每天0点', value: '0 0 0 * * *' },
|
||||
{ label: '每天8点', value: '0 0 8 * * *' },
|
||||
{ label: '每周一', value: '0 0 0 * * 1' },
|
||||
{ label: '每月1号', value: '0 0 0 1 * *' },
|
||||
]
|
||||
|
||||
|
||||
const form = ref<Partial<Task>>({})
|
||||
const tagInput = ref('')
|
||||
const cleanType = ref('none')
|
||||
const cleanKeep = ref(30)
|
||||
const allEnvVars = ref<EnvVar[]>([])
|
||||
const allAgents = ref<Agent[]>([])
|
||||
const selectedEnvIds = ref<string[]>([])
|
||||
@@ -54,23 +44,10 @@ const selectedTriggerType = ref<string>('cron')
|
||||
const envSearchQuery = ref('')
|
||||
// 为每个执行位置保存独立的工作目录配置
|
||||
const workDirCache = ref<Record<string, string>>({})
|
||||
const concurrency = ref(0)
|
||||
const concurrencyEnabled = ref(false)
|
||||
const commentToTaskEnabled = ref(false)
|
||||
const allEnvsEnabled = ref(false)
|
||||
const scriptsDir = ref<string>(PATHS.SCRIPTS_DIR)
|
||||
|
||||
const cronDescription = computed(() => {
|
||||
if (!form.value.schedule) return ''
|
||||
return getCronDescription(form.value.schedule, (navigator as any).language)
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 监听 concurrencyEnabled 的变化,同步到 concurrency
|
||||
watch(concurrencyEnabled, (val: boolean) => {
|
||||
concurrency.value = val ? 1 : 0
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -78,21 +55,7 @@ function onAllEnvsChange(val: boolean) {
|
||||
allEnvsEnabled.value = val
|
||||
}
|
||||
|
||||
function addTag(passedTag?: string) {
|
||||
const val = (passedTag || tagInput.value).trim()
|
||||
if (!val) return
|
||||
const currentTags = form.value.tags ? form.value.tags.split(',').filter(Boolean) : []
|
||||
if (!currentTags.includes(val)) {
|
||||
currentTags.push(val)
|
||||
form.value.tags = currentTags.join(',')
|
||||
}
|
||||
tagInput.value = ''
|
||||
}
|
||||
|
||||
function removeTag(tagToRemove: string) {
|
||||
const currentTags = form.value.tags ? form.value.tags.split(',').filter(Boolean) : []
|
||||
form.value.tags = currentTags.filter((t: string) => t !== tagToRemove).join(',')
|
||||
}
|
||||
|
||||
// 当前显示的工作目录(根据选择的执行位置)
|
||||
const currentWorkDir = computed({
|
||||
@@ -102,10 +65,6 @@ const currentWorkDir = computed({
|
||||
}
|
||||
})
|
||||
|
||||
const cleanConfig = computed(() => {
|
||||
if (!cleanType.value || cleanType.value === 'none' || cleanKeep.value <= 0) return ''
|
||||
return JSON.stringify({ type: cleanType.value, keep: cleanKeep.value })
|
||||
})
|
||||
|
||||
const filteredEnvVars = computed(() => {
|
||||
return allEnvVars.value.filter((env: EnvVar) => {
|
||||
@@ -128,72 +87,12 @@ const onlineAgents = computed(() => {
|
||||
return allAgents.value.filter((a: Agent) => a.enabled)
|
||||
})
|
||||
|
||||
// 语言环境相关
|
||||
const installedLangs = ref<MiseLanguage[]>([])
|
||||
const loadingLangs = ref(false)
|
||||
|
||||
|
||||
const selectedLangs = ref<{ name: string; version: string; availableVersions: string[] }[]>([])
|
||||
|
||||
const availablePlugins = ref<string[]>([])
|
||||
const pluginSearch = ref('')
|
||||
const versionSearch = ref('')
|
||||
|
||||
const notificationConfigRef = ref<InstanceType<typeof TaskNotificationConfig> | null>(null)
|
||||
|
||||
const filteredPlugins = computed(() => {
|
||||
if (!pluginSearch.value) return availablePlugins.value
|
||||
const s = pluginSearch.value.toLowerCase()
|
||||
return availablePlugins.value.filter((p: string) => p.toLowerCase().includes(s))
|
||||
})
|
||||
|
||||
function getFilteredVersions(versions: string[]) {
|
||||
if (!versionSearch.value) return versions
|
||||
const s = versionSearch.value.toLowerCase()
|
||||
return versions.filter((v: string) => v.toLowerCase().includes(s))
|
||||
}
|
||||
|
||||
async function fetchInstalledLangs() {
|
||||
loadingLangs.value = true
|
||||
try {
|
||||
installedLangs.value = await api.mise.list()
|
||||
const plugins = new Set<string>()
|
||||
installedLangs.value.forEach((l: MiseLanguage) => plugins.add(l.plugin))
|
||||
availablePlugins.value = Array.from(plugins).sort()
|
||||
} catch (e) {
|
||||
console.error('Fetch installed langs failed', e)
|
||||
} finally {
|
||||
loadingLangs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
import { getLangIcon } from '@/utils/icons'
|
||||
|
||||
function updateAvailableVersions(lang: { name: string; version: string; availableVersions: string[] }) {
|
||||
if (lang.name) {
|
||||
lang.availableVersions = installedLangs.value
|
||||
.filter((l: MiseLanguage) => l.plugin === lang.name)
|
||||
.map((l: MiseLanguage) => l.version)
|
||||
.sort((a: string, b: string) => b.localeCompare(a, undefined, { numeric: true }))
|
||||
} else {
|
||||
lang.availableVersions = []
|
||||
}
|
||||
}
|
||||
|
||||
function addLang() {
|
||||
selectedLangs.value.push({ name: '', version: '', availableVersions: [] })
|
||||
}
|
||||
|
||||
function removeLang(index: number) {
|
||||
selectedLangs.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function updateLangName(index: number, name: string) {
|
||||
const lang = selectedLangs.value[index]
|
||||
if (!lang) return
|
||||
lang.name = name
|
||||
lang.version = '' // reset version
|
||||
updateAvailableVersions(lang)
|
||||
}
|
||||
|
||||
watch(() => props.open, async (val: boolean) => {
|
||||
if (val) {
|
||||
form.value = {
|
||||
@@ -206,20 +105,7 @@ watch(() => props.open, async (val: boolean) => {
|
||||
post_command: props.task?.post_command ?? '',
|
||||
...props.task
|
||||
}
|
||||
// 解析清理配置
|
||||
if (props.task?.clean_config) {
|
||||
try {
|
||||
const config = JSON.parse(props.task.clean_config)
|
||||
cleanType.value = config.type || 'none'
|
||||
cleanKeep.value = config.keep || 30
|
||||
} catch {
|
||||
cleanType.value = 'none'
|
||||
cleanKeep.value = 30
|
||||
}
|
||||
} else {
|
||||
cleanType.value = 'none'
|
||||
cleanKeep.value = 30
|
||||
}
|
||||
// 配置清理会在 TaskAdvancedConfig 中自动处理
|
||||
// 解析任务配置
|
||||
try {
|
||||
// 确保 config 是有效的 JSON 对象字符串
|
||||
@@ -232,30 +118,17 @@ watch(() => props.open, async (val: boolean) => {
|
||||
const parsed = JSON.parse(configStr)
|
||||
// 确保解析结果是对象
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const val = parsed['$task_concurrency']
|
||||
if (typeof val === 'number') {
|
||||
// 如果已存在并发配置,直接使用(0 或 1)
|
||||
concurrency.value = val
|
||||
concurrencyEnabled.value = val === 1
|
||||
} else {
|
||||
// 默认值:允许并发
|
||||
concurrency.value = 1
|
||||
concurrencyEnabled.value = true
|
||||
}
|
||||
|
||||
// 解析全部环境变量配置
|
||||
allEnvsEnabled.value = !!parsed['$task_all_envs']
|
||||
// 解析注释解析配置
|
||||
commentToTaskEnabled.value = !!parsed['$task_comment_to_task']
|
||||
} else {
|
||||
concurrency.value = 1
|
||||
concurrencyEnabled.value = true
|
||||
allEnvsEnabled.value = false
|
||||
commentToTaskEnabled.value = false
|
||||
}
|
||||
} catch {
|
||||
concurrency.value = 1
|
||||
concurrencyEnabled.value = true
|
||||
// ignore
|
||||
}
|
||||
// 解析环境变量
|
||||
if (props.task?.envs) {
|
||||
@@ -290,13 +163,7 @@ watch(() => props.open, async (val: boolean) => {
|
||||
? normalizeLocalWorkDirForDisplay(props.task?.work_dir)
|
||||
: (props.task?.work_dir || '')
|
||||
}
|
||||
if (selectedAgentId.value === 'local') {
|
||||
await fetchInstalledLangs()
|
||||
// 更新所有语言的可用版本
|
||||
selectedLangs.value.forEach((lang: { name: string; version: string; availableVersions: string[] }) => {
|
||||
updateAvailableVersions(lang)
|
||||
})
|
||||
}
|
||||
|
||||
// 加载通知配置
|
||||
await notificationConfigRef.value?.loadConfig(props.isEdit ? props.task?.id : undefined)
|
||||
}
|
||||
@@ -355,7 +222,6 @@ function encodeLocalWorkDir(workDir?: string | null): string {
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
form.value.clean_config = cleanConfig.value
|
||||
form.value.envs = selectedEnvIds.value.join(',')
|
||||
form.value.type = 'task'
|
||||
form.value.trigger_type = selectedTriggerType.value
|
||||
@@ -382,8 +248,6 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新并发控制字段 (1: 开启, 0: 关闭)
|
||||
config['$task_concurrency'] = concurrencyEnabled.value ? 1 : 0
|
||||
// 更新注入全部环境变量字段
|
||||
config['$task_all_envs'] = !!allEnvsEnabled.value
|
||||
// 更新注释解析字段
|
||||
@@ -443,24 +307,7 @@ async function save() {
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold">任务备注</Label>
|
||||
<Input v-model="form.remark" placeholder="输入任务备注信息 (可选)" :class="cn('sm:col-span-3 h-9 bg-muted/20 border-muted-foreground/15 transition-all focus:bg-background/50', form.remark ? 'text-sm font-medium' : 'text-[11px] font-normal')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold pt-2.5">任务标签</Label>
|
||||
<div class="sm:col-span-3 space-y-2">
|
||||
<div class="flex flex-wrap gap-1.5 p-2 min-h-[42px] bg-muted/20 border border-muted-foreground/15 rounded-md focus-within:border-primary/30 transition-colors">
|
||||
<span v-for="tag in (form.tags ? form.tags.split(',').filter(Boolean) : [])" :key="tag"
|
||||
class="flex items-center gap-1.5 bg-primary/5 text-primary px-2.5 py-1 rounded-full text-[11px] font-medium border border-primary/10 group transition-all hover:bg-primary/10">
|
||||
{{ tag }}
|
||||
<button type="button" class="text-primary/40 hover:text-destructive transition-colors shrink-0" @click.prevent="removeTag(tag)"><X class="h-3 w-3" /></button>
|
||||
</span>
|
||||
<div class="flex-1 min-w-[100px]">
|
||||
<TagInput v-model="tagInput" placeholder="输入并回车添加标签..."
|
||||
clearOnSelect
|
||||
class="h-6 border-none bg-transparent shadow-none focus-visible:ring-0 px-0 text-xs"
|
||||
@enter="addTag" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TaskTagsConfig v-model="form.tags" />
|
||||
<!-- 执行位置与触发方式 (大屏保持原样,小屏并排展示优化) -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-1 gap-2.5 sm:gap-5">
|
||||
<div class="grid sm:grid-cols-4 items-center gap-1 sm:gap-3 min-w-0">
|
||||
@@ -511,30 +358,10 @@ async function save() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3 mt-2">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold pt-2.5">语言环境</Label>
|
||||
<div class="sm:col-span-3 space-y-2">
|
||||
<div v-for="(clang, idx) in selectedLangs" :key="idx" class="flex gap-2 p-2 rounded-lg bg-muted/20 border border-muted-foreground/10 group/lang relative overflow-hidden">
|
||||
<div class="absolute left-0 top-0 bottom-0 w-0.5 bg-primary/20 group-hover/lang:bg-primary transition-colors" />
|
||||
<Popover>
|
||||
<PopoverTrigger asChild><Button variant="ghost" class="justify-between flex-1 h-8 text-xs font-normal hover:bg-background/50"><div class="flex items-center gap-2 truncate"><div v-if="clang.name && getLangIcon(clang.name)" class="w-4 h-4 shrink-0 rounded-sm bg-white p-0.5 border shadow-sm"><img :src="getLangIcon(clang.name)" class="w-full h-full object-contain" /></div><span class="font-medium">{{ clang.name || "选择环境..." }}</span></div><ChevronsUpDown class="ml-1 h-3 w-3 opacity-40" /></Button></PopoverTrigger>
|
||||
<PopoverContent class="p-0 w-[240px]" align="start">
|
||||
<div class="p-2 border-b bg-muted/30"><div class="relative"><Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="pluginSearch" placeholder="搜索已安装语言..." :class="cn('h-8 pl-8 bg-background border-muted-foreground/20', pluginSearch ? 'text-xs font-medium' : 'text-[10px]')" /></div></div>
|
||||
<ScrollArea class="h-48 p-1">
|
||||
<div v-if="loadingLangs" class="flex items-center justify-center py-6"><Loader2 class="h-5 w-5 animate-spin text-primary/50" /></div>
|
||||
<button v-else v-for="p in filteredPlugins" :key="p" @click="updateLangName(idx, p)" class="w-full flex items-center px-3 py-2 text-xs rounded-md hover:bg-accent text-left transition-all mb-0.5"><span class="flex-1" :class="{ 'font-bold text-primary': clang.name === p }">{{ p }}</span><Check v-if="clang.name === p" class="h-3 w-3 text-primary" /></button>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild :disabled="!clang.name"><Button variant="ghost" class="justify-between w-28 h-8 text-xs font-normal hover:bg-background/50" :disabled="!clang.name"><span class="truncate">{{ clang.version || "版本..." }}</span><ChevronsUpDown class="h-3 w-3 opacity-40 ml-1" /></Button></PopoverTrigger>
|
||||
<PopoverContent class="p-0 w-[160px]" align="start">
|
||||
<ScrollArea class="h-48 p-1"><button v-for="v in getFilteredVersions(clang.availableVersions)" :key="v" @click="clang.version = v" class="w-full flex items-center px-3 py-2 text-xs rounded-md hover:bg-accent font-mono mb-0.5"><span class="flex-1 truncate" :class="{ 'font-bold text-primary': clang.version === v }">{{ v }}</span><Check v-if="clang.version === v" class="h-3 w-3 text-primary" /></button></ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0" @click="removeLang(idx)"><X class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="w-full h-9 text-xs border-dashed border-muted-foreground/30 text-muted-foreground hover:text-primary hover:border-primary/50 transition-all bg-muted/5 hover:bg-primary/5 rounded-xl" @click="addLang"><Plus class="h-4 w-4 mr-2" /> 添加运行时环境 (Mise)</Button>
|
||||
<TaskLangConfig v-model="selectedLangs" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -623,87 +450,11 @@ async function save() {
|
||||
</div>
|
||||
<div class="grid gap-5 pl-3 border-l border-muted">
|
||||
<template v-if="selectedTriggerType === TRIGGER_TYPE.CRON">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold">定时规则</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<Input v-model="form.schedule" placeholder="秒 分 时 日 月 周 (必须 6 位)" :class="cn('h-9 bg-muted/30 border-muted-foreground/20 transition-all focus:ring-1 focus:ring-primary/40 focus:border-primary/40', form.schedule ? 'font-mono text-sm tracking-[0.1em] font-medium' : 'text-[11px] font-normal')" />
|
||||
<div v-if="cronDescription" class="mt-2.5 p-2 px-3 rounded-xl bg-primary/5 border border-primary/10 text-[11px] text-primary/80 font-medium flex items-center gap-2.5"><Zap class="h-3 w-3 text-primary" />{{ cronDescription }}</div>
|
||||
<div class="mt-3 flex flex-wrap gap-1.5"><button v-for="preset in cronPresets" :key="preset.value" class="px-2.5 py-1 text-[10px] rounded-lg bg-muted/50 border border-muted-foreground/10 hover:border-primary/50 hover:bg-primary/5 hover:text-primary transition-all font-medium" @click.prevent="form.schedule = preset.value">{{ preset.label }}</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-semibold">随机延迟</Label>
|
||||
<div class="sm:col-span-3 flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input :model-value="form.random_range" @update:model-value="(v: string | number) => form.random_range = Number(v || 0)" type="number" :min="0" class="w-20 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-xs font-semibold text-muted-foreground">秒</span>
|
||||
</div>
|
||||
<div class="flex-1 text-[11px] text-muted-foreground leading-snug p-2 rounded-lg bg-blue-500/5 border border-blue-500/10 italic">
|
||||
基准时间后随机延迟 0~{{ form.random_range || 0 }}s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TaskCronConfig v-model="form.schedule" />
|
||||
</template>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-semibold">执行超时</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input :model-value="form.timeout" @update:model-value="(v: string | number) => form.timeout = Number(v)" type="number" :min="0" class="w-20 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-[11px] font-semibold text-muted-foreground">分钟超时</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-semibold">失败策略</Label>
|
||||
<div class="sm:col-span-3 flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[11px] text-muted-foreground font-semibold">重试</span>
|
||||
<Input :model-value="form.retry_count" @update:model-value="(v: string | number) => form.retry_count = Number(v)" type="number" :min="0" class="w-16 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-[11px] text-muted-foreground font-semibold">次,间隔</span>
|
||||
<Input :model-value="form.retry_interval" @update:model-value="(v: string | number) => form.retry_interval = Number(v)" type="number" :min="0" class="w-16 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-[11px] text-muted-foreground font-semibold">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-semibold">日志清理</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Select :model-value="cleanType" @update:model-value="(v: any) => cleanType = String(v || 'none')">
|
||||
<SelectTrigger class="w-28 h-9 text-xs bg-muted/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">保留日志</SelectItem>
|
||||
<SelectItem value="day">按天清理</SelectItem>
|
||||
<SelectItem value="count">按条清理</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="cleanType && cleanType !== 'none'" class="flex items-center gap-2">
|
||||
<Input :model-value="cleanKeep" @update:model-value="(v: string | number) => cleanKeep = Number(v || 30)" type="number" class="w-20 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-[11px] font-semibold text-muted-foreground">{{ cleanType === 'day' ? '天' : '条' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-semibold">运行策略</Label>
|
||||
<div class="sm:col-span-3 space-y-4">
|
||||
<div class="p-3 rounded-xl bg-muted/20 border border-muted-foreground/10 space-y-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-xs font-semibold">
|
||||
<Zap :class="cn('h-3.5 w-3.5', concurrencyEnabled ? 'text-primary' : 'text-muted-foreground')" />
|
||||
并发控制
|
||||
</div>
|
||||
<Switch :model-value="concurrencyEnabled" @update:model-value="v => concurrencyEnabled = v" />
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{{ concurrencyEnabled ? '允许同时开启多个副本。' : '当前任务未结束时,新触发将被静默忽略。' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TaskAdvancedConfig v-model="form" />
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<TaskNotificationConfig ref="notificationConfigRef" :task-id="isEdit ? task?.id : undefined" />
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Zap } from 'lucide-vue-next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Task } from '@/api'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: Partial<Task>
|
||||
showRetry?: boolean
|
||||
}>(), {
|
||||
showRetry: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Partial<Task>]
|
||||
}>()
|
||||
|
||||
const form = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// === 日志清理配置 ===
|
||||
const cleanType = ref('none')
|
||||
const cleanKeep = ref(30)
|
||||
|
||||
// 解析初始清理配置
|
||||
function parseCleanConfig() {
|
||||
if (form.value.clean_config) {
|
||||
try {
|
||||
const config = JSON.parse(form.value.clean_config)
|
||||
cleanType.value = config.type || 'none'
|
||||
cleanKeep.value = config.keep || 30
|
||||
} catch {
|
||||
cleanType.value = 'none'
|
||||
cleanKeep.value = 30
|
||||
}
|
||||
} else {
|
||||
cleanType.value = 'none'
|
||||
cleanKeep.value = 30
|
||||
}
|
||||
}
|
||||
|
||||
// 保存清理配置
|
||||
function updateCleanConfig() {
|
||||
if (!cleanType.value || cleanType.value === 'none' || cleanKeep.value <= 0) {
|
||||
form.value.clean_config = ''
|
||||
} else {
|
||||
form.value.clean_config = JSON.stringify({ type: cleanType.value, keep: cleanKeep.value })
|
||||
}
|
||||
}
|
||||
|
||||
watch(cleanType, updateCleanConfig)
|
||||
watch(cleanKeep, updateCleanConfig)
|
||||
|
||||
// === 并发控制配置 ===
|
||||
const concurrencyEnabled = ref(false)
|
||||
|
||||
// 解析初始并发配置
|
||||
function parseConcurrencyConfig() {
|
||||
let configStr = form.value.config
|
||||
if (!configStr) {
|
||||
concurrencyEnabled.value = true // 默认开启
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(configStr)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const val = parsed['$task_concurrency']
|
||||
if (typeof val === 'number') {
|
||||
concurrencyEnabled.value = val === 1
|
||||
} else {
|
||||
concurrencyEnabled.value = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
concurrencyEnabled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 保存并发配置
|
||||
function updateConcurrencyConfig(enabled: boolean) {
|
||||
concurrencyEnabled.value = enabled
|
||||
let config: Record<string, any> = {}
|
||||
if (form.value.config) {
|
||||
try {
|
||||
const parsed = JSON.parse(form.value.config)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
config = parsed
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
config['$task_concurrency'] = enabled ? 1 : 0
|
||||
form.value.config = JSON.stringify(config)
|
||||
}
|
||||
|
||||
// 初始化解析
|
||||
watch(() => form.value.id, () => {
|
||||
parseCleanConfig()
|
||||
parseConcurrencyConfig()
|
||||
}, { immediate: true })
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 随机延迟 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-semibold">随机延迟</Label>
|
||||
<div class="sm:col-span-3 flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input :model-value="form.random_range" @update:model-value="(v: string | number) => form.random_range = Number(v || 0)" type="number" :min="0" class="w-20 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-xs font-semibold text-muted-foreground">秒</span>
|
||||
</div>
|
||||
<div class="flex-1 text-[11px] text-muted-foreground leading-snug p-2 rounded-lg bg-blue-500/5 border border-blue-500/10 italic">
|
||||
基准时间后随机延迟 0~{{ form.random_range || 0 }}s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 执行超时 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-semibold">执行超时</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input :model-value="form.timeout" @update:model-value="(v: string | number) => form.timeout = Number(v || 0)" type="number" :min="0" class="w-20 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-[11px] font-semibold text-muted-foreground">分钟超时</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 失败策略 -->
|
||||
<div v-if="showRetry" class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-semibold">失败策略</Label>
|
||||
<div class="sm:col-span-3 flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[11px] text-muted-foreground font-semibold">重试</span>
|
||||
<Input :model-value="form.retry_count" @update:model-value="(v: string | number) => form.retry_count = Number(v || 0)" type="number" :min="0" class="w-16 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-[11px] text-muted-foreground font-semibold">次,间隔</span>
|
||||
<Input :model-value="form.retry_interval" @update:model-value="(v: string | number) => form.retry_interval = Number(v || 0)" type="number" :min="0" class="w-16 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-[11px] text-muted-foreground font-semibold">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志清理 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-semibold">日志清理</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Select v-model="cleanType">
|
||||
<SelectTrigger class="w-28 h-9 text-xs bg-muted/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">保留日志</SelectItem>
|
||||
<SelectItem value="day">按天清理</SelectItem>
|
||||
<SelectItem value="count">按条清理</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="cleanType && cleanType !== 'none'" class="flex items-center gap-2">
|
||||
<Input v-model="cleanKeep" type="number" class="w-20 h-9 bg-muted/30 text-center font-semibold text-xs" />
|
||||
<span class="text-[11px] font-semibold text-muted-foreground">{{ cleanType === 'day' ? '天' : '条' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 运行策略 (包含并发控制,支持外部通过 slot 注入自动添加任务等) -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-semibold">运行策略</Label>
|
||||
<div class="sm:col-span-3 space-y-4">
|
||||
<!-- 供 RepoDialog 插入特有的自动添加任务开关等 -->
|
||||
<slot name="run-strategy-prepend"></slot>
|
||||
|
||||
<div class="p-3 rounded-xl bg-muted/20 border border-muted-foreground/10 space-y-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-xs font-semibold">
|
||||
<Zap :class="cn('h-3.5 w-3.5', concurrencyEnabled ? 'text-primary' : 'text-muted-foreground')" />
|
||||
并发控制
|
||||
</div>
|
||||
<Switch :model-value="concurrencyEnabled" @update:model-value="updateConcurrencyConfig" />
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{{ concurrencyEnabled ? '允许同时开启多个副本。' : '当前任务/同步未结束时,新触发将被静默忽略。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<slot name="run-strategy-append"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Zap, Clock } from 'lucide-vue-next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getCronDescription } from '@/utils/cron'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const schedule = computed({
|
||||
get: () => props.modelValue || '',
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const cronPresets = [
|
||||
{ label: '每5秒', value: '*/5 * * * * *' },
|
||||
{ label: '每30秒', value: '*/30 * * * * *' },
|
||||
{ label: '每分钟', value: '0 * * * * *' },
|
||||
{ label: '每5分钟', value: '0 */5 * * * *' },
|
||||
{ label: '每小时', value: '0 0 * * * *' },
|
||||
{ label: '每天0点', value: '0 0 0 * * *' },
|
||||
{ label: '每天8点', value: '0 0 8 * * *' },
|
||||
{ label: '每周一', value: '0 0 0 * * 1' },
|
||||
{ label: '每月1号', value: '0 0 0 1 * *' },
|
||||
]
|
||||
|
||||
const cronDescription = computed(() => {
|
||||
if (!schedule.value) return ''
|
||||
return getCronDescription(schedule.value, (navigator as any).language)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||
<div class="sm:text-right flex sm:block items-center gap-2">
|
||||
<slot name="label">
|
||||
<label class="text-xs text-foreground/70 uppercase tracking-wider font-bold">定时规则</label>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="sm:col-span-3">
|
||||
<Input v-model="schedule" placeholder="秒 分 时 日 月 周 (必须 6 位)" :class="cn('h-9 bg-muted/30 border-muted-foreground/20 transition-all focus:ring-1 focus:ring-primary/40 focus:border-primary/40', schedule ? 'font-mono text-sm tracking-[0.1em] font-medium' : 'text-[11px] font-normal')" autocomplete="off" />
|
||||
<div v-if="cronDescription" class="mt-2.5 p-2 rounded-lg bg-primary/5 border border-primary/10 text-[11px] text-primary font-medium flex items-center gap-2 animate-in fade-in slide-in-from-top-1 duration-300">
|
||||
<Zap class="h-3 w-3 shrink-0" />
|
||||
{{ cronDescription }}
|
||||
</div>
|
||||
<div class="mt-2.5 space-y-2">
|
||||
<div class="flex items-center gap-1.5 text-[10px] text-muted-foreground/70 uppercase font-bold tracking-tighter">
|
||||
<Clock class="h-3 w-3" /> 格式指导: 秒 分 时 日 月 周
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="preset in cronPresets" :key="preset.value"
|
||||
class="px-2 py-1 text-[10px] rounded-md bg-muted/50 border border-muted-foreground/10 hover:border-primary/50 hover:bg-primary/5 hover:text-primary transition-all font-medium"
|
||||
@click.prevent="schedule = preset.value">
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Check, ChevronsUpDown, Plus, Search, X, Loader2 } from 'lucide-vue-next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api, type MiseLanguage } from '@/api'
|
||||
import { getLangIcon } from '@/utils/icons'
|
||||
|
||||
export interface LangConfig {
|
||||
name: string
|
||||
version: string
|
||||
availableVersions: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: LangConfig[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: LangConfig[]]
|
||||
}>()
|
||||
|
||||
const selectedLangs = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const installedLangs = ref<MiseLanguage[]>([])
|
||||
const loadingLangs = ref(false)
|
||||
const availablePlugins = ref<string[]>([])
|
||||
const pluginSearch = ref('')
|
||||
const versionSearch = ref('')
|
||||
|
||||
const filteredPlugins = computed(() => {
|
||||
if (!pluginSearch.value) return availablePlugins.value
|
||||
const s = pluginSearch.value.toLowerCase()
|
||||
return availablePlugins.value.filter((p: string) => p.toLowerCase().includes(s))
|
||||
})
|
||||
|
||||
function getFilteredVersions(versions: string[]) {
|
||||
if (!versionSearch.value) return versions
|
||||
const s = versionSearch.value.toLowerCase()
|
||||
return versions.filter((v: string) => v.toLowerCase().includes(s))
|
||||
}
|
||||
|
||||
async function fetchInstalledLangs() {
|
||||
loadingLangs.value = true
|
||||
try {
|
||||
installedLangs.value = await api.mise.list()
|
||||
const plugins = new Set<string>()
|
||||
installedLangs.value.forEach((l: MiseLanguage) => plugins.add(l.plugin))
|
||||
availablePlugins.value = Array.from(plugins).sort()
|
||||
|
||||
// 刷新已选语言的可用版本
|
||||
selectedLangs.value.forEach(lang => updateAvailableVersions(lang))
|
||||
} catch (e) {
|
||||
console.error('Fetch installed langs failed', e)
|
||||
} finally {
|
||||
loadingLangs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateAvailableVersions(lang: LangConfig) {
|
||||
if (lang.name) {
|
||||
lang.availableVersions = installedLangs.value
|
||||
.filter((l: MiseLanguage) => l.plugin === lang.name)
|
||||
.map((l: MiseLanguage) => l.version)
|
||||
.sort((a: string, b: string) => b.localeCompare(a, undefined, { numeric: true }))
|
||||
} else {
|
||||
lang.availableVersions = []
|
||||
}
|
||||
}
|
||||
|
||||
function addLang() {
|
||||
const newList = [...selectedLangs.value, { name: '', version: '', availableVersions: [] }]
|
||||
emit('update:modelValue', newList)
|
||||
}
|
||||
|
||||
function removeLang(index: number) {
|
||||
const newList = [...selectedLangs.value]
|
||||
newList.splice(index, 1)
|
||||
emit('update:modelValue', newList)
|
||||
}
|
||||
|
||||
function updateLangName(index: number, name: string) {
|
||||
const lang = selectedLangs.value[index]
|
||||
if (!lang) return
|
||||
lang.name = name
|
||||
lang.version = '' // reset version
|
||||
updateAvailableVersions(lang)
|
||||
}
|
||||
|
||||
// 当组件挂载时加载语言环境列表
|
||||
onMounted(() => {
|
||||
fetchInstalledLangs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(clang, idx) in selectedLangs" :key="idx" class="flex gap-2 p-2 rounded-lg bg-muted/20 border border-muted-foreground/10 group/lang relative overflow-hidden">
|
||||
<div class="absolute left-0 top-0 bottom-0 w-0.5 bg-primary/20 group-hover/lang:bg-primary transition-colors" />
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" class="justify-between flex-1 h-8 text-xs font-normal hover:bg-background/50">
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<div v-if="clang.name && getLangIcon(clang.name)" class="w-4 h-4 shrink-0 rounded-sm bg-white p-0.5 border shadow-sm">
|
||||
<img :src="getLangIcon(clang.name)" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<span class="font-medium">{{ clang.name || "选择环境..." }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-1 h-3 w-3 opacity-40" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0 w-[240px]" align="start">
|
||||
<div class="p-2 border-b bg-muted/30">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="pluginSearch" placeholder="搜索已安装语言..." :class="cn('h-8 pl-8 bg-background border-muted-foreground/20', pluginSearch ? 'text-xs font-medium' : 'text-[10px]')" />
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea class="h-48 p-1">
|
||||
<div v-if="loadingLangs" class="flex items-center justify-center py-6">
|
||||
<Loader2 class="h-5 w-5 animate-spin text-primary/50" />
|
||||
</div>
|
||||
<button v-else v-for="p in filteredPlugins" :key="p" @click="updateLangName(idx, p)" class="w-full flex items-center px-3 py-2 text-xs rounded-md hover:bg-accent text-left transition-all mb-0.5">
|
||||
<span class="flex-1" :class="{ 'font-bold text-primary': clang.name === p }">{{ p }}</span>
|
||||
<Check v-if="clang.name === p" class="h-3 w-3 text-primary" />
|
||||
</button>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild :disabled="!clang.name">
|
||||
<Button variant="ghost" class="justify-between w-28 h-8 text-xs font-normal hover:bg-background/50" :disabled="!clang.name">
|
||||
<span class="truncate">{{ clang.version || "版本..." }}</span>
|
||||
<ChevronsUpDown class="h-3 w-3 opacity-40 ml-1" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0 w-[160px]" align="start">
|
||||
<ScrollArea class="h-48 p-1">
|
||||
<div v-if="getFilteredVersions(clang.availableVersions).length === 0" class="py-6 text-center text-xs text-muted-foreground">
|
||||
无可用版本
|
||||
</div>
|
||||
<button v-else v-for="v in getFilteredVersions(clang.availableVersions)" :key="v" @click="clang.version = v" class="w-full flex items-center px-3 py-2 text-xs rounded-md hover:bg-accent font-mono mb-0.5 text-left">
|
||||
<span class="flex-1 truncate" :class="{ 'font-bold text-primary': clang.version === v }">{{ v }}</span>
|
||||
<Check v-if="clang.version === v" class="h-3 w-3 text-primary" />
|
||||
</button>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0" @click="removeLang(idx)">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" class="w-full h-9 text-xs border-dashed border-muted-foreground/30 text-muted-foreground hover:text-primary hover:border-primary/50 transition-all bg-muted/5 hover:bg-primary/5 rounded-xl" @click="addLang">
|
||||
<Plus class="h-4 w-4 mr-2" /> 添加运行时环境 (Mise)
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import TagInput from '@/components/TagInput.vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const tagInput = ref('')
|
||||
|
||||
const tagsList = computed(() => {
|
||||
return props.modelValue ? props.modelValue.split(',').filter(Boolean) : []
|
||||
})
|
||||
|
||||
function addTag(passedTag?: string) {
|
||||
const val = (passedTag || tagInput.value).trim()
|
||||
if (!val) return
|
||||
const currentTags = [...tagsList.value]
|
||||
if (!currentTags.includes(val)) {
|
||||
currentTags.push(val)
|
||||
emit('update:modelValue', currentTags.join(','))
|
||||
}
|
||||
tagInput.value = ''
|
||||
}
|
||||
|
||||
function removeTag(tagToRemove: string) {
|
||||
const currentTags = tagsList.value.filter(t => t !== tagToRemove)
|
||||
emit('update:modelValue', currentTags.join(','))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3">
|
||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold pt-2.5">任务标签</Label>
|
||||
<div class="sm:col-span-3 space-y-2">
|
||||
<div class="flex flex-wrap gap-1.5 p-2 min-h-[42px] bg-muted/20 border border-muted-foreground/15 rounded-md focus-within:border-primary/30 transition-colors">
|
||||
<span v-for="tag in tagsList" :key="tag"
|
||||
class="flex items-center gap-1.5 bg-primary/5 text-primary px-2.5 py-1 rounded-full text-[11px] font-medium border border-primary/10 group transition-all hover:bg-primary/10">
|
||||
{{ tag }}
|
||||
<button type="button" class="text-primary/40 hover:text-destructive transition-colors shrink-0" @click.prevent="removeTag(tag)"><X class="h-3 w-3" /></button>
|
||||
</span>
|
||||
<div class="flex-1 min-w-[100px]">
|
||||
<TagInput v-model="tagInput" placeholder="输入并回车添加标签..."
|
||||
clearOnSelect
|
||||
class="h-6 border-none bg-transparent shadow-none focus-visible:ring-0 px-0 text-xs"
|
||||
@enter="addTag" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user