#feature: some update

This commit is contained in:
2026-08-12 18:00:09 +08:00
parent ba916f5ce1
commit 103340536b
39 changed files with 2916 additions and 227 deletions
+607 -149
View File
@@ -6,12 +6,21 @@
<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="loadProjects"
:disabled="loading || triggering"
@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': loading}">
<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>
刷新
@@ -35,135 +44,156 @@
<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 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>
<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-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>
<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>
<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>
</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-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>
<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>
<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 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>
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</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 || '失败') }}
<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>
</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>
<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: {
@@ -268,13 +298,19 @@ export default {
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: [],
results: [],
primaryParameterOrder: ['env', 'branchName', 'deploy', 'sql', 'masterCheck', 'deployVersion'],
operationRecords: [],
primaryParameterOrder: ['env', 'branchName', 'deploy', 'deployVersion'],
message: '',
error: ''
};
@@ -285,17 +321,57 @@ export default {
},
isAllSelected() {
return this.projects.length > 0 && this.selectedProjects.length === this.projects.length;
},
runningOperationRecords() {
return this.operationRecords.filter((record) => this.isRunningStatus(record.status));
}
},
async mounted() {
await this.loadProjects();
this.operationRecords = this.loadOperationRecords();
this.loadProjects();
this.ensureStatusPolling();
this.checkOperationStatuses();
},
beforeUnmount() {
this.stopStatusPolling();
},
methods: {
async loadProjects() {
this.loading = true;
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' }
@@ -307,25 +383,71 @@ export default {
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)] || {};
cachedBuildProjects = data.data.projects || [];
this.saveProjectsCache(cachedBuildProjects);
this.applyProjects(cachedBuildProjects, { preserveCurrentValues: true });
return {
...project,
selected: Boolean(saved.selected),
parameters,
values: this.mergeSavedValues(defaults, saved.values || {}, parameters)
};
});
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) {
@@ -343,6 +465,9 @@ export default {
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));
@@ -364,6 +489,17 @@ export default {
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)] = {
@@ -446,25 +582,15 @@ export default {
this.triggering = true;
this.error = '';
this.message = '';
this.results = [];
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 = 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 || [];
const { response, data } = await this.submitBuilds(requestedBuilds);
if (!response.ok || !data.success) {
this.error = data.message || '触发失败';
return;
@@ -476,6 +602,338 @@ export default {
} 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())}`;
}
}
}
@@ -6,7 +6,7 @@
<h3 class="text-lg font-bold text-gray-800">系统设置</h3>
<p class="text-sm text-gray-500">管理本地偏好与服务端全局配置</p>
</div>
<div v-if="jira.loading || configs.loading" class="text-sm text-blue-600 animate-pulse">
<div v-if="jira.loading || configs.loading || erpRequestReport.loading" class="text-sm text-blue-600 animate-pulse">
数据同步中...
</div>
</div>
@@ -86,6 +86,46 @@
</div>
</div>
</div>
<div v-if="isAdmin" class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h4 class="font-semibold text-gray-700 text-sm">ERP 请求日报</h4>
</div>
<div class="p-4 space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">钉钉机器人 Token</label>
<input
v-model="erpRequestReport.dingtalkToken"
type="password"
autocomplete="new-password"
class="w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入新的 access_token"
/>
<p class="mt-1 text-xs text-gray-400">Token 仅用于保存不会在页面中回显保存后需到定时任务启用 ERP 请求日报</p>
</div>
<div class="flex items-center justify-between gap-3">
<span :class="erpRequestReport.configured ? 'text-green-600' : 'text-yellow-600'" class="text-xs">
{{ erpRequestReport.configured ? '已配置 Token' : '未配置 Token' }}
</span>
<button
@click="saveErpRequestReportConfig"
:disabled="erpRequestReport.saving || !erpRequestReport.dingtalkToken.trim()"
class="px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{{ erpRequestReport.saving ? '保存中...' : '保存 Token' }}
</button>
</div>
<div v-if="erpRequestReport.message" class="text-sm text-green-600 bg-green-50 px-3 py-2 rounded border border-green-100">
{{ erpRequestReport.message }}
</div>
<div v-if="erpRequestReport.error" class="text-sm text-red-600 bg-red-50 px-3 py-2 rounded border border-red-100">
{{ erpRequestReport.error }}
</div>
</div>
</div>
</div>
<!-- Right Column: Database Configs -->
@@ -276,6 +316,15 @@ export default {
description: '',
valueText: ''
}
},
erpRequestReport: {
loading: false,
saving: false,
loadedOnce: false,
configured: false,
dingtalkToken: '',
message: '',
error: ''
}
};
},
@@ -290,13 +339,18 @@ export default {
this.jira.localDefaultQueryUserSaved = savedOverride;
await this.loadServerConfig();
if (this.isAdmin) {
await this.loadConfigs();
await Promise.all([this.loadConfigs(), this.loadErpRequestReportConfig()]);
}
},
watch: {
isAdmin(value) {
if (value && !this.configs.loadedOnce) {
this.loadConfigs();
if (value) {
if (!this.configs.loadedOnce) {
this.loadConfigs();
}
if (!this.erpRequestReport.loadedOnce) {
this.loadErpRequestReportConfig();
}
}
}
},
@@ -397,6 +451,69 @@ export default {
this.configs.loading = false;
}
},
async loadErpRequestReportConfig() {
if (!this.isAdmin) {
return;
}
this.erpRequestReport.loading = true;
this.erpRequestReport.error = '';
try {
const response = await fetch('/api/admin/erp-request-report/config', {
headers: { Accept: 'application/json' }
});
const data = await this.parseJsonResponse(response);
if (!response.ok || !data.success) {
this.erpRequestReport.error = this.getErrorMessage(data, '加载 ERP 请求日报配置失败');
return;
}
this.erpRequestReport.configured = Boolean(data.data.dingtalk_token_configured);
this.erpRequestReport.loadedOnce = true;
} catch (error) {
this.erpRequestReport.error = error.message;
} finally {
this.erpRequestReport.loading = false;
}
},
async saveErpRequestReportConfig() {
if (!this.erpRequestReport.dingtalkToken.trim()) {
return;
}
this.erpRequestReport.saving = true;
this.erpRequestReport.error = '';
this.erpRequestReport.message = '';
try {
const response = await fetch('/api/admin/erp-request-report/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
dingtalk_token: this.erpRequestReport.dingtalkToken.trim()
})
});
const data = await this.parseJsonResponse(response);
if (!response.ok || !data.success) {
this.erpRequestReport.error = this.getErrorMessage(data, '保存 ERP 请求日报配置失败');
return;
}
this.erpRequestReport.configured = Boolean(data.data.dingtalk_token_configured);
this.erpRequestReport.dingtalkToken = '';
this.erpRequestReport.message = data.message || 'Token 已保存';
} catch (error) {
this.erpRequestReport.error = error.message;
} finally {
this.erpRequestReport.saving = false;
}
},
async createConfig() {
if (!this.configs.newConfig.key.trim()) {
this.configs.error = 'key 不能为空';