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.
483 lines
19 KiB
Vue
483 lines
19 KiB
Vue
<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>
|