Files
toolbox/resources/js/components/admin/JenkinsBuilds.vue
T
tradewind ba916f5ce1 #feature: add Jenkins batch build trigger
Add an admin Jenkins build page that reuses notification-enabled project config, loads Jenkins build parameters, supports persisted selections, and triggers selected jobs in one action.
2026-06-01 18:03:33 +08:00

483 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="space-y-2">
<div class="flex items-center justify-between bg-white px-4 py-2 rounded-lg shadow-sm border border-gray-200">
<div>
<h3 class="text-base font-bold text-gray-800">Jenkins 一键构建</h3>
<p class="text-xs text-gray-500">勾选 Jenkins 通知项目调整参数后批量触发 Build</p>
</div>
<div class="flex items-center gap-2">
<button
@click="loadProjects"
:disabled="loading || triggering"
class="px-2.5 py-1.5 bg-gray-100 text-gray-700 text-xs font-medium rounded hover:bg-gray-200 disabled:opacity-50 flex items-center gap-1"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4" :class="{'animate-spin': loading}">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0v2.433l-.31-.31a7 7 0 00-11.712 3.138.75.75 0 001.449.39 5.5 5.5 0 019.201-2.466l.312.312h-2.433a.75.75 0 000 1.5h4.185a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
刷新
</button>
<button
@click="triggerBuilds"
:disabled="triggering || selectedProjects.length === 0"
class="px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
>
<svg v-if="!triggering" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M6.3 2.84A1.5 1.5 0 004 4.11v11.78a1.5 1.5 0 002.3 1.27l9.34-5.89a1.5 1.5 0 000-2.54L6.3 2.84z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 animate-spin">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39z" clip-rule="evenodd" />
</svg>
{{ triggering ? '触发中...' : `触发选中 (${selectedProjects.length})` }}
</button>
</div>
</div>
<div v-if="message" class="text-xs text-green-600 bg-green-50 px-3 py-2 rounded border border-green-100">{{ message }}</div>
<div v-if="error" class="text-xs text-red-600 bg-red-50 px-3 py-2 rounded border border-red-100">{{ error }}</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200 flex justify-between items-center">
<div class="flex items-center gap-2">
<h4 class="font-semibold text-gray-700 text-sm">可构建项目</h4>
<span class="text-xs text-gray-400">{{ projects.length }} 个项目</span>
</div>
<label v-if="projects.length > 0" class="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" :checked="isAllSelected" @change="toggleAll" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
全选
</label>
</div>
<div v-if="loading" class="p-10 text-center text-sm text-gray-400">加载 Jenkins 项目中...</div>
<div v-else-if="projects.length === 0" class="p-10 text-center text-sm text-gray-400">
暂无启用 Jenkins 发布通知且配置 Job 名称的项目
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-100 table-fixed">
<thead class="bg-gray-50">
<tr>
<th class="w-60 px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">项目 / Job</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Build 参数</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<tr v-for="project in projects" :key="project.slug" class="hover:bg-gray-50 align-top">
<td class="px-4 py-2">
<label class="flex items-start gap-2 cursor-pointer">
<input v-model="project.selected" type="checkbox" class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" @change="savePreferences" />
<span class="min-w-0 leading-tight">
<span class="flex items-center gap-1.5 min-w-0">
<span class="font-mono text-sm font-semibold text-gray-800 truncate">{{ project.slug }}</span>
<span class="text-[11px] text-gray-500 truncate" :title="project.name">{{ project.name }}</span>
</span>
<span class="block font-mono text-[11px] text-gray-400 truncate" :title="project.jenkins_job_name">{{ project.jenkins_job_name }}</span>
</span>
</label>
</td>
<td class="px-4 py-2">
<div v-if="project.parameters.length === 0" class="text-xs text-gray-400 leading-7">
无参数直接触发 build
</div>
<div v-else class="space-y-2">
<div class="grid grid-cols-[7rem_9rem_5.5rem_5rem_8rem_8rem] gap-2 items-start">
<template v-for="parameterName in primaryParameterOrder" :key="parameterName">
<parameter-control
v-if="parameterByName(project, parameterName)"
:project="project"
:parameter="parameterByName(project, parameterName)"
:triggering="triggering"
class="w-full"
@save="savePreferences"
@toggle-multi="toggleMultiValue"
/>
<div v-else class="h-8"></div>
</template>
</div>
<div v-if="hasSecondaryParameters(project)" class="flex flex-wrap gap-2 border-t border-gray-100 pt-2">
<div class="grid grid-cols-[7rem_minmax(0,1fr)] gap-2 items-start w-full">
<parameter-control
v-if="parameterByName(project, 'region')"
:project="project"
:parameter="parameterByName(project, 'region')"
:triggering="triggering"
class="w-full"
@save="savePreferences"
@toggle-multi="toggleMultiValue"
/>
<div v-else class="min-h-[4.25rem]"></div>
<parameter-control
v-if="parameterByName(project, 'project')"
:project="project"
:parameter="parameterByName(project, 'project')"
:triggering="triggering"
class="w-full"
@save="savePreferences"
@toggle-multi="toggleMultiValue"
/>
<div v-else class="min-h-[4.25rem]"></div>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="results.length > 0" class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="bg-gray-50 px-3 py-2 border-b border-gray-200">
<h4 class="font-semibold text-gray-700 text-sm">触发结果</h4>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">项目</th>
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Job</th>
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">队列</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="result in results" :key="result.project_slug">
<td class="px-3 py-2 text-xs text-gray-800">{{ result.project_name }}</td>
<td class="px-3 py-2 text-xs font-mono text-gray-600">{{ result.job_name }}</td>
<td class="px-3 py-2 text-xs">
<span :class="result.success ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'" class="px-2 py-0.5 rounded text-xs">
{{ result.success ? '已触发' : (result.message || '失败') }}
</span>
</td>
<td class="px-3 py-2 text-xs">
<a v-if="result.queue_url" :href="result.queue_url" target="_blank" rel="noopener" class="text-blue-600 hover:text-blue-800 break-all">{{ result.queue_url }}</a>
<span v-else class="text-gray-400">-</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
const ParameterControl = {
name: 'ParameterControl',
props: {
project: { type: Object, required: true },
parameter: { type: Object, required: true },
triggering: { type: Boolean, default: false }
},
emits: ['save', 'toggle-multi'],
methods: {
normalizedChoices(parameter) {
return (parameter.choices || []).map((choice) => {
if (choice && typeof choice === 'object') {
return {
value: choice.value,
label: choice.label || choice.value
};
}
return {
value: choice,
label: choice
};
});
},
isBooleanParameter(parameter) {
return String(parameter.type || '').toLowerCase().includes('boolean');
},
isMultiSelected(project, parameter, value) {
return (project.values[parameter.name] || []).includes(value);
},
formatDefault(value) {
if (value === null || value === undefined || value === '') {
return '-';
}
return String(value);
}
},
template: `
<label
class="inline-flex items-center min-h-8 max-w-full rounded border border-gray-200 bg-white overflow-hidden"
:class="{ 'min-h-[4.25rem]': parameter.name === 'project' }"
:title="parameter.description || parameter.name"
>
<span class="self-stretch px-2 text-[11px] font-medium text-gray-500 bg-gray-50 border-r border-gray-200 inline-flex items-center whitespace-nowrap">
{{ parameter.name }}
</span>
<select
v-if="!parameter.multiple && parameter.choices && parameter.choices.length > 0"
v-model="project.values[parameter.name]"
:disabled="triggering"
class="h-8 min-w-20 max-w-32 px-2 text-xs border-0 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400"
@change="$emit('save')"
>
<option v-for="choice in normalizedChoices(parameter)" :key="choice.value" :value="choice.value">{{ choice.label }}</option>
</select>
<span v-else-if="parameter.multiple" class="px-2 py-1 grid grid-rows-2 grid-flow-col auto-cols-max gap-1.5 min-h-[4.25rem] max-w-full content-start overflow-x-auto">
<label
v-for="choice in normalizedChoices(parameter)"
:key="choice.value"
class="inline-flex items-center gap-1 rounded bg-gray-50 px-1.5 py-0.5 text-xs text-gray-700 border border-gray-200 cursor-pointer hover:bg-blue-50 hover:border-blue-200"
>
<input
:checked="isMultiSelected(project, parameter, choice.value)"
type="checkbox"
:disabled="triggering"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
@change="$emit('toggle-multi', project, parameter.name, choice.value)"
/>
{{ choice.label }}
</label>
</span>
<span v-else-if="isBooleanParameter(parameter)" class="px-2 inline-flex items-center gap-1 text-xs text-gray-700">
<input
v-model="project.values[parameter.name]"
type="checkbox"
:disabled="triggering"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
@change="$emit('save')"
/>
</span>
<input
v-else
v-model="project.values[parameter.name]"
type="text"
:disabled="triggering"
class="h-8 w-28 px-2 text-xs border-0 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400"
:placeholder="formatDefault(parameter.default)"
@input="$emit('save')"
/>
</label>
`
};
export default {
name: 'JenkinsBuilds',
components: {
ParameterControl
},
preferenceKey: 'toolbox.jenkinsBuilds.preferences.v1',
data() {
return {
loading: false,
triggering: false,
projects: [],
results: [],
primaryParameterOrder: ['env', 'branchName', 'deploy', 'sql', 'masterCheck', 'deployVersion'],
message: '',
error: ''
};
},
computed: {
selectedProjects() {
return this.projects.filter((project) => project.selected);
},
isAllSelected() {
return this.projects.length > 0 && this.selectedProjects.length === this.projects.length;
}
},
async mounted() {
await this.loadProjects();
},
methods: {
async loadProjects() {
this.loading = true;
this.error = '';
this.message = '';
try {
const response = await fetch('/api/admin/jenkins/build-projects', {
headers: { Accept: 'application/json' }
});
const data = await response.json();
if (!response.ok || !data.success) {
this.error = data.message || '加载 Jenkins 项目失败';
return;
}
const preferences = this.loadPreferences();
this.projects = (data.data.projects || []).map((project) => {
const parameters = project.parameters || [];
const defaults = this.defaultValues(parameters);
const saved = preferences[this.preferenceProjectKey(project)] || {};
return {
...project,
selected: Boolean(saved.selected),
parameters,
values: this.mergeSavedValues(defaults, saved.values || {}, parameters)
};
});
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
defaultValues(parameters) {
return parameters.reduce((values, parameter) => {
if (parameter.multiple) {
values[parameter.name] = Array.isArray(parameter.default) ? parameter.default : [];
} else if (parameter.default !== null && parameter.default !== undefined) {
values[parameter.name] = parameter.default;
} else if (parameter.choices && parameter.choices.length > 0) {
values[parameter.name] = this.normalizedChoices(parameter)[0]?.value || '';
} else if (this.isBooleanParameter(parameter)) {
values[parameter.name] = false;
} else {
values[parameter.name] = '';
}
return values;
}, {});
},
mergeSavedValues(defaults, savedValues, parameters) {
const merged = { ...defaults };
const parameterNames = new Set(parameters.map((parameter) => parameter.name));
Object.entries(savedValues).forEach(([name, value]) => {
if (!parameterNames.has(name)) {
return;
}
merged[name] = Array.isArray(defaults[name]) ? (Array.isArray(value) ? value : []) : value;
});
return merged;
},
loadPreferences() {
try {
return JSON.parse(window.localStorage.getItem(this.$options.preferenceKey) || '{}');
} catch (error) {
return {};
}
},
savePreferences() {
const preferences = this.projects.reduce((payload, project) => {
payload[this.preferenceProjectKey(project)] = {
selected: Boolean(project.selected),
values: project.values || {}
};
return payload;
}, {});
window.localStorage.setItem(this.$options.preferenceKey, JSON.stringify(preferences));
},
preferenceProjectKey(project) {
return project.jenkins_job_name || project.slug;
},
isBooleanParameter(parameter) {
return String(parameter.type || '').toLowerCase().includes('boolean');
},
normalizedChoices(parameter) {
return (parameter.choices || []).map((choice) => {
if (choice && typeof choice === 'object') {
return {
value: choice.value,
label: choice.label || choice.value,
selected: Boolean(choice.selected)
};
}
return {
value: choice,
label: choice,
selected: false
};
});
},
isMultiSelected(project, parameter, value) {
return (project.values[parameter.name] || []).includes(value);
},
hasSecondaryParameters(project) {
return Boolean(this.parameterByName(project, 'region') || this.parameterByName(project, 'project'));
},
parameterByName(project, name) {
return project.parameters.find((parameter) => parameter.name === name);
},
toggleMultiValue(project, parameterName, value) {
const current = project.values[parameterName] || [];
if (current.includes(value)) {
project.values[parameterName] = current.filter((item) => item !== value);
this.savePreferences();
return;
}
project.values[parameterName] = [...current, value];
this.savePreferences();
},
formatDefault(value) {
if (value === null || value === undefined || value === '') {
return '-';
}
return String(value);
},
toggleAll() {
const nextValue = !this.isAllSelected;
this.projects.forEach((project) => {
project.selected = nextValue;
});
this.savePreferences();
},
async triggerBuilds() {
if (this.selectedProjects.length === 0) {
this.error = '请至少选择一个项目';
return;
}
if (!window.confirm(`确认触发 ${this.selectedProjects.length} 个 Jenkins Build 吗?`)) {
return;
}
this.triggering = true;
this.error = '';
this.message = '';
this.results = [];
try {
const response = await fetch('/api/admin/jenkins/trigger-builds', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
builds: this.selectedProjects.map((project) => ({
project_slug: project.slug,
parameters: project.values
}))
})
});
const data = await response.json();
this.results = data.data?.results || [];
if (!response.ok || !data.success) {
this.error = data.message || '触发失败';
return;
}
this.message = data.message || '触发成功';
} catch (error) {
this.error = error.message;
} finally {
this.triggering = false;
}
}
}
}
</script>