Files
toolbox/resources/js/components/admin/JenkinsBuilds.vue
T
2026-08-12 18:00:09 +08:00

941 lines
36 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">
<div
v-if="refreshing"
class="inline-flex items-center gap-1.5 rounded border border-blue-100 bg-blue-50 px-2.5 py-1.5 text-xs text-blue-700"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-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-.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>
正在刷新
</div>
<button
@click="refreshProjects()"
:disabled="refreshing || 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': refreshing}">
<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="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_24rem] gap-2 items-start">
<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_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>
<aside 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 flex items-center justify-between">
<h4 class="font-semibold text-gray-700 text-sm">发布记录</h4>
<span class="text-xs text-gray-400">{{ operationRecords.length }}</span>
</div>
<div v-if="operationRecords.length === 0" class="p-4 text-xs text-gray-400">
暂无发布记录
</div>
<div v-else class="divide-y divide-gray-100 max-h-[calc(100vh-13rem)] overflow-y-auto">
<div v-for="record in operationRecords" :key="record.id" class="px-3 py-2">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-[11px] text-gray-500">{{ record.time }}</span>
<div class="flex items-center gap-1.5">
<span class="rounded px-1.5 py-0.5 text-[11px]" :class="statusBadgeClass(record.status)">
{{ statusLabel(record.status) }}
</span>
<button
@click="rebuildOperationRecord(record)"
:disabled="record.rebuilding || !record.project_slug || triggering"
class="rounded border border-blue-200 px-1.5 py-0.5 text-[11px] text-blue-600 hover:bg-blue-50 disabled:opacity-50"
>
{{ record.rebuilding ? 'rebuild中' : 'rebuild' }}
</button>
<button
v-if="canCancelRecord(record)"
@click="cancelOperationRecord(record)"
:disabled="record.cancelling"
class="rounded border border-red-200 px-1.5 py-0.5 text-[11px] text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{{ record.cancelling ? '取消中' : '取消' }}
</button>
</div>
</div>
<div class="mt-1 flex items-center gap-1.5 text-xs text-gray-700 min-w-0">
<span class="font-mono font-semibold truncate" :title="record.project_slug">{{ record.project_slug || '-' }}</span>
<span class="text-[11px] text-gray-400 truncate" :title="record.project_name">{{ record.project_name }}</span>
</div>
<div class="mt-0.5 grid grid-cols-[3.25rem_minmax(0,1fr)] gap-1 text-[11px] text-gray-500">
<span class="text-gray-400">project</span>
<span class="font-mono truncate" :title="record.project_parameter">{{ record.project_parameter || '-' }}</span>
<span class="text-gray-400">构建号</span>
<span class="font-mono truncate">{{ record.build_number ? `#${record.build_number}` : '-' }}</span>
</div>
<div v-if="record.message" class="mt-1 text-[11px] text-gray-400 truncate" :title="record.message">
{{ record.message }}
</div>
</div>
</div>
</aside>
</div>
</div>
</template>
<script>
let cachedBuildProjects = null;
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',
cacheKey: 'toolbox.jenkinsBuilds.projectsCache.v1',
operationRecordsKey: 'toolbox.jenkinsBuilds.operationRecords.v1',
hiddenParameterNames: ['sql', 'masterCheck'],
data() {
return {
loading: false,
refreshing: false,
triggering: false,
statusChecking: false,
statusPollingTimer: null,
projects: [],
operationRecords: [],
primaryParameterOrder: ['env', 'branchName', 'deploy', 'deployVersion'],
message: '',
error: ''
};
},
computed: {
selectedProjects() {
return this.projects.filter((project) => project.selected);
},
isAllSelected() {
return this.projects.length > 0 && this.selectedProjects.length === this.projects.length;
},
runningOperationRecords() {
return this.operationRecords.filter((record) => this.isRunningStatus(record.status));
}
},
async mounted() {
this.operationRecords = this.loadOperationRecords();
this.loadProjects();
this.ensureStatusPolling();
this.checkOperationStatuses();
},
beforeUnmount() {
this.stopStatusPolling();
},
methods: {
loadProjects() {
this.error = '';
this.message = '';
const freshCachedProjects = this.loadProjectsCache();
if (freshCachedProjects) {
cachedBuildProjects = freshCachedProjects;
this.applyProjects(cachedBuildProjects);
return;
}
if (!cachedBuildProjects) {
cachedBuildProjects = this.loadProjectsCache({ allowExpired: true });
}
if (cachedBuildProjects) {
this.applyProjects(cachedBuildProjects);
} else {
this.loading = true;
}
this.refreshProjects({ silent: Boolean(cachedBuildProjects) });
},
async refreshProjects(options = {}) {
if (this.refreshing) {
return;
}
const { silent = false } = options;
this.refreshing = true;
this.error = '';
if (!silent && this.projects.length === 0) {
this.loading = true;
}
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;
}
cachedBuildProjects = data.data.projects || [];
this.saveProjectsCache(cachedBuildProjects);
this.applyProjects(cachedBuildProjects, { preserveCurrentValues: true });
if (!silent) {
this.message = 'Jenkins 项目已刷新';
}
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
this.refreshing = false;
}
},
applyProjects(projects, options = {}) {
const { preserveCurrentValues = false } = options;
const preferences = this.loadPreferences();
const currentProjects = new Map(this.projects.map((project) => [this.preferenceProjectKey(project), project]));
this.projects = projects.map((project) => {
const parameters = this.visibleParameters(project.parameters || []);
const defaults = this.defaultValues(parameters);
const key = this.preferenceProjectKey(project);
const saved = preferences[key] || {};
const current = preserveCurrentValues ? currentProjects.get(key) : null;
return {
...project,
selected: current ? Boolean(current.selected) : Boolean(saved.selected),
parameters,
values: this.mergeSavedValues(defaults, current?.values || saved.values || {}, parameters)
};
});
this.savePreferences();
},
loadProjectsCache(options = {}) {
try {
const { allowExpired = false } = options;
const cache = JSON.parse(window.localStorage.getItem(this.$options.cacheKey) || 'null');
if (!cache || !Array.isArray(cache.projects)) {
return null;
}
if (!allowExpired && cache.date !== this.todayKey()) {
return null;
}
return cache.projects;
} catch (error) {
return null;
}
},
saveProjectsCache(projects) {
window.localStorage.setItem(this.$options.cacheKey, JSON.stringify({
date: this.todayKey(),
projects
}));
},
todayKey() {
const date = new Date();
const pad = (value) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
},
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;
}, {});
},
visibleParameters(parameters) {
return parameters.filter((parameter) => !this.$options.hiddenParameterNames.includes(parameter.name));
},
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 {};
}
},
loadOperationRecords() {
try {
const records = JSON.parse(window.localStorage.getItem(this.$options.operationRecordsKey) || '[]');
return Array.isArray(records) ? records.map((record) => this.normalizeOperationRecord(record)) : [];
} catch (error) {
return [];
}
},
saveOperationRecords() {
window.localStorage.setItem(this.$options.operationRecordsKey, JSON.stringify(this.operationRecords.slice(0, 30)));
},
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 = '';
const requestedBuilds = this.selectedProjects.map((project) => ({
project_slug: project.slug,
project_name: project.name,
job_name: project.jenkins_job_name,
parameters: this.serializeBuildParameters(project.values)
}));
try {
const { response, data } = await this.submitBuilds(requestedBuilds);
if (!response.ok || !data.success) {
this.error = data.message || '触发失败';
return;
}
this.message = data.message || '触发成功';
} catch (error) {
this.error = error.message;
} finally {
this.triggering = false;
}
},
async submitBuilds(requestedBuilds) {
const response = await fetch('/api/admin/jenkins/trigger-builds', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
builds: requestedBuilds.map((build) => ({
project_slug: build.project_slug,
parameters: build.parameters
}))
})
});
const data = await response.json();
this.addOperationRecords(data.data?.results || [], requestedBuilds);
return { response, data };
},
async rebuildOperationRecord(record) {
if (!record.project_slug) {
this.error = '缺少项目标识,无法 rebuild';
return;
}
if (!window.confirm(`确认 rebuild ${record.project_slug} 吗?`)) {
return;
}
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, rebuilding: true } : item
));
this.saveOperationRecords();
this.error = '';
this.message = '';
try {
const { response, data } = await this.submitBuilds([{
project_slug: record.project_slug,
project_name: record.project_name,
job_name: record.job_name,
parameters: record.parameters || {}
}]);
if (!response.ok || !data.success) {
this.error = data.message || 'rebuild 失败';
return;
}
this.message = data.message || 'rebuild 已触发';
} catch (error) {
this.error = error.message;
} finally {
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, rebuilding: false } : item
));
this.saveOperationRecords();
}
},
serializeBuildParameters(values) {
return Object.entries(values || {}).reduce((payload, [name, value]) => {
if (this.$options.hiddenParameterNames.includes(name)) {
return payload;
}
if (Array.isArray(value)) {
payload[name] = value.filter((item) => item !== null && item !== '').join(',');
} else {
payload[name] = value;
}
return payload;
}, {});
},
addOperationRecords(results, requestedBuilds) {
const resultList = Array.isArray(results) ? results : [];
const requestedBySlug = new Map(requestedBuilds.map((build) => [build.project_slug, build]));
const records = resultList.map((result) => {
const requested = requestedBySlug.get(result.project_slug) || {};
const canTrackBuild = Boolean(result.queue_url || result.build_number);
return {
id: `${Date.now()}-${result.project_slug || Math.random().toString(36).slice(2, 8)}`,
time: this.formatRecordTime(new Date()),
project_slug: result.project_slug || requested.project_slug || '',
project_name: result.project_name || requested.project_name || '',
job_name: result.job_name || requested.job_name || '',
project_parameter: this.formatParameterValue(requested.parameters?.project),
status: result.success ? (canTrackBuild ? 'PENDING' : 'UNKNOWN') : 'FAILURE',
queue_url: result.queue_url || null,
build_number: result.build_number || null,
build_url: null,
parameters: requested.parameters || {},
message: result.success
? (canTrackBuild ? '已提交 Jenkins,等待发布结果' : '已提交 Jenkins,但未返回队列地址,无法自动跟踪或取消')
: (result.message || '触发失败'),
cancelling: false,
rebuilding: false
};
});
this.operationRecords = [
...records,
...this.operationRecords
].slice(0, 30);
this.saveOperationRecords();
this.ensureStatusPolling();
this.checkOperationStatuses();
},
normalizeOperationRecord(record) {
if (record.status) {
return {
...record,
parameters: record.parameters || {},
cancelling: false,
rebuilding: false
};
}
return {
id: record.id || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
time: record.time || '-',
project_slug: record.projects || '',
project_name: record.projects || '历史发布记录',
job_name: '',
project_parameter: '-',
status: record.failed > 0 ? 'FAILURE' : 'SUCCESS',
queue_url: null,
build_number: null,
build_url: null,
parameters: {},
message: `${record.success || 0} 成功 / ${record.failed || 0} 失败`,
cancelling: false,
rebuilding: false
};
},
async checkOperationStatuses() {
const runningRecords = this.runningOperationRecords.filter((record) => record.queue_url || record.build_number);
if (this.statusChecking || runningRecords.length === 0) {
this.ensureStatusPolling();
return;
}
this.statusChecking = true;
try {
const response = await fetch('/api/admin/jenkins/build-statuses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
builds: runningRecords.map((record) => ({
id: record.id,
project_slug: record.project_slug,
queue_url: record.queue_url,
build_number: record.build_number
}))
})
});
const data = await response.json();
if (!response.ok || !data.success) {
this.error = data.message || '查询 Jenkins 发布状态失败';
return;
}
const statuses = new Map((data.data?.results || []).map((result) => [result.id, result]));
this.operationRecords = this.operationRecords.map((record) => {
const status = statuses.get(record.id);
if (!status) {
return record;
}
const nextStatus = record.status === 'CANCELING' && !status.completed
? 'CANCELING'
: this.normalizeJenkinsStatus(status);
return {
...record,
status: nextStatus,
build_number: status.build_number || record.build_number,
build_url: status.build_url || record.build_url,
message: status.message || null,
cancelling: false
};
});
this.saveOperationRecords();
} catch (error) {
this.error = error.message;
} finally {
this.statusChecking = false;
this.ensureStatusPolling();
}
},
async cancelOperationRecord(record) {
if (!window.confirm(`确认取消 ${record.project_slug} 的 Jenkins 发布吗?`)) {
return;
}
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, cancelling: true, status: 'CANCELING', message: '正在发送取消请求' } : item
));
this.saveOperationRecords();
try {
const response = await fetch('/api/admin/jenkins/cancel-build', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
project_slug: record.project_slug,
queue_url: record.queue_url,
build_number: record.build_number
})
});
const data = await response.json();
if (!response.ok || !data.success) {
this.error = data.message || '取消 Jenkins 发布失败';
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, cancelling: false, status: record.status, message: data.message || item.message } : item
));
this.saveOperationRecords();
return;
}
const cancelResult = data.data?.result || {};
const nextStatus = cancelResult.cancelled_queue ? 'ABORTED' : 'CANCELING';
const nextMessage = cancelResult.cancelled_queue ? '已取消 Jenkins 队列任务' : '已发送停止请求,等待 Jenkins 确认';
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, cancelling: false, status: nextStatus, message: nextMessage } : item
));
this.saveOperationRecords();
this.ensureStatusPolling();
if (nextStatus === 'CANCELING') {
this.checkOperationStatuses();
}
} catch (error) {
this.error = error.message;
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, cancelling: false, status: record.status, message: error.message } : item
));
this.saveOperationRecords();
}
},
normalizeJenkinsStatus(status) {
if (!status.success && status.status === 'UNKNOWN') {
return 'UNKNOWN';
}
if (status.completed) {
return status.status || status.result || 'UNKNOWN';
}
return status.status === 'PENDING' ? 'PENDING' : 'BUILDING';
},
ensureStatusPolling() {
const hasPollableRecords = this.runningOperationRecords.some((record) => record.queue_url || record.build_number);
if (!hasPollableRecords) {
this.stopStatusPolling();
return;
}
if (!this.statusPollingTimer) {
this.statusPollingTimer = window.setInterval(() => {
this.checkOperationStatuses();
}, 10000);
}
},
stopStatusPolling() {
if (this.statusPollingTimer) {
window.clearInterval(this.statusPollingTimer);
this.statusPollingTimer = null;
}
},
isRunningStatus(status) {
return ['PENDING', 'BUILDING', 'CANCELING'].includes(status);
},
canCancelRecord(record) {
return ['PENDING', 'BUILDING'].includes(record.status)
&& Boolean(record.queue_url || record.build_number)
&& !record.cancelling;
},
statusLabel(status) {
return {
PENDING: '发布中',
BUILDING: '发布中',
CANCELING: '取消中',
SUCCESS: '成功',
FAILURE: '失败',
ABORTED: '已取消',
UNSTABLE: '不稳定',
UNKNOWN: '未知'
}[status] || '未知';
},
statusBadgeClass(status) {
if (['PENDING', 'BUILDING', 'CANCELING'].includes(status)) {
return 'bg-blue-50 text-blue-600';
}
if (status === 'SUCCESS') {
return 'bg-green-50 text-green-600';
}
if (status === 'UNSTABLE') {
return 'bg-yellow-50 text-yellow-700';
}
return 'bg-red-50 text-red-600';
},
formatParameterValue(value) {
if (Array.isArray(value)) {
return value.join(', ');
}
if (value === null || value === undefined || value === '') {
return '-';
}
return String(value);
},
formatRecordTime(date) {
const pad = (value) => String(value).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
}
}
</script>