#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.
This commit is contained in:
2026-06-01 18:03:33 +08:00
parent 1de5fe1ff9
commit ba916f5ce1
7 changed files with 867 additions and 27 deletions
+219 -10
View File
@@ -2,14 +2,18 @@
namespace App\Clients;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class JenkinsClient
{
private ?string $host;
private ?string $username;
private ?string $apiToken;
private int $timeout;
public function __construct()
@@ -23,28 +27,121 @@ class JenkinsClient
public function isConfigured(): bool
{
return !empty($this->host) && !empty($this->username) && !empty($this->apiToken);
return ! empty($this->host) && ! empty($this->username) && ! empty($this->apiToken);
}
public function getJobInfo(string $jobName): ?array
{
return $this->request("/job/{$jobName}/api/json");
return $this->request($this->getJobPath($jobName).'/api/json');
}
public function getBuildInfo(string $jobName, int $buildNumber): ?array
{
return $this->request("/job/{$jobName}/{$buildNumber}/api/json");
return $this->request($this->getJobPath($jobName)."/{$buildNumber}/api/json");
}
public function getLastBuild(string $jobName): ?array
{
return $this->request("/job/{$jobName}/lastBuild/api/json");
return $this->request($this->getJobPath($jobName).'/lastBuild/api/json');
}
public function getParameterDefinitions(string $jobName): array
{
$jobInfo = $this->getJobInfo($jobName);
if (! $jobInfo || empty($jobInfo['property'])) {
return [];
}
$buildFormParameters = $this->getBuildFormParameters($jobName);
foreach ($jobInfo['property'] as $property) {
if (empty($property['parameterDefinitions']) || ! is_array($property['parameterDefinitions'])) {
continue;
}
return array_map(function (array $definition) use ($buildFormParameters) {
$name = $definition['name'] ?? '';
$formParameter = $buildFormParameters[$name] ?? [];
$default = $definition['defaultParameterValue']['value'] ?? null;
if (($formParameter['multiple'] ?? false) && isset($formParameter['default'])) {
$default = $formParameter['default'];
}
return [
'name' => $name,
'type' => $definition['type'] ?? $definition['_class'] ?? 'StringParameterDefinition',
'description' => $definition['description'] ?? '',
'default' => $default,
'choices' => $definition['choices'] ?? $formParameter['choices'] ?? [],
'multiple' => (bool) ($formParameter['multiple'] ?? false),
];
}, array_values(array_filter($property['parameterDefinitions'], fn ($definition) => ! empty($definition['name']))));
}
return [];
}
public function triggerBuild(string $jobName, array $parameters = []): array
{
if (! $this->isConfigured()) {
Log::warning('Jenkins client is not configured');
return [
'success' => false,
'message' => 'Jenkins not configured',
];
}
$path = $this->getJobPath($jobName).(empty($parameters) ? '/build' : '/buildWithParameters');
$url = $this->host.$path;
try {
$request = $this->http();
$crumb = $this->getCrumb();
if ($crumb) {
$request = $request->withHeaders([$crumb['field'] => $crumb['crumb']]);
}
$response = empty($parameters)
? $request->post($url)
: $request->asForm()->post($url, $parameters);
if ($response->successful() || $response->status() === 201) {
return [
'success' => true,
'queue_url' => $response->header('Location'),
'status' => $response->status(),
];
}
Log::warning('Jenkins build trigger failed', [
'url' => $url,
'status' => $response->status(),
'body' => $response->body(),
]);
return [
'success' => false,
'message' => 'Jenkins 返回状态码 '.$response->status(),
'status' => $response->status(),
];
} catch (\Throwable $e) {
Log::error('Jenkins build trigger error', [
'url' => $url,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'message' => $e->getMessage(),
];
}
}
public function getBuilds(string $jobName, int $limit = 10): array
{
$jobInfo = $this->getJobInfo($jobName);
if (!$jobInfo || empty($jobInfo['builds'])) {
if (! $jobInfo || empty($jobInfo['builds'])) {
return [];
}
@@ -63,17 +160,16 @@ class JenkinsClient
private function request(string $path): ?array
{
if (!$this->isConfigured()) {
if (! $this->isConfigured()) {
Log::warning('Jenkins client is not configured');
return null;
}
$url = $this->host . $path;
$url = $this->host.$path;
try {
$response = Http::timeout($this->timeout)
->withBasicAuth($this->username, $this->apiToken)
->get($url);
$response = $this->http()->get($url);
if ($response->successful()) {
return $response->json();
@@ -94,4 +190,117 @@ class JenkinsClient
return null;
}
}
private function requestBody(string $path, bool $allowMethodNotAllowed = false): ?string
{
if (! $this->isConfigured()) {
Log::warning('Jenkins client is not configured');
return null;
}
$url = $this->host.$path;
try {
$response = $this->http()->get($url);
if ($response->successful() || ($allowMethodNotAllowed && $response->status() === 405)) {
return $response->body();
}
Log::warning('Jenkins page request failed', [
'url' => $url,
'status' => $response->status(),
]);
return null;
} catch (\Throwable $e) {
Log::error('Jenkins page request error', [
'url' => $url,
'error' => $e->getMessage(),
]);
return null;
}
}
private function getBuildFormParameters(string $jobName): array
{
$html = $this->requestBody($this->getJobPath($jobName).'/build?delay=0sec', allowMethodNotAllowed: true);
if (! $html) {
return [];
}
$parameters = [];
foreach (array_slice(preg_split('/<div name="parameter"[^>]*>/s', $html) ?: [], 1) as $parameterHtml) {
if (! preg_match('/<input name="name" type="hidden" value="([^"]+)"/s', $parameterHtml, $nameMatch)) {
continue;
}
if (! preg_match('/<select([^>]*)>(.*?)<\/select>/s', $parameterHtml, $selectMatch)) {
continue;
}
$name = html_entity_decode($nameMatch[1], ENT_QUOTES | ENT_HTML5);
$selectAttributes = $selectMatch[1];
$optionsHtml = $selectMatch[2];
$multiple = str_contains($selectAttributes, 'multiple');
preg_match_all('/<option([^>]*) value="([^"]*)"[^>]*>(.*?)<\/option>/s', $optionsHtml, $optionMatches, PREG_SET_ORDER);
$choices = [];
$defaults = [];
foreach ($optionMatches as $optionMatch) {
$attributes = $optionMatch[1];
$value = html_entity_decode($optionMatch[2], ENT_QUOTES | ENT_HTML5);
$label = trim(strip_tags(html_entity_decode($optionMatch[3], ENT_QUOTES | ENT_HTML5)));
$selected = str_contains($attributes, 'selected') || str_contains($label, '√');
$label = trim(str_replace('√', '', $label));
$choices[] = [
'value' => $value,
'label' => $label ?: $value,
'selected' => $selected,
];
if ($selected) {
$defaults[] = $value;
}
}
$parameters[$name] = [
'choices' => $choices,
'default' => $multiple ? $defaults : ($defaults[0] ?? null),
'multiple' => $multiple,
];
}
return $parameters;
}
private function http(): PendingRequest
{
return Http::timeout($this->timeout)
->withBasicAuth($this->username, $this->apiToken);
}
private function getCrumb(): ?array
{
$crumb = $this->request('/crumbIssuer/api/json');
if (! $crumb || empty($crumb['crumb']) || empty($crumb['crumbRequestField'])) {
return null;
}
return [
'field' => $crumb['crumbRequestField'],
'crumb' => $crumb['crumb'],
];
}
private function getJobPath(string $jobName): string
{
$segments = array_filter(explode('/', trim($jobName, '/')), fn ($segment) => $segment !== '');
return '/job/'.implode('/job/', array_map('rawurlencode', $segments));
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Clients\JenkinsClient;
use App\Http\Controllers\Controller;
use App\Models\Project;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class JenkinsBuildController extends Controller
{
public function __construct(
private readonly JenkinsClient $jenkinsClient
) {}
public function projects(): JsonResponse
{
if (! $this->jenkinsClient->isConfigured()) {
return response()->json([
'success' => false,
'message' => 'Jenkins 未配置,请先配置 JENKINS_HOST、JENKINS_USERNAME、JENKINS_API_TOKEN',
], 422);
}
$projects = Project::getJenkinsNotifyEnabled()
->map(function (Project $project) {
return [
'id' => $project->id,
'slug' => $project->slug,
'name' => $project->name,
'jenkins_job_name' => $project->jenkins_job_name,
'parameters' => $this->jenkinsClient->getParameterDefinitions($project->jenkins_job_name),
];
})
->values();
return response()->json([
'success' => true,
'data' => [
'projects' => $projects,
],
]);
}
public function trigger(Request $request): JsonResponse
{
if (! $this->jenkinsClient->isConfigured()) {
return response()->json([
'success' => false,
'message' => 'Jenkins 未配置,请先配置 Jenkins 连接信息',
], 422);
}
$projectSlugs = Project::getJenkinsNotifyEnabled()->pluck('slug')->all();
$data = $request->validate([
'builds' => ['required', 'array', 'min:1'],
'builds.*.project_slug' => ['required', 'string', Rule::in($projectSlugs)],
'builds.*.parameters' => ['nullable', 'array'],
]);
$projects = Project::getJenkinsNotifyEnabled()->keyBy('slug');
$results = [];
$successCount = 0;
foreach ($data['builds'] as $build) {
/** @var Project $project */
$project = $projects[$build['project_slug']];
$parameters = $this->normalizeParameters($build['parameters'] ?? []);
$result = $this->jenkinsClient->triggerBuild($project->jenkins_job_name, $parameters);
if ($result['success'] ?? false) {
$successCount++;
}
$results[] = [
'project_slug' => $project->slug,
'project_name' => $project->name,
'job_name' => $project->jenkins_job_name,
'success' => (bool) ($result['success'] ?? false),
'message' => $result['message'] ?? null,
'queue_url' => $result['queue_url'] ?? null,
];
}
return response()->json([
'success' => $successCount === count($results),
'message' => sprintf('触发完成:成功 %d 个,失败 %d 个', $successCount, count($results) - $successCount),
'data' => [
'results' => $results,
],
], $successCount > 0 ? 200 : 422);
}
private function normalizeParameters(array $parameters): array
{
return collect($parameters)
->filter(fn ($value) => $value !== null && $value !== '')
->map(function ($value) {
if (is_array($value)) {
return implode(',', array_filter($value, fn ($item) => $item !== null && $item !== ''));
}
return is_bool($value) ? ($value ? 'true' : 'false') : $value;
})
->filter(fn ($value) => $value !== '')
->all();
}
}
@@ -79,6 +79,9 @@
<!-- 定时任务管理页面 -->
<scheduled-tasks v-else-if="currentPage === 'scheduled-tasks'" />
<!-- Jenkins 一键构建页面 -->
<jenkins-builds v-else-if="currentPage === 'jenkins-builds'" />
</admin-layout>
</template>
@@ -99,6 +102,7 @@ import OperationLogs from './OperationLogs.vue';
import IpUserMappings from './IpUserMappings.vue';
import ProjectManagement from './ProjectManagement.vue';
import ScheduledTasks from './ScheduledTasks.vue';
import JenkinsBuilds from './JenkinsBuilds.vue';
export default {
name: 'AdminDashboard',
@@ -118,7 +122,8 @@ export default {
OperationLogs,
IpUserMappings,
ProjectManagement,
ScheduledTasks
ScheduledTasks,
JenkinsBuilds
},
data() {
return {
@@ -148,7 +153,7 @@ export default {
}
},
handleMenuChange(menu) {
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) {
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks' || menu === 'jenkins-builds') && !this.isAdmin) {
this.redirectToDefault();
return;
}
@@ -171,7 +176,8 @@ export default {
'logs': '操作日志',
'ip-mappings': 'IP 用户映射',
'projects': '项目配置管理',
'scheduled-tasks': '定时任务管理'
'scheduled-tasks': '定时任务管理',
'jenkins-builds': 'Jenkins 一键构建'
};
this.pageTitle = titles[menu] || '环境配置管理';
@@ -211,9 +217,11 @@ export default {
page = 'projects';
} else if (path === '/scheduled-tasks') {
page = 'scheduled-tasks';
} else if (path === '/jenkins-builds') {
page = 'jenkins-builds';
}
if ((page === 'ip-mappings' || page === 'projects' || page === 'scheduled-tasks') && !this.isAdmin) {
if ((page === 'ip-mappings' || page === 'projects' || page === 'scheduled-tasks' || page === 'jenkins-builds') && !this.isAdmin) {
this.redirectToDefault();
return;
}
+29 -2
View File
@@ -72,6 +72,31 @@
定时任务
</a>
<a
href="#"
@click.prevent="setActiveMenu('jenkins-builds')"
v-if="isAdmin"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'jenkins-builds'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'jenkins-builds' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
Jenkins 构建
</a>
<a
href="#"
@click.prevent="setActiveMenu('env')"
@@ -447,7 +472,7 @@ export default {
},
methods: {
setActiveMenu(menu) {
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) {
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks' || menu === 'jenkins-builds') && !this.isAdmin) {
return;
}
@@ -492,9 +517,11 @@ export default {
menu = 'projects';
} else if (path === '/scheduled-tasks') {
menu = 'scheduled-tasks';
} else if (path === '/jenkins-builds') {
menu = 'jenkins-builds';
}
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) {
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks' || menu === 'jenkins-builds') && !this.isAdmin) {
menu = 'env';
}
@@ -0,0 +1,482 @@
<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>
+12 -10
View File
@@ -1,21 +1,22 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\EnvController;
use App\Http\Controllers\JiraController;
use App\Http\Controllers\TestMailController;
use App\Http\Controllers\LogAnalysisController;
use App\Http\Controllers\MessageSyncController;
use App\Http\Controllers\MessageDispatchController;
use App\Http\Controllers\ProductionDiagnosisController;
use App\Http\Controllers\SqlGeneratorController;
use App\Http\Controllers\Admin\AdminMetaController;
use App\Http\Controllers\Admin\ConfigController;
use App\Http\Controllers\Admin\IpUserMappingController;
use App\Http\Controllers\Admin\JenkinsBuildController;
use App\Http\Controllers\Admin\JenkinsDeploymentController;
use App\Http\Controllers\Admin\OperationLogController;
use App\Http\Controllers\Admin\ProjectController;
use App\Http\Controllers\Admin\ScheduledTaskController;
use App\Http\Controllers\EnvController;
use App\Http\Controllers\JiraController;
use App\Http\Controllers\LogAnalysisController;
use App\Http\Controllers\MessageDispatchController;
use App\Http\Controllers\MessageSyncController;
use App\Http\Controllers\ProductionDiagnosisController;
use App\Http\Controllers\SqlGeneratorController;
use App\Http\Controllers\TestMailController;
use Illuminate\Support\Facades\Route;
// 环境管理API路由
Route::prefix('env')->group(function () {
@@ -51,7 +52,6 @@ Route::prefix('jira')->group(function () {
Route::get('/weekly-report/download', [JiraController::class, 'downloadWeeklyReport']);
});
// 提测邮件 API 路由
Route::prefix('test-mail')->group(function () {
Route::get('/sprints', [TestMailController::class, 'sprints']);
@@ -116,6 +116,8 @@ Route::prefix('admin')->middleware('admin.ip')->group(function () {
Route::post('/scheduled-tasks/{name}/toggle', [ScheduledTaskController::class, 'toggle']);
// Jenkins 发布历史
Route::get('/jenkins/build-projects', [JenkinsBuildController::class, 'projects']);
Route::post('/jenkins/trigger-builds', [JenkinsBuildController::class, 'trigger']);
Route::get('/jenkins/deployments', [JenkinsDeploymentController::class, 'index']);
Route::get('/jenkins/deployments/{id}', [JenkinsDeploymentController::class, 'show']);
});
+2 -1
View File
@@ -1,7 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AdminController;
use Illuminate\Support\Facades\Route;
// 首页 - 显示admin框架
Route::get('/', [AdminController::class, 'index'])->name('home');
@@ -22,3 +22,4 @@ Route::get('/logs', [AdminController::class, 'index'])->name('admin.logs');
Route::get('/ip-mappings', [AdminController::class, 'index'])->name('admin.ip-mappings')->middleware('admin.ip');
Route::get('/projects', [AdminController::class, 'index'])->name('admin.projects')->middleware('admin.ip');
Route::get('/scheduled-tasks', [AdminController::class, 'index'])->name('admin.scheduled-tasks')->middleware('admin.ip');
Route::get('/jenkins-builds', [AdminController::class, 'index'])->name('admin.jenkins-builds')->middleware('admin.ip');