#feature: some update
This commit is contained in:
@@ -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 不能为空';
|
||||
|
||||
@@ -87,6 +87,18 @@
|
||||
>
|
||||
查询今天数据
|
||||
</button>
|
||||
<button
|
||||
@click="setQuickDateRange('previousDay')"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
前一天
|
||||
</button>
|
||||
<button
|
||||
@click="setQuickDateRange('nextDay')"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
后一天
|
||||
</button>
|
||||
<button
|
||||
@click="clearQuickSelect()"
|
||||
v-if="workLogs.activeQuickSelect"
|
||||
@@ -378,8 +390,8 @@ export default {
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() + mondayOffset);
|
||||
|
||||
this.workLogs.startDate = monday.toISOString().split('T')[0];
|
||||
this.workLogs.endDate = today.toISOString().split('T')[0];
|
||||
this.workLogs.startDate = this.formatDate(monday);
|
||||
this.workLogs.endDate = this.formatDate(today);
|
||||
},
|
||||
|
||||
setLastWeekDateRange() {
|
||||
@@ -394,28 +406,50 @@ export default {
|
||||
const lastSunday = new Date(today);
|
||||
lastSunday.setDate(today.getDate() + lastSundayOffset);
|
||||
|
||||
this.workLogs.startDate = lastMonday.toISOString().split('T')[0];
|
||||
this.workLogs.endDate = lastSunday.toISOString().split('T')[0];
|
||||
this.workLogs.startDate = this.formatDate(lastMonday);
|
||||
this.workLogs.endDate = this.formatDate(lastSunday);
|
||||
},
|
||||
|
||||
setYesterdayDateRange() {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const dateStr = yesterday.toISOString().split('T')[0];
|
||||
const dateStr = this.formatDate(yesterday);
|
||||
this.workLogs.startDate = dateStr;
|
||||
this.workLogs.endDate = dateStr;
|
||||
},
|
||||
|
||||
setTodayDateRange() {
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().split('T')[0];
|
||||
const dateStr = this.formatDate(today);
|
||||
|
||||
this.workLogs.startDate = dateStr;
|
||||
this.workLogs.endDate = dateStr;
|
||||
},
|
||||
|
||||
setQuickDateRange(type) {
|
||||
shiftDateRange(days) {
|
||||
const startDate = new Date(`${this.workLogs.startDate}T00:00:00`);
|
||||
const endDate = new Date(`${this.workLogs.endDate}T00:00:00`);
|
||||
|
||||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
||||
this.setTodayDateRange();
|
||||
return;
|
||||
}
|
||||
|
||||
startDate.setDate(startDate.getDate() + days);
|
||||
endDate.setDate(endDate.getDate() + days);
|
||||
this.workLogs.startDate = this.formatDate(startDate);
|
||||
this.workLogs.endDate = this.formatDate(endDate);
|
||||
},
|
||||
|
||||
formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
},
|
||||
|
||||
async setQuickDateRange(type) {
|
||||
this.workLogs.activeQuickSelect = type;
|
||||
|
||||
switch (type) {
|
||||
@@ -428,7 +462,15 @@ export default {
|
||||
case 'today':
|
||||
this.setTodayDateRange();
|
||||
break;
|
||||
case 'previousDay':
|
||||
this.shiftDateRange(-1);
|
||||
break;
|
||||
case 'nextDay':
|
||||
this.shiftDateRange(1);
|
||||
break;
|
||||
}
|
||||
|
||||
await this.getWorkLogs();
|
||||
},
|
||||
|
||||
clearQuickSelect() {
|
||||
|
||||
@@ -54,14 +54,15 @@
|
||||
</div>
|
||||
<div class="max-h-[360px] overflow-auto rounded border">
|
||||
<table class="dense-table min-w-[1180px]">
|
||||
<thead><tr><th v-for="h in issueHeaders" :key="h">{{ h }}</th></tr></thead>
|
||||
<thead><tr><th v-for="h in issueHeaders" :key="h">{{ h }}</th><th class="w-14">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="issue in issues" :key="issue.key">
|
||||
<td><a :href="issue.url" target="_blank" class="text-blue-600">{{ issue.key }}</a></td>
|
||||
<td class="min-w-72">{{ issue.summary }}</td>
|
||||
<td>{{ issue.reporter || '-' }}</td><td>{{ issue.status }}</td><td>{{ issue.developer || '-' }}</td><td>{{ issue.assignee || '-' }}</td><td>{{ issue.sprint || '-' }}</td><td>{{ issue.estimated_test_at || '' }}</td><td>{{ issue.estimated_release_at || '' }}</td>
|
||||
<td><button @click="removeIssue(issue.key)" class="text-xs text-red-600 hover:underline">删除</button></td>
|
||||
</tr>
|
||||
<tr v-if="!issues.length"><td colspan="9" class="text-center text-gray-400">请选择 Sprint 后拉取 Jira 数据</td></tr>
|
||||
<tr v-if="!issues.length"><td colspan="10" class="text-center text-gray-400">请选择 Sprint 后拉取 Jira 数据</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -85,7 +86,7 @@
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<button @click="refreshDatabases" class="rounded bg-blue-100 px-3 py-1.5 text-sm text-blue-700 hover:bg-blue-200">刷新数据库分支</button>
|
||||
<span class="text-xs text-gray-500">agent / portal / portal-ticket 版本号可各自调整</span>
|
||||
<span class="text-xs text-gray-500">agent / portal / portal-ticket / mono 版本号可各自调整</span>
|
||||
</div>
|
||||
<div class="mt-3 overflow-auto rounded border">
|
||||
<table class="dense-table min-w-[520px]"><thead><tr><th>系统</th><th>是否有数据库</th><th>分支</th></tr></thead><tbody><tr v-for="row in databases" :key="row.group"><td>{{ row.system }}</td><td>{{ row.has_database }}</td><td>{{ row.branch || '-' }}</td></tr></tbody></table>
|
||||
@@ -156,6 +157,7 @@ function normalizeTestMailSprintPeriod(value) {
|
||||
}
|
||||
|
||||
const LAST_SPRINT_STORAGE_KEY = 'toolbox.testMail.lastSprint';
|
||||
const RECIPIENTS_STORAGE_KEY = 'toolbox.testMail.recipients';
|
||||
|
||||
const EditableDenseTable = {
|
||||
props: ['headers', 'rows', 'columns'], emits: ['remove'],
|
||||
@@ -171,7 +173,7 @@ export default {
|
||||
},
|
||||
data() { return {
|
||||
steps: ['1 Sprint','2 收件人','3 Jira 表格','4 容器/数据库','5 八截图+九/十表格','6 下载'],
|
||||
sprint: '', sprintOptions: [], loading: false, downloading: false, draftLoading: false, draftOpening: false, draftSource: '', error: '', jql: '', issues: [], defaults: {}, images: [],
|
||||
sprint: '', sprintOptions: [], loading: false, downloading: false, draftLoading: false, draftOpening: false, draftRequestId: 0, draftSource: '', error: '', jql: '', issues: [], defaults: {}, images: [],
|
||||
from: '万文山 <wanwenshan@angelalign.com>',
|
||||
to: '"ouyangxiaowen@angelalign.com" <ouyangxiaowen@angelalign.com>, "yaowenying@angelalign.com" <yaowenying@angelalign.com>, "guoziliang@angelalign.com" <guoziliang@angelalign.com>, chenhui7@angelalign.com, leyunpeng@angelalign.com',
|
||||
cc: '黄宇 <huangyu@angelalign.com>, "yujie2@angelalign.com" <yujie2@angelalign.com>, "lizhongyuan@angelalign.com" <lizhongyuan@angelalign.com>, "huangfang2@angelalign.com" <huangfang2@angelalign.com>, 周国辉 <zhouguohui@angelalign.com>, "yuxinli@angelalign.com" <yuxinli@angelalign.com>, "zhangzhen3@angelalign.com" <zhangzhen3@angelalign.com>, "renzhaochun@angelalign.com" <renzhaochun@angelalign.com>, "yangyunhao@angelalign.com" <yangyunhao@angelalign.com>, "xiangshang@angelalign.com" <xiangshang@angelalign.com>, "liuyuan1@angelalign.com" <liuyuan1@angelalign.com>, zhangyuan1@angelalign.com, yangjuan1@angelalign.com, wanghe2@angelalign.com',
|
||||
@@ -192,8 +194,12 @@ export default {
|
||||
draftSourceLabel() { return this.draftSource === 'ai' ? 'AI' : '规则默认'; },
|
||||
mailHtml() { return `<div style="font-family:'Microsoft YaHei UI',Arial,sans-serif;font-size:14px;color:#000;line-height:1.5">${this.section('一、需求内容')}${this.issueTableHtml()}${this.section('二、技术文档')}${this.multiline(this.techDocs)}${this.section('三、冒烟测试情况:')}${this.paragraph(`冒烟通过率:${this.escape(this.smokeRate)}`)}${this.smokeLinksHtml()}${this.section('四、计划异常情况')}${this.paragraph('紧急需求:<br> ' + this.escape(this.urgentItems).replace(/\n/g, '<br> '))}${this.paragraph('延期需求:<br> ' + this.escape(this.delayedItems).replace(/\n/g, '<br> '))}${this.section('五、容器部署和版本')}${this.simpleTable(['容器','版本号','服务器所在地'], this.selectedContainerRows, ['name','version','location'])}${this.section('六、数据库')}${this.simpleTable(['系统','是否有数据库','分支'], this.databases, ['system','has_database','branch'])}${this.section('七、是否涉及合规')}${this.paragraph(' 不涉及')}${this.section('八、环境部署准备清单:')}${this.screenshotHtml('environment')}${this.section('九、测试注意事项/其他依赖项')}${this.noteTableHtml()}${this.section('十、已知问题与风险')}${this.riskTableHtml()}</div>`; }
|
||||
},
|
||||
watch: { sprint(value) { this.rememberSprint(value); this.updateSubjectFromSprint(); } },
|
||||
async mounted() { await this.loadSprints(); await this.loadData(); },
|
||||
watch: {
|
||||
sprint(value) { this.rememberSprint(value); this.updateSubjectFromSprint(); },
|
||||
to() { this.rememberRecipients(); },
|
||||
cc() { this.rememberRecipients(); },
|
||||
},
|
||||
async mounted() { this.restoreRecipients(); await this.loadSprints(); await this.loadData(); },
|
||||
methods: {
|
||||
csrf() { return document.querySelector('meta[name="csrf-token"]').getAttribute('content'); },
|
||||
async loadSprints() { try { const data = await (await fetch('/api/test-mail/sprints')).json(); if (data.success) { this.sprintOptions = data.data.sprints || []; const savedSprint = this.restoreSprint(); if (!this.sprint && savedSprint) this.sprint = savedSprint; if (!this.sprint && this.sprintOptions.length) this.sprint = this.sprintOptions[0].id || this.sprintOptions[0].name || ''; this.defaults = data.data.defaults || {}; this.initializeContainers(); this.updateSubjectFromSprint(); } } catch (e) { console.error(e); } },
|
||||
@@ -202,6 +208,9 @@ export default {
|
||||
handleSprintSelection() { this.updateSubjectFromSprint(); this.loadData(); },
|
||||
rememberSprint(value) { try { const sprint = String(value || '').trim(); if (sprint) localStorage.setItem(LAST_SPRINT_STORAGE_KEY, sprint); else localStorage.removeItem(LAST_SPRINT_STORAGE_KEY); } catch (e) { console.error(e); } },
|
||||
restoreSprint() { try { return localStorage.getItem(LAST_SPRINT_STORAGE_KEY) || ''; } catch (e) { console.error(e); return ''; } },
|
||||
rememberRecipients() { try { localStorage.setItem(RECIPIENTS_STORAGE_KEY, JSON.stringify({to:this.to,cc:this.cc})); } catch (e) { console.error(e); } },
|
||||
restoreRecipients() { try { const saved = JSON.parse(localStorage.getItem(RECIPIENTS_STORAGE_KEY) || 'null'); if (saved && typeof saved.to === 'string' && typeof saved.cc === 'string') { this.to = saved.to; this.cc = saved.cc; } } catch (e) { console.error(e); } },
|
||||
removeIssue(key) { this.draftRequestId++; this.draftLoading = false; this.issues = this.issues.filter(issue => issue.key !== key); this.testNoteRows = this.testNoteRows.filter(row => row.issue !== key); if (!this.testNoteRows.length) this.addNoteRow(); },
|
||||
async refreshDatabases() { try { const data = await (await fetch('/api/test-mail/databases', { method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body: JSON.stringify({ selected_groups: this.selectedGroups, versions: this.versions }) })).json(); if (data.success) this.databases = data.data.databases || []; } catch (e) { console.error(e); } },
|
||||
isGroupSelected(key) { return this.selectedGroups.includes(key); },
|
||||
toggleGroup(key, checked) { const group = this.containerGroups.find(g => g.key === key); const names = (group?.containers || []).map(c => c.name); if (checked) { if (!this.selectedGroups.includes(key)) this.selectedGroups.push(key); this.selectedContainers = Array.from(new Set([...this.selectedContainers, ...names])); } else { this.selectedGroups = this.selectedGroups.filter(k => k !== key); this.selectedContainers = this.selectedContainers.filter(n => !names.includes(n)); } this.refreshDatabases(); },
|
||||
@@ -209,7 +218,7 @@ export default {
|
||||
clearEnvironmentScreenshots() { this.images = this.images.filter(i => i.section !== 'environment'); this.$refs.environmentPasteBox.innerHTML = '<p class="text-gray-500">点击这里后直接粘贴「八、环境部署准备清单」截图</p>'; },
|
||||
addNoteRow() { this.testNoteRows.push({_id:Date.now()+Math.random(),type:'其他依赖项',issue:'',system:'',content:'',owner:''}); }, removeNoteRow(i) { this.testNoteRows.splice(i,1); if (!this.testNoteRows.length) this.addNoteRow(); },
|
||||
addRiskRow() { this.riskRows.push({_id:Date.now()+Math.random(),problem:'',impact:'',action:'',owner:''}); }, removeRiskRow(i) { this.riskRows.splice(i,1); if (!this.riskRows.length) this.addRiskRow(); },
|
||||
async generateDraftSections(showErrors = true) { this.draftLoading = true; if (showErrors) this.error = ''; try { const res = await fetch('/api/test-mail/draft-sections', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body:JSON.stringify({ issues:this.issues, tech_docs:this.techDocs, selected_containers:this.selectedContainers, databases:this.databases }) }); const data = await res.json(); if (!data.success) throw new Error(data.message || '生成草稿失败'); this.testNoteRows = (data.data.test_notes || []).map((row, index) => ({_id:Date.now()+index, type:row.type || '测试注意事项', issue:row.issue || '', system:row.system || '', content:row.content || '', owner:row.owner || ''})); this.riskRows = (data.data.risks || []).map((row, index) => ({_id:Date.now()+100+index, problem:row.problem || '', impact:row.impact || '', action:row.action || '', owner:row.owner || ''})); if (!this.testNoteRows.length) this.addNoteRow(); if (!this.riskRows.length) this.addRiskRow(); this.draftSource = data.data.source || 'rules'; } catch(e) { if (showErrors) this.error = e.message; else console.error(e); } finally { this.draftLoading = false; } },
|
||||
async generateDraftSections(showErrors = true) { const requestId = ++this.draftRequestId; this.draftLoading = true; if (showErrors) this.error = ''; try { const res = await fetch('/api/test-mail/draft-sections', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body:JSON.stringify({ issues:this.issues, tech_docs:this.techDocs, selected_containers:this.selectedContainers, databases:this.databases }) }); const data = await res.json(); if (requestId !== this.draftRequestId) return; if (!data.success) throw new Error(data.message || '生成草稿失败'); this.testNoteRows = (data.data.test_notes || []).map((row, index) => ({_id:Date.now()+index, type:row.type || '测试注意事项', issue:row.issue || '', system:row.system || '', content:row.content || '', owner:row.owner || ''})); this.riskRows = (data.data.risks || []).map((row, index) => ({_id:Date.now()+100+index, problem:row.problem || '', impact:row.impact || '', action:row.action || '', owner:row.owner || ''})); if (!this.testNoteRows.length) this.addNoteRow(); if (!this.riskRows.length) this.addRiskRow(); this.draftSource = data.data.source || 'rules'; } catch(e) { if (showErrors) this.error = e.message; else console.error(e); } finally { if (requestId === this.draftRequestId) this.draftLoading = false; } },
|
||||
updateSubjectFromSprint() { const sprint = this.sprint.trim(); this.subject = this.buildSubject(this.resolveSprintPeriod() || (sprint ? `Sprint${sprint}` : '')); },
|
||||
buildSubject(period) { return `【提测】${period ? period : ''}需求提测(SP、PP、TP)`; },
|
||||
resolveSprintPeriod() { const option = this.selectedSprintOption(); if (option?.period) return option.period; const candidates = [this.sprint, option?.name, option?.label, ...this.issues.map(i => i.sprint || '')]; for (const candidate of candidates) { const period = normalizeTestMailSprintPeriod(candidate); if (period) return period; } return ''; },
|
||||
|
||||
@@ -58,6 +58,11 @@
|
||||
<code class="font-mono">AgentBusinessDocument\\ConfirmProduction::canProduction</code> /
|
||||
<code class="font-mono">AgentSaleDocument\\ConfirmPermit::canPermit</code>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
进产原因来自 CRM <code class="font-mono">ea_case_cstm.label_bit</code>,经 agent-be
|
||||
<code class="font-mono">stuck_payment_reason</code> 配置过滤后写入
|
||||
<code class="font-mono">cases.is_need_pfp</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误 -->
|
||||
@@ -146,6 +151,87 @@
|
||||
提示:{{ check.hint }}
|
||||
</div>
|
||||
|
||||
<!-- 进产原因展示 -->
|
||||
<div v-if="check.key === 'need_pfp'" class="mt-3 space-y-3">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 mb-1">
|
||||
进产(卡款)原因
|
||||
<span class="ml-2 text-gray-400">来源:CRM label_bit & stuck_payment_reason 配置</span>
|
||||
</div>
|
||||
<div v-if="check.reasons && check.reasons.length" class="space-y-2">
|
||||
<div
|
||||
v-for="reason in check.reasons"
|
||||
:key="reason.bit"
|
||||
class="border border-amber-200 bg-amber-50 rounded-lg p-2"
|
||||
>
|
||||
<div class="text-xs font-semibold text-amber-800">
|
||||
{{ reason.label }}
|
||||
<span class="ml-1 font-mono font-normal text-amber-600">bit {{ reason.bit }}</span>
|
||||
<span v-if="reason.crm_label !== reason.label" class="ml-2 font-normal text-amber-600">
|
||||
CRM: {{ reason.crm_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-amber-700 mt-1">{{ reason.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-600 bg-gray-50 rounded-lg p-2">
|
||||
该病例没有任何卡生产原因,不需要代理放行。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="check.crm_ignored_reasons && check.crm_ignored_reasons.length"
|
||||
class="border border-blue-200 bg-blue-50 rounded-lg p-2"
|
||||
>
|
||||
<div class="text-xs font-semibold text-blue-800">CRM 有标记但未纳入配置</div>
|
||||
<div
|
||||
v-for="ignored in check.crm_ignored_reasons"
|
||||
:key="ignored.bit"
|
||||
class="text-xs text-blue-700 mt-1"
|
||||
>
|
||||
{{ ignored.label }} (bit {{ ignored.bit }}) — {{ ignored.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="check.sync_mismatch"
|
||||
class="border border-orange-200 bg-orange-50 rounded-lg p-2 text-xs text-orange-800"
|
||||
>
|
||||
代理库与 CRM 不一致:CRM label_bit = {{ check.crm_label_bit }},按配置应为
|
||||
is_need_pfp = {{ check.expected_is_need_pfp }},实际为 {{ check.is_need_pfp }}。
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-500">CRM label_bit</div>
|
||||
<div class="text-gray-900 break-all">
|
||||
{{ check.crm_available ? check.crm_label_bit + ' · ' + check.crm_label_bit_text : check.crm_label_bit_text }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-500">放行状态 (is_pfp)</div>
|
||||
<div class="text-gray-900">{{ check.is_pfp_text }} ({{ check.is_pfp }})</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-500">配置来源</div>
|
||||
<div class="text-gray-900 break-all">
|
||||
{{ check.config_source_text }} · 掩码 {{ check.config_mask }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="check.config_options && check.config_options.length" class="text-xs text-gray-500">
|
||||
当前 stuck_payment_reason:
|
||||
<span
|
||||
v-for="option in check.config_options"
|
||||
:key="option.bit"
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 mr-1 font-mono"
|
||||
>
|
||||
{{ option.bit }} · {{ option.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账期链路展示 -->
|
||||
<div v-if="check.key === 'credit' && check.chain && check.chain.length" class="mt-3">
|
||||
<div class="text-xs text-gray-500 mb-1">
|
||||
@@ -251,8 +337,12 @@ export default {
|
||||
{ key: 'hospital_code', label: '机构编号', value: e.hospital_code },
|
||||
{ key: 'doctor_code', label: '医生编号', value: e.doctor_code },
|
||||
{ key: 'patient_name', label: '患者姓名', value: e.patient_name },
|
||||
{ key: 'is_need_pfp', label: 'is_need_pfp', value: e.is_need_pfp },
|
||||
{ key: 'is_pfp', label: 'is_pfp', value: e.is_pfp }
|
||||
{
|
||||
key: 'is_need_pfp',
|
||||
label: '进产原因 (is_need_pfp)',
|
||||
value: `${e.debt_reason_text || '无'} (${e.is_need_pfp})`
|
||||
},
|
||||
{ key: 'is_pfp', label: '放行状态 (is_pfp)', value: `${e.is_pfp_text || '-'} (${e.is_pfp})` }
|
||||
);
|
||||
} else {
|
||||
base.push({ key: 'hospital_code', label: '机构编号', value: e.hospital_code });
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import './bootstrap';
|
||||
import { createApp } from 'vue';
|
||||
import ProductionDiagnosis from './components/tools/ProductionDiagnosis.vue';
|
||||
|
||||
const app = createApp({});
|
||||
|
||||
app.component('production-diagnosis', ProductionDiagnosis);
|
||||
app.mount('#app');
|
||||
Reference in New Issue
Block a user