refactor(web): extract common config components for task and repo dialogs

This commit is contained in:
duorameng
2026-06-05 19:58:37 +08:00
parent e0d729e50e
commit d0c6f836ff
7 changed files with 529 additions and 602 deletions
+24 -334
View File
@@ -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>