From 61e6c73f12d8a85506822e9f8b806a15d60ec7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 10:53:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=88=90=E5=91=98?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=92=8C=E5=8F=91=E5=B8=83=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/devopsApi.ts | 6 + src/stores/platform.ts | 18 ++ src/types/devops.ts | 4 +- src/views/MembersView.vue | 374 ++++++++++++++++++++++++++++---- src/views/ReleaseCenterView.vue | 39 +++- src/views/RunsView.vue | 25 ++- 6 files changed, 412 insertions(+), 54 deletions(-) diff --git a/src/services/devopsApi.ts b/src/services/devopsApi.ts index 6130984..265c2d2 100644 --- a/src/services/devopsApi.ts +++ b/src/services/devopsApi.ts @@ -34,6 +34,7 @@ import type { ProjectGiteaRepositoryDiagnostic, ProjectJenkinsDiagnosticsSummary, ProjectJenkinsJobDiagnostic, + ProjectPermissionLevel, ProjectRefs, ReleaseRun, ReleaseStatus, @@ -75,6 +76,8 @@ interface BackendProject { repositoryUrl: string defaultBranch: string status: 'active' | 'archived' + permissionLevel?: ProjectPermissionLevel + permissionLevelName?: string environments: Array<{ name: EnvironmentName displayName: string @@ -818,6 +821,9 @@ function toProjectConfigs(backendProjects: BackendProject[]): ProjectConfig[] { serviceType: serviceTypeLabel(project.key), defaultBranch: project.defaultBranch, tagPolicy: tagPolicyLabel(project.environments), + permissionLevel: project.permissionLevel === 'build' ? 'build' : 'read', + permissionLevelName: + project.permissionLevel === 'build' ? '构建权限' : '只读权限', giteaStatus: 'warning', giteaMessage: '尚未执行 Gitea 诊断', giteaWebhookStatus: 'warning', diff --git a/src/stores/platform.ts b/src/stores/platform.ts index 0a0ec01..af9bd68 100644 --- a/src/stores/platform.ts +++ b/src/stores/platform.ts @@ -14,6 +14,7 @@ import type { ProjectConfig, ProjectGiteaDiagnosticsSummary, ProjectJenkinsDiagnosticsSummary, + ProjectPermissionLevel, ProjectRefs, JenkinsSyncSummary, NotificationOutboxMessage, @@ -52,6 +53,21 @@ export const usePlatformStore = defineStore('platform', () => { runs.value.filter((run) => run.status === 'queued' || run.status === 'running'), ) + function projectPermissionLevel(projectKey?: string): ProjectPermissionLevel { + const session = useSessionStore() + + if (session.role === 'super_admin') { + return 'build' + } + + const project = projects.value.find((item) => item.key === projectKey) + return project?.permissionLevel === 'build' ? 'build' : 'read' + } + + function canBuildProject(projectKey?: string): boolean { + return Boolean(projectKey) && projectPermissionLevel(projectKey) === 'build' + } + async function loadAll() { const session = useSessionStore() const includeAdminData = session.role === 'super_admin' @@ -336,6 +352,8 @@ export const usePlatformStore = defineStore('platform', () => { projectRefs, failedRuns, runningRuns, + projectPermissionLevel, + canBuildProject, loadAll, createDeployRun, cancelDeployRun, diff --git a/src/types/devops.ts b/src/types/devops.ts index 474905d..5e04b7c 100644 --- a/src/types/devops.ts +++ b/src/types/devops.ts @@ -35,7 +35,7 @@ export type AuthRole = 'super_admin' | 'member' export type AuthStatus = 'active' | 'disabled' -export type ProjectPermissionLevel = 'none' | 'read' | 'build' +export type ProjectPermissionLevel = 'read' | 'build' export type PlatformMessageState = 'pending' | 'handled' @@ -214,6 +214,8 @@ export interface ProjectConfig { serviceType: string defaultBranch: string tagPolicy: string + permissionLevel?: ProjectPermissionLevel + permissionLevelName?: string giteaStatus: HealthState giteaMessage?: string giteaWebUrl?: string diff --git a/src/views/MembersView.vue b/src/views/MembersView.vue index 47b7637..460e658 100644 --- a/src/views/MembersView.vue +++ b/src/views/MembersView.vue @@ -3,6 +3,7 @@ * 成员权限页对接后端成员、项目权限和平台消息接口,不在前端模拟账号数据。 */ import { + ArrowRight, Check, Delete, Lock, @@ -19,10 +20,16 @@ import type { CreateMemberPayload, MemberSummary, PlatformMessageSummary, + ProjectConfig, ProjectPermissionLevel, } from '../types/devops' type PermissionMap = Record +type PermissionProjectGroup = { + key: string + label: string + children: ProjectConfig[] +} const route = useRoute() const platform = usePlatformStore() @@ -38,9 +45,9 @@ const createForm = reactive({ permissions: {} as PermissionMap, }) const editPermissions = reactive({}) +const expandedPermissionGroupKeys = ref([]) const permissionOptions: Array<{ label: string; value: ProjectPermissionLevel }> = [ - { label: '无权限', value: 'none' }, { label: '只读', value: 'read' }, { label: '构建', value: 'build' }, ] @@ -64,6 +71,24 @@ const pendingMessages = computed(() => messages.value.filter((message) => message.status === 'pending'), ) +const permissionProjectTree = computed(() => { + const groupByOwner = new Map() + + for (const project of platform.projects) { + const projects = groupByOwner.get(project.owner) ?? [] + projects.push(project) + groupByOwner.set(project.owner, projects) + } + + return Array.from(groupByOwner.entries()) + .map(([owner, projects]) => ({ + key: owner, + label: projectGroupLabel(owner), + children: projects.sort((left, right) => left.name.localeCompare(right.name)), + })) + .sort((left, right) => left.label.localeCompare(right.label)) +}) + onMounted(async () => { if (typeof route.query.account === 'string') { accountKeyword.value = route.query.account @@ -108,12 +133,14 @@ function openCreateDialog() { createForm.account = '' createForm.displayName = '' resetPermissionMap(createForm.permissions) + resetPermissionGroupExpansion() dialogVisible.value = true } function openPermissionDialog(member: MemberSummary) { editingMember.value = member resetPermissionMap(editPermissions, member) + resetPermissionGroupExpansion() dialogVisible.value = true } @@ -238,7 +265,7 @@ function resetPermissionMap(target: PermissionMap, member?: MemberSummary) { ) for (const project of platform.projects) { - target[project.key] = permissionByProject.get(project.key) ?? 'none' + target[project.key] = permissionByProject.get(project.key) ?? 'read' } } @@ -253,6 +280,74 @@ function canMutate(member: MemberSummary) { return member.role !== 'super_admin' } +function setAllProjectBuildPermissions() { + setAllProjectPermissions(currentPermissionMap(), 'build') +} + +function setAllProjectReadPermissions() { + setAllProjectPermissions(currentPermissionMap(), 'read') +} + +function setAllProjectPermissions(target: PermissionMap, level: ProjectPermissionLevel) { + for (const project of platform.projects) { + target[project.key] = level + } +} + +function currentPermissionMap(): PermissionMap { + return editingMember.value ? editPermissions : createForm.permissions +} + +function resetPermissionGroupExpansion() { + expandedPermissionGroupKeys.value = permissionProjectTree.value.map((group) => group.key) +} + +function togglePermissionGroup(groupKey: string) { + if (expandedPermissionGroupKeys.value.includes(groupKey)) { + expandedPermissionGroupKeys.value = expandedPermissionGroupKeys.value.filter( + (key) => key !== groupKey, + ) + return + } + + expandedPermissionGroupKeys.value = [...expandedPermissionGroupKeys.value, groupKey] +} + +function isPermissionGroupExpanded(groupKey: string) { + return expandedPermissionGroupKeys.value.includes(groupKey) +} + +function groupBuildCount(group: PermissionProjectGroup) { + const permissionMap = currentPermissionMap() + return group.children.filter((project) => permissionMap[project.key] === 'build').length +} + +function groupPermissionSummary(group: PermissionProjectGroup) { + const buildCount = groupBuildCount(group) + + if (buildCount === 0) { + return '全部只读' + } + + if (buildCount === group.children.length) { + return '全部构建' + } + + return `${buildCount} 个构建` +} + +function projectGroupLabel(owner: string) { + if (owner === 'devops-platform') { + return '运维平台' + } + + if (owner === 'my-project') { + return '业务项目' + } + + return owner +} + function formatDateTime(value?: string) { if (!value) { return '-' @@ -369,7 +464,7 @@ function formatDateTime(value?: string) { {{ permission.projectName }}:{{ permission.levelName }} @@ -436,52 +531,103 @@ function formatDateTime(value?: string) { - +
-
- - {{ project.name }} - {{ project.key }} - - +
+ 项目权限 + 默认只读,按需开启构建。 +
+
+ + 全部开启 + + + 全部关闭 + +
+
+
+
- - - - - + +
+
+ + {{ project.name }} + {{ project.key }} + + + + {{ option.label }} + + + + + {{ option.label }} + + +
+
+
@@ -569,20 +715,127 @@ function formatDateTime(value?: string) { gap: 6px; } +:deep(.member-permission-dialog .el-dialog__body) { + padding-top: 8px; + padding-bottom: 8px; +} + +:deep(.member-permission-dialog .el-dialog__footer) { + padding-top: 10px; +} + +.member-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + .permission-editor { display: grid; gap: 10px; } +.permission-editor-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 0 10px; + border-bottom: 1px solid #e5eaf3; + color: #4b5565; + font-size: 13px; +} + +.permission-editor-toolbar > div { + display: flex; + align-items: center; + gap: 10px; +} + +.permission-editor-toolbar strong { + color: #172033; + font-size: 14px; +} + +.permission-editor-actions { + flex-shrink: 0; +} + +.permission-tree { + overflow: auto; + max-height: 320px; + border: 1px solid #e5eaf3; + border-radius: 6px; + background: #fff; +} + +.permission-tree-group { + border-bottom: 1px solid #eef2f7; +} + +.permission-tree-group:last-child { + border-bottom: 0; +} + +.permission-tree-group-title { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 8px 10px; + border: 0; + background: #f8fafc; + color: #172033; + font-size: 14px; + text-align: left; + cursor: pointer; +} + +.permission-tree-group-title > span { + display: inline-flex; + align-items: center; + min-width: 0; + gap: 8px; +} + +.permission-tree-group-title small { + color: #7b8798; + font-size: 12px; +} + +.permission-tree-arrow { + flex-shrink: 0; + color: #6b7280; + transition: transform 0.15s ease; +} + +.permission-tree-arrow.expanded { + transform: rotate(90deg); +} + +.permission-tree-summary { + flex-shrink: 0; + color: #64748b; + font-size: 12px; +} + +.permission-project-list { + display: grid; +} + .permission-editor-row { display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 12px; - border: 1px solid #edf1f7; - border-radius: 8px; - background: #fbfcff; + gap: 10px; + min-height: 36px; + padding: 6px 10px 6px 34px; + border-top: 1px solid #f1f4f8; +} + +.tree-project-cell { + display: block; + min-width: 0; } .permission-editor-row strong, @@ -591,25 +844,52 @@ function formatDateTime(value?: string) { } .permission-editor-row small { - margin-top: 4px; + margin-top: 2px; color: #7b8798; font-size: 12px; } -.permission-select { - width: 160px; +.permission-radio-group { + flex-shrink: 0; +} + +.permission-radio-group :deep(.el-radio) { + margin-right: 14px; +} + +.permission-radio-group :deep(.el-radio:last-child) { + margin-right: 0; +} + +.permission-radio-group :deep(.el-radio__label) { + padding-left: 5px; + letter-spacing: 0; } @media (max-width: 760px) { + .member-form-grid { + grid-template-columns: 1fr; + gap: 0; + } + .member-search, - .permission-select { + .permission-radio-group { width: 100%; } + .permission-editor-toolbar { + align-items: stretch; + flex-direction: column; + } + .message-row, .permission-editor-row { align-items: stretch; flex-direction: column; } + + .permission-editor-row { + padding-left: 14px; + } } diff --git a/src/views/ReleaseCenterView.vue b/src/views/ReleaseCenterView.vue index 36b1299..d41ba4e 100644 --- a/src/views/ReleaseCenterView.vue +++ b/src/views/ReleaseCenterView.vue @@ -31,6 +31,14 @@ const selectedProject = computed(() => { return platform.projects.find((project) => project.key === form.projectKey) }) +const selectedProjectPermissionName = computed(() => { + return platform.projectPermissionLevel(form.projectKey) === 'build' + ? '构建权限' + : '只读权限' +}) + +const canBuildSelectedProject = computed(() => platform.canBuildProject(form.projectKey)) + const currentRefs = computed(() => platform.projectRefs[form.projectKey]) const environmentOptions = computed(() => { @@ -129,7 +137,7 @@ const selectedPullRequestMeta = computed(() => { }) const canTrigger = computed(() => { - if (!form.projectKey || !form.environment || !form.ref) { + if (!canBuildSelectedProject.value || !form.projectKey || !form.environment || !form.ref) { return false } @@ -141,12 +149,23 @@ const shownRun = computed(() => { }) const canSyncJenkins = computed(() => { - return shownRun.value?.status === 'queued' || shownRun.value?.status === 'running' + return ( + platform.canBuildProject(shownRun.value?.projectKey) && + (shownRun.value?.status === 'queued' || shownRun.value?.status === 'running') + ) }) -const canCancelShownRun = computed(() => shownRun.value?.status === 'queued' || shownRun.value?.status === 'running') +const canCancelShownRun = computed( + () => + platform.canBuildProject(shownRun.value?.projectKey) && + (shownRun.value?.status === 'queued' || shownRun.value?.status === 'running'), +) -const canRetryShownRun = computed(() => shownRun.value?.status === 'failed' || shownRun.value?.status === 'canceled') +const canRetryShownRun = computed( + () => + platform.canBuildProject(shownRun.value?.projectKey) && + (shownRun.value?.status === 'failed' || shownRun.value?.status === 'canceled'), +) async function triggerRelease() { submitting.value = true @@ -364,6 +383,9 @@ watch(refOptions, (options) => { :value="project.key" /> +
+ 当前权限:{{ selectedProjectPermissionName }} +
{ 重试 + + 当前账号只有只读权限,发布操作已禁用 + @@ -498,10 +523,16 @@ watch(refOptions, (options) => { .action-row { display: flex; flex-wrap: wrap; + align-items: center; gap: 10px; margin-top: 16px; } +.permission-action-hint { + color: #8a5a00; + font-size: 12px; +} + .commit-preview { display: grid; gap: 4px; diff --git a/src/views/RunsView.vue b/src/views/RunsView.vue index 2a17b84..b787522 100644 --- a/src/views/RunsView.vue +++ b/src/views/RunsView.vue @@ -11,9 +11,11 @@ import RunExecutionDetails from '../components/RunExecutionDetails.vue' import StatusPill from '../components/StatusPill.vue' import { isDevopsApiError } from '../services/http' import { usePlatformStore } from '../stores/platform' +import { useSessionStore } from '../stores/session' import type { ReleaseRun } from '../types/devops' const platform = usePlatformStore() +const session = useSessionStore() const route = useRoute() const selectedRunId = ref(platform.runs[0]?.id || '') const syncingAll = ref(false) @@ -30,7 +32,9 @@ const canRetrySelectedRun = computed(() => { return canRetryRun(selectedRun.value) }) -const canSyncAnyRun = computed(() => platform.runs.some((run) => canSyncRun(run))) +const canSyncAnyRun = computed( + () => session.role === 'super_admin' && platform.runs.some((run) => canSyncRun(run)), +) watch( () => [route.query.runId, platform.runs.map((run) => run.id).join('|')], @@ -51,13 +55,17 @@ watch( function canSyncRun(run?: ReleaseRun): boolean { return ( + platform.canBuildProject(run?.projectKey) && Boolean(run?.jenkinsQueueId || run?.jenkinsBuildNumber) && (run?.status === 'queued' || run?.status === 'running') ) } function canRetryRun(run?: ReleaseRun): boolean { - return run?.status === 'failed' || run?.status === 'canceled' + return ( + platform.canBuildProject(run?.projectKey) && + (run?.status === 'failed' || run?.status === 'canceled') + ) } async function syncSelectedRun() { @@ -197,6 +205,12 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) { > 重试 + + 只读权限,操作已禁用 + @@ -216,3 +230,10 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) { + +