feat: 优化发布单冲突提示
- src/services/http.ts: 保留后端 envelope 错误码、状态和 details - src/stores/platform.ts: 增加发布单列表轻量刷新入口 - src/views: 发布中心和运行记录接入 CONFLICT 刷新提示 - src/types/devops.ts: 同步 ReleaseRun version 字段
This commit is contained in:
@@ -98,6 +98,7 @@ interface BackendDeployRunStep {
|
|||||||
|
|
||||||
interface BackendDeployRun {
|
interface BackendDeployRun {
|
||||||
id?: string
|
id?: string
|
||||||
|
version?: number
|
||||||
projectKey?: string
|
projectKey?: string
|
||||||
environment?: EnvironmentName
|
environment?: EnvironmentName
|
||||||
ref?: string
|
ref?: string
|
||||||
@@ -993,6 +994,7 @@ function toReleaseRun(run: BackendDeployRun, projectList: ProjectConfig[]): Rele
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: run.id || `${projectKey}-${ref}-${run.createdAt ?? Date.now()}`,
|
id: run.id || `${projectKey}-${ref}-${run.createdAt ?? Date.now()}`,
|
||||||
|
version: typeof run.version === 'number' && Number.isFinite(run.version) ? run.version : 0,
|
||||||
projectKey,
|
projectKey,
|
||||||
projectName: project?.name ?? projectKey,
|
projectName: project?.name ?? projectKey,
|
||||||
environment: toEnvironmentName(run.environment),
|
environment: toEnvironmentName(run.environment),
|
||||||
|
|||||||
+85
-8
@@ -1,5 +1,29 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
|
type DevopsApiErrorOptions = {
|
||||||
|
code?: string
|
||||||
|
message?: string
|
||||||
|
details?: unknown
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端 envelope 错误在浏览器侧的统一形态,保留 code/details 供页面做精确交互。
|
||||||
|
*/
|
||||||
|
export class DevopsApiError extends Error {
|
||||||
|
readonly code: string
|
||||||
|
readonly details: unknown
|
||||||
|
readonly status: number | undefined
|
||||||
|
|
||||||
|
constructor(options: DevopsApiErrorOptions) {
|
||||||
|
super(options.message || '请求失败,请稍后重试')
|
||||||
|
this.name = 'DevopsApiError'
|
||||||
|
this.code = options.code || 'HTTP_ERROR'
|
||||||
|
this.details = options.details
|
||||||
|
this.status = options.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DevOps 平台统一 HTTP 实例,只识别后端 envelope,不承接任何第三方密钥。
|
* DevOps 平台统一 HTTP 实例,只识别后端 envelope,不承接任何第三方密钥。
|
||||||
*/
|
*/
|
||||||
@@ -27,13 +51,7 @@ http.interceptors.response.use(
|
|||||||
return response
|
return response
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
const message =
|
return Promise.reject(toDevopsApiError(error))
|
||||||
error.response?.data?.error?.message ||
|
|
||||||
error.response?.data?.message ||
|
|
||||||
error.message ||
|
|
||||||
'请求失败,请稍后重试'
|
|
||||||
|
|
||||||
return Promise.reject(new Error(message))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,7 +77,11 @@ function unwrapApiPayload(payload: unknown): unknown {
|
|||||||
return envelope.data
|
return envelope.data
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(envelope.error?.message || '请求失败,请稍后重试')
|
throw new DevopsApiError({
|
||||||
|
code: envelope.error?.code,
|
||||||
|
message: envelope.error?.message,
|
||||||
|
details: envelope.error?.details,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(payload).length === 1 && 'data' in payload) {
|
if (Object.keys(payload).length === 1 && 'data' in payload) {
|
||||||
@@ -76,3 +98,58 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
function isApiEnvelope(value: unknown): value is ApiEnvelope<unknown> {
|
function isApiEnvelope(value: unknown): value is ApiEnvelope<unknown> {
|
||||||
return isRecord(value) && typeof value.success === 'boolean'
|
return isRecord(value) && typeof value.success === 'boolean'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型守卫供页面区分业务错误与普通网络错误,避免靠中文文案判断状态。
|
||||||
|
*/
|
||||||
|
export function isDevopsApiError(error: unknown): error is DevopsApiError {
|
||||||
|
return error instanceof DevopsApiError
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDevopsApiError(error: unknown): DevopsApiError {
|
||||||
|
if (isDevopsApiError(error)) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(error)) {
|
||||||
|
const response = isRecord(error.response) ? error.response : undefined
|
||||||
|
const status = typeof response?.status === 'number' ? response.status : undefined
|
||||||
|
const payload = response?.data
|
||||||
|
|
||||||
|
if (isApiEnvelope(payload)) {
|
||||||
|
return new DevopsApiError({
|
||||||
|
code: payload.error?.code,
|
||||||
|
message: payload.error?.message,
|
||||||
|
details: payload.error?.details,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(payload)) {
|
||||||
|
const backendError = isRecord(payload.error) ? payload.error : undefined
|
||||||
|
const code = textValue(backendError?.code) || textValue(payload.code)
|
||||||
|
const message =
|
||||||
|
textValue(backendError?.message) ||
|
||||||
|
textValue(payload.message) ||
|
||||||
|
textValue(error.message)
|
||||||
|
|
||||||
|
return new DevopsApiError({
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
details: backendError?.details ?? payload.details,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DevopsApiError({
|
||||||
|
message: textValue(error.message),
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DevopsApiError({})
|
||||||
|
}
|
||||||
|
|
||||||
|
function textValue(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value.trim() ? value : undefined
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,6 +124,16 @@ export const usePlatformStore = defineStore('platform', () => {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布单状态被其他请求推进时,只刷新 run 列表并同步项目环境摘要。
|
||||||
|
*/
|
||||||
|
async function refreshRuns() {
|
||||||
|
runs.value = await devopsApi.getRuns(projects.value)
|
||||||
|
applyProjectRunSummaries()
|
||||||
|
dataSource.value = devopsApi.getLastDataSource()
|
||||||
|
return runs.value
|
||||||
|
}
|
||||||
|
|
||||||
async function loadProjectRefs(projectKey: string) {
|
async function loadProjectRefs(projectKey: string) {
|
||||||
const refs = await devopsApi.getProjectRefs(projectKey)
|
const refs = await devopsApi.getProjectRefs(projectKey)
|
||||||
projectRefs.value = {
|
projectRefs.value = {
|
||||||
@@ -332,6 +342,7 @@ export const usePlatformStore = defineStore('platform', () => {
|
|||||||
retryDeployRun,
|
retryDeployRun,
|
||||||
syncJenkinsRun,
|
syncJenkinsRun,
|
||||||
syncJenkinsRuns,
|
syncJenkinsRuns,
|
||||||
|
refreshRuns,
|
||||||
loadProjectRefs,
|
loadProjectRefs,
|
||||||
loadProjectJenkinsDiagnostics,
|
loadProjectJenkinsDiagnostics,
|
||||||
loadProjectGiteaDiagnostics,
|
loadProjectGiteaDiagnostics,
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ export interface PipelineStep {
|
|||||||
|
|
||||||
export interface ReleaseRun {
|
export interface ReleaseRun {
|
||||||
id: string
|
id: string
|
||||||
|
version: number
|
||||||
projectKey: string
|
projectKey: string
|
||||||
projectName: string
|
projectName: string
|
||||||
environment: EnvironmentName
|
environment: EnvironmentName
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { computed, reactive, ref, watch } from 'vue'
|
|||||||
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
|
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
|
||||||
import RunExecutionDetails from '../components/RunExecutionDetails.vue'
|
import RunExecutionDetails from '../components/RunExecutionDetails.vue'
|
||||||
import StatusPill from '../components/StatusPill.vue'
|
import StatusPill from '../components/StatusPill.vue'
|
||||||
|
import { isDevopsApiError } from '../services/http'
|
||||||
import { usePlatformStore } from '../stores/platform'
|
import { usePlatformStore } from '../stores/platform'
|
||||||
import type { EnvironmentName, ProjectRefItem, ReleaseRun, ReleaseStatus } from '../types/devops'
|
import type { EnvironmentName, ProjectRefItem, ReleaseRun, ReleaseStatus } from '../types/devops'
|
||||||
|
|
||||||
@@ -169,6 +170,8 @@ async function triggerRelease() {
|
|||||||
activeRun.value = run
|
activeRun.value = run
|
||||||
localStatus.value = run.status
|
localStatus.value = run.status
|
||||||
ElMessage.success('发布单已提交到后端')
|
ElMessage.success('发布单已提交到后端')
|
||||||
|
} catch (error) {
|
||||||
|
await handleReleaseActionError(error, '发布单提交失败')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -197,11 +200,14 @@ async function cancelRelease() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
|
const runId = shownRun.value.id
|
||||||
try {
|
try {
|
||||||
const run = await platform.cancelDeployRun(shownRun.value)
|
const run = await platform.cancelDeployRun(shownRun.value)
|
||||||
activeRun.value = run
|
activeRun.value = run
|
||||||
localStatus.value = run.status
|
localStatus.value = run.status
|
||||||
ElMessage.success('发布单已取消')
|
ElMessage.success('发布单已取消')
|
||||||
|
} catch (error) {
|
||||||
|
await handleReleaseActionError(error, '发布单取消失败', runId)
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -213,11 +219,14 @@ async function retryRelease() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
|
const runId = shownRun.value.id
|
||||||
try {
|
try {
|
||||||
const run = await platform.retryDeployRun(shownRun.value)
|
const run = await platform.retryDeployRun(shownRun.value)
|
||||||
activeRun.value = run
|
activeRun.value = run
|
||||||
localStatus.value = run.status
|
localStatus.value = run.status
|
||||||
ElMessage.success('已提交重试发布单')
|
ElMessage.success('已提交重试发布单')
|
||||||
|
} catch (error) {
|
||||||
|
await handleReleaseActionError(error, '发布单重试失败', runId)
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -229,11 +238,14 @@ async function syncJenkinsRun() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
|
const runId = shownRun.value.id
|
||||||
try {
|
try {
|
||||||
const run = await platform.syncJenkinsRun(shownRun.value)
|
const run = await platform.syncJenkinsRun(shownRun.value)
|
||||||
activeRun.value = run
|
activeRun.value = run
|
||||||
localStatus.value = run.status
|
localStatus.value = run.status
|
||||||
ElMessage.success('已同步 Jenkins 状态')
|
ElMessage.success('已同步 Jenkins 状态')
|
||||||
|
} catch (error) {
|
||||||
|
await handleReleaseActionError(error, 'Jenkins 状态同步失败', runId)
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -267,6 +279,29 @@ function useFailedRun() {
|
|||||||
activeRun.value = failed
|
activeRun.value = failed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleReleaseActionError(
|
||||||
|
error: unknown,
|
||||||
|
fallbackMessage: string,
|
||||||
|
runId?: string,
|
||||||
|
) {
|
||||||
|
if (isDevopsApiError(error) && error.code === 'CONFLICT') {
|
||||||
|
const refreshedRuns = await platform.refreshRuns()
|
||||||
|
const refreshedRun = runId
|
||||||
|
? refreshedRuns.find((run) => run.id === runId)
|
||||||
|
: refreshedRuns[0]
|
||||||
|
|
||||||
|
if (refreshedRun) {
|
||||||
|
activeRun.value = refreshedRun
|
||||||
|
localStatus.value = refreshedRun.status
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.warning('发布单状态已变化,已刷新最新记录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : fallbackMessage)
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [form.projectKey, platform.projects.length],
|
() => [form.projectKey, platform.projects.length],
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
+33
-6
@@ -9,6 +9,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
|
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
|
||||||
import RunExecutionDetails from '../components/RunExecutionDetails.vue'
|
import RunExecutionDetails from '../components/RunExecutionDetails.vue'
|
||||||
import StatusPill from '../components/StatusPill.vue'
|
import StatusPill from '../components/StatusPill.vue'
|
||||||
|
import { isDevopsApiError } from '../services/http'
|
||||||
import { usePlatformStore } from '../stores/platform'
|
import { usePlatformStore } from '../stores/platform'
|
||||||
import type { ReleaseRun } from '../types/devops'
|
import type { ReleaseRun } from '../types/devops'
|
||||||
|
|
||||||
@@ -64,9 +65,13 @@ async function syncSelectedRun() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = await platform.syncJenkinsRun(selectedRun.value)
|
try {
|
||||||
selectedRunId.value = run.id
|
const run = await platform.syncJenkinsRun(selectedRun.value)
|
||||||
ElMessage.success('已同步 Jenkins 状态')
|
selectedRunId.value = run.id
|
||||||
|
ElMessage.success('已同步 Jenkins 状态')
|
||||||
|
} catch (error) {
|
||||||
|
await handleRunActionError(error, 'Jenkins 状态同步失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retrySelectedRun() {
|
async function retrySelectedRun() {
|
||||||
@@ -74,9 +79,13 @@ async function retrySelectedRun() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = await platform.retryDeployRun(selectedRun.value)
|
try {
|
||||||
selectedRunId.value = run.id
|
const run = await platform.retryDeployRun(selectedRun.value)
|
||||||
ElMessage.success('已提交重试发布单')
|
selectedRunId.value = run.id
|
||||||
|
ElMessage.success('已提交重试发布单')
|
||||||
|
} catch (error) {
|
||||||
|
await handleRunActionError(error, '发布单重试失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncAllJenkinsRuns() {
|
async function syncAllJenkinsRuns() {
|
||||||
@@ -95,10 +104,28 @@ async function syncAllJenkinsRuns() {
|
|||||||
? `已同步 ${result.syncedRuns.length} 个 Jenkins run`
|
? `已同步 ${result.syncedRuns.length} 个 Jenkins run`
|
||||||
: '当前没有需要同步的 Jenkins run',
|
: '当前没有需要同步的 Jenkins run',
|
||||||
)
|
)
|
||||||
|
} catch (error) {
|
||||||
|
await handleRunActionError(error, 'Jenkins 批量同步失败')
|
||||||
} finally {
|
} finally {
|
||||||
syncingAll.value = false
|
syncingAll.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRunActionError(error: unknown, fallbackMessage: string) {
|
||||||
|
if (isDevopsApiError(error) && error.code === 'CONFLICT') {
|
||||||
|
await platform.refreshRuns()
|
||||||
|
const stillSelected = platform.runs.find((run) => run.id === selectedRunId.value)
|
||||||
|
|
||||||
|
if (!stillSelected && platform.runs[0]) {
|
||||||
|
selectedRunId.value = platform.runs[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.warning('发布单状态已变化,已刷新最新记录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : fallbackMessage)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
Reference in New Issue
Block a user