From a2eb83051c87d12219ae1d322edd20da8fe39b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 05:22:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=8D=95=E5=86=B2=E7=AA=81=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/services/http.ts: 保留后端 envelope 错误码、状态和 details - src/stores/platform.ts: 增加发布单列表轻量刷新入口 - src/views: 发布中心和运行记录接入 CONFLICT 刷新提示 - src/types/devops.ts: 同步 ReleaseRun version 字段 --- src/services/devopsApi.ts | 2 + src/services/http.ts | 93 ++++++++++++++++++++++++++++++--- src/stores/platform.ts | 11 ++++ src/types/devops.ts | 1 + src/views/ReleaseCenterView.vue | 35 +++++++++++++ src/views/RunsView.vue | 39 +++++++++++--- 6 files changed, 167 insertions(+), 14 deletions(-) diff --git a/src/services/devopsApi.ts b/src/services/devopsApi.ts index bd5ae94..5c23d1a 100644 --- a/src/services/devopsApi.ts +++ b/src/services/devopsApi.ts @@ -98,6 +98,7 @@ interface BackendDeployRunStep { interface BackendDeployRun { id?: string + version?: number projectKey?: string environment?: EnvironmentName ref?: string @@ -993,6 +994,7 @@ function toReleaseRun(run: BackendDeployRun, projectList: ProjectConfig[]): Rele return { id: run.id || `${projectKey}-${ref}-${run.createdAt ?? Date.now()}`, + version: typeof run.version === 'number' && Number.isFinite(run.version) ? run.version : 0, projectKey, projectName: project?.name ?? projectKey, environment: toEnvironmentName(run.environment), diff --git a/src/services/http.ts b/src/services/http.ts index 1f661b0..2d81c48 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -1,5 +1,29 @@ 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,不承接任何第三方密钥。 */ @@ -27,13 +51,7 @@ http.interceptors.response.use( return response }, (error) => { - const message = - error.response?.data?.error?.message || - error.response?.data?.message || - error.message || - '请求失败,请稍后重试' - - return Promise.reject(new Error(message)) + return Promise.reject(toDevopsApiError(error)) }, ) @@ -59,7 +77,11 @@ function unwrapApiPayload(payload: unknown): unknown { 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) { @@ -76,3 +98,58 @@ function isRecord(value: unknown): value is Record { function isApiEnvelope(value: unknown): value is ApiEnvelope { 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 +} diff --git a/src/stores/platform.ts b/src/stores/platform.ts index 01a61ff..0a0ec01 100644 --- a/src/stores/platform.ts +++ b/src/stores/platform.ts @@ -124,6 +124,16 @@ export const usePlatformStore = defineStore('platform', () => { 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) { const refs = await devopsApi.getProjectRefs(projectKey) projectRefs.value = { @@ -332,6 +342,7 @@ export const usePlatformStore = defineStore('platform', () => { retryDeployRun, syncJenkinsRun, syncJenkinsRuns, + refreshRuns, loadProjectRefs, loadProjectJenkinsDiagnostics, loadProjectGiteaDiagnostics, diff --git a/src/types/devops.ts b/src/types/devops.ts index 6cc23b3..f2d4ecd 100644 --- a/src/types/devops.ts +++ b/src/types/devops.ts @@ -351,6 +351,7 @@ export interface PipelineStep { export interface ReleaseRun { id: string + version: number projectKey: string projectName: string environment: EnvironmentName diff --git a/src/views/ReleaseCenterView.vue b/src/views/ReleaseCenterView.vue index 4107ee9..36b1299 100644 --- a/src/views/ReleaseCenterView.vue +++ b/src/views/ReleaseCenterView.vue @@ -8,6 +8,7 @@ import { computed, reactive, ref, watch } from 'vue' import BpmnStatusViewer from '../components/BpmnStatusViewer.vue' import RunExecutionDetails from '../components/RunExecutionDetails.vue' import StatusPill from '../components/StatusPill.vue' +import { isDevopsApiError } from '../services/http' import { usePlatformStore } from '../stores/platform' import type { EnvironmentName, ProjectRefItem, ReleaseRun, ReleaseStatus } from '../types/devops' @@ -169,6 +170,8 @@ async function triggerRelease() { activeRun.value = run localStatus.value = run.status ElMessage.success('发布单已提交到后端') + } catch (error) { + await handleReleaseActionError(error, '发布单提交失败') } finally { submitting.value = false } @@ -197,11 +200,14 @@ async function cancelRelease() { } submitting.value = true + const runId = shownRun.value.id try { const run = await platform.cancelDeployRun(shownRun.value) activeRun.value = run localStatus.value = run.status ElMessage.success('发布单已取消') + } catch (error) { + await handleReleaseActionError(error, '发布单取消失败', runId) } finally { submitting.value = false } @@ -213,11 +219,14 @@ async function retryRelease() { } submitting.value = true + const runId = shownRun.value.id try { const run = await platform.retryDeployRun(shownRun.value) activeRun.value = run localStatus.value = run.status ElMessage.success('已提交重试发布单') + } catch (error) { + await handleReleaseActionError(error, '发布单重试失败', runId) } finally { submitting.value = false } @@ -229,11 +238,14 @@ async function syncJenkinsRun() { } submitting.value = true + const runId = shownRun.value.id try { const run = await platform.syncJenkinsRun(shownRun.value) activeRun.value = run localStatus.value = run.status ElMessage.success('已同步 Jenkins 状态') + } catch (error) { + await handleReleaseActionError(error, 'Jenkins 状态同步失败', runId) } finally { submitting.value = false } @@ -267,6 +279,29 @@ function useFailedRun() { 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( () => [form.projectKey, platform.projects.length], () => { diff --git a/src/views/RunsView.vue b/src/views/RunsView.vue index 426dd0d..2a17b84 100644 --- a/src/views/RunsView.vue +++ b/src/views/RunsView.vue @@ -9,6 +9,7 @@ import { useRoute } from 'vue-router' import BpmnStatusViewer from '../components/BpmnStatusViewer.vue' import RunExecutionDetails from '../components/RunExecutionDetails.vue' import StatusPill from '../components/StatusPill.vue' +import { isDevopsApiError } from '../services/http' import { usePlatformStore } from '../stores/platform' import type { ReleaseRun } from '../types/devops' @@ -64,9 +65,13 @@ async function syncSelectedRun() { return } - const run = await platform.syncJenkinsRun(selectedRun.value) - selectedRunId.value = run.id - ElMessage.success('已同步 Jenkins 状态') + try { + const run = await platform.syncJenkinsRun(selectedRun.value) + selectedRunId.value = run.id + ElMessage.success('已同步 Jenkins 状态') + } catch (error) { + await handleRunActionError(error, 'Jenkins 状态同步失败') + } } async function retrySelectedRun() { @@ -74,9 +79,13 @@ async function retrySelectedRun() { return } - const run = await platform.retryDeployRun(selectedRun.value) - selectedRunId.value = run.id - ElMessage.success('已提交重试发布单') + try { + const run = await platform.retryDeployRun(selectedRun.value) + selectedRunId.value = run.id + ElMessage.success('已提交重试发布单') + } catch (error) { + await handleRunActionError(error, '发布单重试失败') + } } async function syncAllJenkinsRuns() { @@ -95,10 +104,28 @@ async function syncAllJenkinsRuns() { ? `已同步 ${result.syncedRuns.length} 个 Jenkins run` : '当前没有需要同步的 Jenkins run', ) + } catch (error) { + await handleRunActionError(error, 'Jenkins 批量同步失败') } finally { 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) +}