feat: 接入成员权限与 Agent 配置页面
- src/views/MembersView.vue: 新增成员、项目权限和密码消息管理 - src/views/SettingsView.vue: 接入 Agent 配置读取、保存和连接测试 - src/services/devopsApi.ts: 补齐成员、消息和 Agent 配置真实接口 - src/views/AgentView.vue: 移除旧独立 Agent 页面,保留顶部右侧抽屉入口
This commit is contained in:
@@ -4,8 +4,9 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于
|
|||||||
|
|
||||||
## 当前范围
|
## 当前范围
|
||||||
|
|
||||||
- 登录、总览、项目配置、发布中心、运行记录、系统配置,以及全局右侧 Agent 抽屉。
|
- 登录、总览、项目配置、成员权限、发布中心、运行记录、系统配置,以及全局右侧 Agent 抽屉。
|
||||||
- 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。
|
- 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。
|
||||||
|
- 成员权限页调用后端 `/members` 和 `/messages`,维护普通成员账号、项目权限、密码重置和平台消息。
|
||||||
- 发布中心包含项目、环境、ref/tag、备注、生产二次确认、触发、取消、重试和 Jenkins 状态同步入口。
|
- 发布中心包含项目、环境、ref/tag、备注、生产二次确认、触发、取消、重试和 Jenkins 状态同步入口。
|
||||||
- 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要。
|
- 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要。
|
||||||
- BPMN Viewer 使用 `bpmn-js` 渲染后端流程 XML,并通过进度统计、节点状态 badge、右侧时间线和详情面板展示 queued/running/success/failed/skipped/canceled 状态。
|
- BPMN Viewer 使用 `bpmn-js` 渲染后端流程 XML,并通过进度统计、节点状态 badge、右侧时间线和详情面板展示 queued/running/success/failed/skipped/canceled 状态。
|
||||||
@@ -41,7 +42,7 @@ pnpm build
|
|||||||
|
|
||||||
接口入口为 `src/services/devopsApi.ts`,只调用后端统一 envelope 响应;后端不可用时页面应暴露真实接口错误,不伪造项目、运行记录或 refs。后续以 `/Users/mac033/Desktop/my-project/devops-platform-docs/devops-platform-implementation-tracker.md` 中的产品约束和后端 OpenAPI 为准继续收敛字段。
|
接口入口为 `src/services/devopsApi.ts`,只调用后端统一 envelope 响应;后端不可用时页面应暴露真实接口错误,不伪造项目、运行记录或 refs。后续以 `/Users/mac033/Desktop/my-project/devops-platform-docs/devops-platform-implementation-tracker.md` 中的产品约束和后端 OpenAPI 为准继续收敛字段。
|
||||||
|
|
||||||
通知平台配置方案见 [docs/notification-platform-config.md](docs/notification-platform-config.md),包含企业微信、飞书和通用 webhook 的后端变量、安全边界与验证步骤。`/settings` 页面支持展示后端返回的变量说明字段,用于 hover 查看每个变量含义。
|
通知平台配置方案见 [docs/notification-platform-config.md](docs/notification-platform-config.md),包含企业微信、飞书和通用 webhook 的后端变量、安全边界与验证步骤。`/settings` 页面支持展示后端返回的变量说明字段,用于 hover 查看每个变量含义,并通过 `/agent/config` 维护后端保存的模型 baseURL、model 和 key。
|
||||||
|
|
||||||
浏览器端不得保存或直连以下密钥和服务:
|
浏览器端不得保存或直连以下密钥和服务:
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,14 @@
|
|||||||
|
|
||||||
## 页面结构
|
## 页面结构
|
||||||
|
|
||||||
- `/login`:本地操作员会话入口,后续接后端认证。
|
- `/login`:真实平台账号登录入口,支持 token 恢复和首次改密。
|
||||||
- `/overview`:总览,展示服务状态、最近发布和项目环境。
|
- `/overview`:总览,展示服务状态、最近发布和项目环境。
|
||||||
- `/projects`:项目配置,展示 Gitea 仓库、Jenkins Job、通知通道和环境策略。
|
- `/projects`:项目配置,展示 Gitea 仓库、Jenkins Job、通知通道和环境策略。
|
||||||
|
- `/members`:成员权限,仅超级管理员可见,维护普通成员、项目权限和密码重置消息。
|
||||||
- `/releases`:发布中心,提供发布参数、二次确认、触发、取消、重试和 BPMN 流程状态。
|
- `/releases`:发布中心,提供发布参数、二次确认、触发、取消、重试和 BPMN 流程状态。
|
||||||
- `/runs`:运行记录,展示 run 列表和流程轨迹。
|
- `/runs`:运行记录,展示 run 列表和流程轨迹。
|
||||||
- 顶部 Agent 按钮:打开全局右侧抽屉,只保留 DevOps 场景能力;`/agent` 仅重定向到总览,不作为独立侧边栏入口。
|
- 顶部 Agent 按钮:打开全局右侧抽屉,只保留 DevOps 场景能力;`/agent` 仅重定向到总览,不作为独立侧边栏入口。
|
||||||
- `/settings`:系统配置,展示 API、BPMN、Agent、通知边界。
|
- `/settings`:系统配置,展示 API、BPMN、Agent、通知边界,并维护后端 Agent 模型配置。
|
||||||
|
|
||||||
## 数据约定
|
## 数据约定
|
||||||
|
|
||||||
@@ -32,8 +33,9 @@
|
|||||||
|
|
||||||
## 后续接入点
|
## 后续接入点
|
||||||
|
|
||||||
- `devopsApi` 所有项目、发布、refs、流程和 Agent 调用均走后端接口。
|
- `devopsApi` 所有项目、发布、refs、流程、成员、消息和 Agent 调用均走后端接口。
|
||||||
- 发布中心触发按钮接入后端发布单创建接口。
|
- 发布中心触发按钮接入后端发布单创建接口。
|
||||||
- 运行记录接入后端 run/step 查询接口。
|
- 运行记录接入后端 run/step 查询接口。
|
||||||
- BPMN XML 由后端流程定义接口返回。
|
- BPMN XML 由后端流程定义接口返回。
|
||||||
- Agent 面板调用后端 Agent proxy,前端只传 DevOps 场景参数。
|
- Agent 面板调用后端 Agent proxy,前端只传 DevOps 场景参数。
|
||||||
|
- Agent 配置页调用后端 `/agent/config`,前端不持久化 key、baseURL 或 model。
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
SwitchButton,
|
SwitchButton,
|
||||||
Tickets,
|
Tickets,
|
||||||
Tools,
|
Tools,
|
||||||
|
User,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@@ -28,13 +29,18 @@ const agentOpen = ref(false)
|
|||||||
|
|
||||||
const activeMenu = computed(() => route.path)
|
const activeMenu = computed(() => route.path)
|
||||||
|
|
||||||
const menus = [
|
const menus = computed(() => [
|
||||||
{ path: '/overview', label: '总览', icon: DataAnalysis },
|
{ path: '/overview', label: '总览', icon: DataAnalysis },
|
||||||
{ path: '/projects', label: '项目配置', icon: Operation },
|
{ path: '/projects', label: '项目配置', icon: Operation },
|
||||||
|
...(session.role === 'super_admin'
|
||||||
|
? [{ path: '/members', label: '成员权限', icon: User }]
|
||||||
|
: []),
|
||||||
{ path: '/releases', label: '发布中心', icon: DocumentChecked },
|
{ path: '/releases', label: '发布中心', icon: DocumentChecked },
|
||||||
{ path: '/runs', label: '运行记录', icon: Tickets },
|
{ path: '/runs', label: '运行记录', icon: Tickets },
|
||||||
{ path: '/settings', label: '系统配置', icon: Setting },
|
...(session.role === 'super_admin'
|
||||||
]
|
? [{ path: '/settings', label: '系统配置', icon: Setting }]
|
||||||
|
: []),
|
||||||
|
])
|
||||||
|
|
||||||
const jenkinsStatus = computed(() => {
|
const jenkinsStatus = computed(() => {
|
||||||
return platform.integrations.find((item) => item.key === 'jenkins')?.state || 'offline'
|
return platform.integrations.find((item) => item.key === 'jenkins')?.state || 'offline'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import MainLayout from '../layouts/MainLayout.vue'
|
|||||||
import LoginView from '../views/LoginView.vue'
|
import LoginView from '../views/LoginView.vue'
|
||||||
import OverviewView from '../views/OverviewView.vue'
|
import OverviewView from '../views/OverviewView.vue'
|
||||||
import ProjectsView from '../views/ProjectsView.vue'
|
import ProjectsView from '../views/ProjectsView.vue'
|
||||||
|
import MembersView from '../views/MembersView.vue'
|
||||||
import ReleaseCenterView from '../views/ReleaseCenterView.vue'
|
import ReleaseCenterView from '../views/ReleaseCenterView.vue'
|
||||||
import RunsView from '../views/RunsView.vue'
|
import RunsView from '../views/RunsView.vue'
|
||||||
import SettingsView from '../views/SettingsView.vue'
|
import SettingsView from '../views/SettingsView.vue'
|
||||||
@@ -32,6 +33,14 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'projects',
|
name: 'projects',
|
||||||
component: ProjectsView,
|
component: ProjectsView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'members',
|
||||||
|
name: 'members',
|
||||||
|
component: MembersView,
|
||||||
|
meta: {
|
||||||
|
superAdminOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'releases',
|
path: 'releases',
|
||||||
name: 'releases',
|
name: 'releases',
|
||||||
@@ -73,6 +82,12 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.meta.superAdminOnly && session.role !== 'super_admin') {
|
||||||
|
return {
|
||||||
|
name: 'overview',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (to.name === 'login' && authenticated && !session.mustChangePassword) {
|
if (to.name === 'login' && authenticated && !session.mustChangePassword) {
|
||||||
return {
|
return {
|
||||||
name: 'overview',
|
name: 'overview',
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
*/
|
*/
|
||||||
import type {
|
import type {
|
||||||
AgentCard,
|
AgentCard,
|
||||||
|
AgentConfigSummary,
|
||||||
|
AgentConnectionTestResult,
|
||||||
AgentInvocation,
|
AgentInvocation,
|
||||||
AuthenticatedUser,
|
AuthenticatedUser,
|
||||||
AuditLogEntry,
|
AuditLogEntry,
|
||||||
ChangePasswordPayload,
|
ChangePasswordPayload,
|
||||||
CreateAgentInvocationPayload,
|
CreateAgentInvocationPayload,
|
||||||
CreateDeployRunPayload,
|
CreateDeployRunPayload,
|
||||||
|
CreateMemberPayload,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
EnvironmentName,
|
EnvironmentName,
|
||||||
HealthState,
|
HealthState,
|
||||||
@@ -20,13 +23,18 @@ import type {
|
|||||||
JenkinsSyncSummary,
|
JenkinsSyncSummary,
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
|
MemberSummary,
|
||||||
PipelineStep,
|
PipelineStep,
|
||||||
|
PlatformMessageSummary,
|
||||||
ProjectConfig,
|
ProjectConfig,
|
||||||
ProjectRefs,
|
ProjectRefs,
|
||||||
ReleaseRun,
|
ReleaseRun,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
|
SaveAgentConfigPayload,
|
||||||
StepStatus,
|
StepStatus,
|
||||||
SystemSetting,
|
SystemSetting,
|
||||||
|
UpdateMemberPermissionsPayload,
|
||||||
|
UpdateMemberStatusPayload,
|
||||||
} from '../types/devops'
|
} from '../types/devops'
|
||||||
import { http } from './http'
|
import { http } from './http'
|
||||||
|
|
||||||
@@ -243,6 +251,66 @@ export const devopsApi = {
|
|||||||
lastDataSource = 'backend'
|
lastDataSource = 'backend'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getMembers(): Promise<MemberSummary[]> {
|
||||||
|
const { data } = await http.get<MemberSummary[]>('/members')
|
||||||
|
lastDataSource = 'backend'
|
||||||
|
return Array.isArray(data) ? data : []
|
||||||
|
},
|
||||||
|
|
||||||
|
async createMember(payload: CreateMemberPayload): Promise<MemberSummary> {
|
||||||
|
const { data } = await http.post<MemberSummary>('/members', payload)
|
||||||
|
lastDataSource = 'backend'
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateMemberPermissions(
|
||||||
|
memberId: string,
|
||||||
|
payload: UpdateMemberPermissionsPayload,
|
||||||
|
): Promise<MemberSummary> {
|
||||||
|
const { data } = await http.patch<MemberSummary>(
|
||||||
|
`/members/${memberId}/permissions`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
lastDataSource = 'backend'
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetMemberPassword(memberId: string): Promise<MemberSummary> {
|
||||||
|
const { data } = await http.post<MemberSummary>(
|
||||||
|
`/members/${memberId}/reset-password`,
|
||||||
|
)
|
||||||
|
lastDataSource = 'backend'
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateMemberStatus(
|
||||||
|
memberId: string,
|
||||||
|
payload: UpdateMemberStatusPayload,
|
||||||
|
): Promise<MemberSummary> {
|
||||||
|
const { data } = await http.patch<MemberSummary>(
|
||||||
|
`/members/${memberId}/status`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
lastDataSource = 'backend'
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteMember(memberId: string): Promise<void> {
|
||||||
|
await http.delete(`/members/${memberId}`)
|
||||||
|
lastDataSource = 'backend'
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMessages(): Promise<PlatformMessageSummary[]> {
|
||||||
|
const { data } = await http.get<PlatformMessageSummary[]>('/messages')
|
||||||
|
lastDataSource = 'backend'
|
||||||
|
return Array.isArray(data) ? data : []
|
||||||
|
},
|
||||||
|
|
||||||
|
async markMessageHandled(messageId: string): Promise<void> {
|
||||||
|
await http.post(`/messages/${messageId}/handled`)
|
||||||
|
lastDataSource = 'backend'
|
||||||
|
},
|
||||||
|
|
||||||
async getDashboard(): Promise<DashboardSummary> {
|
async getDashboard(): Promise<DashboardSummary> {
|
||||||
const [health, backendProjects, backendRuns, integrationConfig] = await Promise.all([
|
const [health, backendProjects, backendRuns, integrationConfig] = await Promise.all([
|
||||||
fetchHealth(),
|
fetchHealth(),
|
||||||
@@ -343,6 +411,28 @@ export const devopsApi = {
|
|||||||
return fetchIntegrationConfigStatus()
|
return fetchIntegrationConfigStatus()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getAgentConfig(): Promise<AgentConfigSummary> {
|
||||||
|
const { data } = await http.get<AgentConfigSummary>('/agent/config')
|
||||||
|
lastDataSource = 'backend'
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveAgentConfig(
|
||||||
|
payload: SaveAgentConfigPayload,
|
||||||
|
): Promise<AgentConfigSummary> {
|
||||||
|
const { data } = await http.put<AgentConfigSummary>('/agent/config', payload)
|
||||||
|
lastDataSource = 'backend'
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async testAgentConfig(): Promise<AgentConnectionTestResult> {
|
||||||
|
const { data } = await http.post<AgentConnectionTestResult>(
|
||||||
|
'/agent/config/test',
|
||||||
|
)
|
||||||
|
lastDataSource = 'backend'
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
async getReleaseProcessXml(): Promise<string> {
|
async getReleaseProcessXml(): Promise<string> {
|
||||||
const { data } = await http.get<BackendProcessDefinition>(
|
const { data } = await http.get<BackendProcessDefinition>(
|
||||||
'/process-definitions/release',
|
'/process-definitions/release',
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export type AuthRole = 'super_admin' | 'member'
|
|||||||
|
|
||||||
export type AuthStatus = 'active' | 'disabled'
|
export type AuthStatus = 'active' | 'disabled'
|
||||||
|
|
||||||
|
export type ProjectPermissionLevel = 'none' | 'read' | 'build'
|
||||||
|
|
||||||
|
export type PlatformMessageState = 'pending' | 'handled'
|
||||||
|
|
||||||
|
export type PlatformMessageKind = 'password_reset_request'
|
||||||
|
|
||||||
export interface AuthenticatedUser {
|
export interface AuthenticatedUser {
|
||||||
id: string
|
id: string
|
||||||
account: string
|
account: string
|
||||||
@@ -61,6 +67,66 @@ export interface ChangePasswordPayload {
|
|||||||
newPassword: string
|
newPassword: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemberProjectPermission {
|
||||||
|
projectId: string
|
||||||
|
projectKey: string
|
||||||
|
projectName: string
|
||||||
|
level: ProjectPermissionLevel
|
||||||
|
levelName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberSummary {
|
||||||
|
id: string
|
||||||
|
account: string
|
||||||
|
displayName: string
|
||||||
|
role: AuthRole
|
||||||
|
roleName: string
|
||||||
|
status: AuthStatus
|
||||||
|
statusName: string
|
||||||
|
mustChangePassword: boolean
|
||||||
|
hasPendingPasswordResetRequest: boolean
|
||||||
|
lastLoginAt?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
projectPermissions: MemberProjectPermission[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectPermissionPayload {
|
||||||
|
projectKey: string
|
||||||
|
level: ProjectPermissionLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMemberPayload {
|
||||||
|
account: string
|
||||||
|
displayName: string
|
||||||
|
permissions?: ProjectPermissionPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMemberPermissionsPayload {
|
||||||
|
permissions: ProjectPermissionPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMemberStatusPayload {
|
||||||
|
status: AuthStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformMessageSummary {
|
||||||
|
id: string
|
||||||
|
type: PlatformMessageKind
|
||||||
|
status: PlatformMessageState
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
requesterId?: string
|
||||||
|
requesterAccount: string
|
||||||
|
targetResourceType?: string
|
||||||
|
targetResourceId?: string
|
||||||
|
targetUrl?: string
|
||||||
|
handledById?: string
|
||||||
|
handledAt?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export type IntegrationConfigKey =
|
export type IntegrationConfigKey =
|
||||||
| 'jenkins'
|
| 'jenkins'
|
||||||
| 'gitea'
|
| 'gitea'
|
||||||
@@ -273,3 +339,24 @@ export interface CreateAgentInvocationPayload {
|
|||||||
projectKey?: string
|
projectKey?: string
|
||||||
promptSummary: string
|
promptSummary: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentConfigSummary {
|
||||||
|
baseURL: string
|
||||||
|
model: string
|
||||||
|
hasKey: boolean
|
||||||
|
maskedKey?: string
|
||||||
|
warning: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveAgentConfigPayload {
|
||||||
|
key?: string
|
||||||
|
baseURL: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentConnectionTestResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { devopsApi } from '../services/devopsApi'
|
|
||||||
import { usePlatformStore } from '../stores/platform'
|
|
||||||
import type { AgentMode, AgentPurpose } from '../types/devops'
|
|
||||||
|
|
||||||
const platform = usePlatformStore()
|
|
||||||
const selectedMode = ref<AgentMode>('risk')
|
|
||||||
const selectedProject = ref('access-manage')
|
|
||||||
const selectedRun = ref('run-1050')
|
|
||||||
const runbookQuestion = ref('测试环境 Jenkins 构建失败时应该先看哪些信息?')
|
|
||||||
const generating = ref(false)
|
|
||||||
const resultMarkdown = ref('')
|
|
||||||
|
|
||||||
const activeCard = computed(() => {
|
|
||||||
return platform.agentCards.find((card) => card.mode === selectedMode.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const preview = computed(() => {
|
|
||||||
if (selectedMode.value === 'risk') {
|
|
||||||
return ['检查生产 tag 是否来自 develop 合并记录', '确认 Jenkins prod job 参数模板已配置', '通知平台未配置时不阻断发布流程']
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedMode.value === 'diagnosis') {
|
|
||||||
return ['失败节点:Jenkins 构建', '建议先查看 pnpm build 类型错误和 env 注入', '重试前确认依赖锁文件未被并行任务改写']
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedMode.value === 'runbook') {
|
|
||||||
return ['查看 run steps 中的失败节点和 build URL', '读取 progressiveText 的最后 200 行', '若为配置缺失,升级到后端维护密钥和 Job mapping']
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['发布对象、环境、tag 和操作者', '主要变更和验证范围', '回滚入口与通知对象']
|
|
||||||
})
|
|
||||||
|
|
||||||
const purpose = computed<AgentPurpose>(() => {
|
|
||||||
if (selectedMode.value === 'diagnosis') {
|
|
||||||
return 'failure-diagnosis'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedMode.value === 'runbook') {
|
|
||||||
return 'runbook-qa'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedMode.value === 'releaseNotes') {
|
|
||||||
return 'release-note'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'release-risk'
|
|
||||||
})
|
|
||||||
|
|
||||||
async function generateAgentResult() {
|
|
||||||
generating.value = true
|
|
||||||
try {
|
|
||||||
const invocation = await devopsApi.createAgentInvocation({
|
|
||||||
type: purpose.value,
|
|
||||||
runId: selectedMode.value === 'diagnosis' ? selectedRun.value : undefined,
|
|
||||||
projectKey: selectedProject.value,
|
|
||||||
promptSummary:
|
|
||||||
selectedMode.value === 'runbook'
|
|
||||||
? runbookQuestion.value
|
|
||||||
: `${activeCard.value?.title ?? 'Agent'} · ${selectedProject.value}`,
|
|
||||||
})
|
|
||||||
resultMarkdown.value = invocation.resultMarkdown || 'Agent 未返回内容'
|
|
||||||
ElMessage.success('Agent 结果已生成')
|
|
||||||
} finally {
|
|
||||||
generating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="page-shell">
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<h1>Agent 辅助</h1>
|
|
||||||
<p>仅提供 DevOps 场景能力:发布风险、失败诊断、Runbook 问答和发布说明草稿。</p>
|
|
||||||
</div>
|
|
||||||
<ElTag type="warning" effect="plain" round>无泛聊天入口</ElTag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="three-column">
|
|
||||||
<button
|
|
||||||
v-for="card in platform.agentCards"
|
|
||||||
:key="card.mode"
|
|
||||||
class="agent-card"
|
|
||||||
:class="{ active: selectedMode === card.mode }"
|
|
||||||
type="button"
|
|
||||||
@click="selectedMode = card.mode"
|
|
||||||
>
|
|
||||||
<strong>{{ card.title }}</strong>
|
|
||||||
<span>{{ card.description }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="two-column">
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>{{ activeCard?.title }}</h2>
|
|
||||||
<span>输入被限制在运维对象内</span>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<ElForm label-position="top">
|
|
||||||
<ElFormItem label="项目">
|
|
||||||
<ElSelect v-model="selectedProject">
|
|
||||||
<ElOption
|
|
||||||
v-for="project in platform.projects"
|
|
||||||
:key="project.key"
|
|
||||||
:label="project.name"
|
|
||||||
:value="project.key"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
<ElFormItem v-if="selectedMode === 'diagnosis'" label="失败 Run">
|
|
||||||
<ElSelect v-model="selectedRun">
|
|
||||||
<ElOption
|
|
||||||
v-for="run in platform.failedRuns"
|
|
||||||
:key="run.id"
|
|
||||||
:label="`${run.id} · ${run.projectName} · ${run.ref}`"
|
|
||||||
:value="run.id"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
<ElFormItem v-if="selectedMode === 'runbook'" label="Runbook 问题">
|
|
||||||
<ElInput v-model="runbookQuestion" type="textarea" :rows="3" maxlength="120" show-word-limit />
|
|
||||||
<div class="field-hint">问题必须围绕发布、构建、通知、流程状态或回滚。</div>
|
|
||||||
</ElFormItem>
|
|
||||||
<ElFormItem v-if="selectedMode === 'releaseNotes'" label="发布 tag">
|
|
||||||
<ElSelect model-value="v2026.06.11-api.1">
|
|
||||||
<ElOption label="v2026.06.11-api.1" value="v2026.06.11-api.1" />
|
|
||||||
<ElOption label="v2026.06.05-envsplit.7" value="v2026.06.05-envsplit.7" />
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
<ElButton type="primary" :loading="generating" @click="generateAgentResult">
|
|
||||||
生成结果
|
|
||||||
</ElButton>
|
|
||||||
</ElForm>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>输出预览</h2>
|
|
||||||
<span>后端代理 LLM 后替换</span>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<div class="scope-block">
|
|
||||||
<strong>允许输入</strong>
|
|
||||||
<ElTag v-for="scope in activeCard?.inputScope" :key="scope" type="info" effect="plain">
|
|
||||||
{{ scope }}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
|
||||||
<div class="scope-block">
|
|
||||||
<strong>预期输出</strong>
|
|
||||||
<ul>
|
|
||||||
<li v-for="item in preview" :key="item">{{ item }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div v-if="resultMarkdown" class="result-block">
|
|
||||||
<strong>生成结果</strong>
|
|
||||||
<p>{{ resultMarkdown }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.agent-card {
|
|
||||||
min-height: 118px;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid #e3e8f2;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #ffffff;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card.active {
|
|
||||||
border-color: #1d5fd0;
|
|
||||||
background: #f3f7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card strong,
|
|
||||||
.agent-card span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card strong {
|
|
||||||
color: #172033;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card span {
|
|
||||||
margin-top: 10px;
|
|
||||||
color: #697386;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scope-block {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scope-block + .scope-block {
|
|
||||||
border-top: 1px solid #edf1f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scope-block strong {
|
|
||||||
width: 100%;
|
|
||||||
color: #172033;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 18px;
|
|
||||||
color: #596578;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-block {
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid #edf1f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-block strong {
|
|
||||||
display: block;
|
|
||||||
color: #172033;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-block p {
|
|
||||||
margin: 8px 0 0;
|
|
||||||
color: #334155;
|
|
||||||
line-height: 1.7;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 成员权限页对接后端成员、项目权限和平台消息接口,不在前端模拟账号数据。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Delete,
|
||||||
|
Lock,
|
||||||
|
Plus,
|
||||||
|
Refresh,
|
||||||
|
User,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { devopsApi } from '../services/devopsApi'
|
||||||
|
import { usePlatformStore } from '../stores/platform'
|
||||||
|
import type {
|
||||||
|
CreateMemberPayload,
|
||||||
|
MemberSummary,
|
||||||
|
PlatformMessageSummary,
|
||||||
|
ProjectPermissionLevel,
|
||||||
|
} from '../types/devops'
|
||||||
|
|
||||||
|
type PermissionMap = Record<string, ProjectPermissionLevel>
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const platform = usePlatformStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingMember = ref<MemberSummary | null>(null)
|
||||||
|
const members = ref<MemberSummary[]>([])
|
||||||
|
const messages = ref<PlatformMessageSummary[]>([])
|
||||||
|
const accountKeyword = ref('')
|
||||||
|
const createForm = reactive({
|
||||||
|
account: '',
|
||||||
|
displayName: '',
|
||||||
|
permissions: {} as PermissionMap,
|
||||||
|
})
|
||||||
|
const editPermissions = reactive<PermissionMap>({})
|
||||||
|
|
||||||
|
const permissionOptions: Array<{ label: string; value: ProjectPermissionLevel }> = [
|
||||||
|
{ label: '无权限', value: 'none' },
|
||||||
|
{ label: '只读', value: 'read' },
|
||||||
|
{ label: '构建', value: 'build' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filteredMembers = computed(() => {
|
||||||
|
const keyword = accountKeyword.value.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
return members.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return members.value.filter((member) => {
|
||||||
|
return (
|
||||||
|
member.account.toLowerCase().includes(keyword) ||
|
||||||
|
member.displayName.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingMessages = computed(() =>
|
||||||
|
messages.value.filter((message) => message.status === 'pending'),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (typeof route.query.account === 'string') {
|
||||||
|
accountKeyword.value = route.query.account
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureProjects()
|
||||||
|
await loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.account,
|
||||||
|
(account) => {
|
||||||
|
if (typeof account === 'string') {
|
||||||
|
accountKeyword.value = account
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function ensureProjects() {
|
||||||
|
if (!platform.projects.length) {
|
||||||
|
await platform.loadAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [memberList, messageList] = await Promise.all([
|
||||||
|
devopsApi.getMembers(),
|
||||||
|
devopsApi.getMessages(),
|
||||||
|
])
|
||||||
|
members.value = memberList
|
||||||
|
messages.value = messageList
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
editingMember.value = null
|
||||||
|
createForm.account = ''
|
||||||
|
createForm.displayName = ''
|
||||||
|
resetPermissionMap(createForm.permissions)
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPermissionDialog(member: MemberSummary) {
|
||||||
|
editingMember.value = member
|
||||||
|
resetPermissionMap(editPermissions, member)
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDialog() {
|
||||||
|
if (editingMember.value) {
|
||||||
|
await updatePermissions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await createMember()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMember() {
|
||||||
|
const payload: CreateMemberPayload = {
|
||||||
|
account: createForm.account.trim(),
|
||||||
|
displayName: createForm.displayName.trim(),
|
||||||
|
permissions: toPermissionPayload(createForm.permissions),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.account || !payload.displayName) {
|
||||||
|
ElMessage.error('请填写账号和名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await devopsApi.createMember(payload)
|
||||||
|
upsertMember(member)
|
||||||
|
dialogVisible.value = false
|
||||||
|
ElMessage.success('成员已创建,初始密码为 111111')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePermissions() {
|
||||||
|
const member = editingMember.value
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await devopsApi.updateMemberPermissions(member.id, {
|
||||||
|
permissions: toPermissionPayload(editPermissions),
|
||||||
|
})
|
||||||
|
upsertMember(updated)
|
||||||
|
dialogVisible.value = false
|
||||||
|
ElMessage.success('权限已更新')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword(member: MemberSummary) {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认将 ${member.displayName} 的密码重置为 111111?`,
|
||||||
|
'重置密码',
|
||||||
|
{
|
||||||
|
confirmButtonText: '重置',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const updated = await devopsApi.resetMemberPassword(member.id)
|
||||||
|
upsertMember(updated)
|
||||||
|
await loadMessages()
|
||||||
|
ElMessage.success('密码已重置')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(member: MemberSummary) {
|
||||||
|
const nextStatus = member.status === 'active' ? 'disabled' : 'active'
|
||||||
|
const updated = await devopsApi.updateMemberStatus(member.id, {
|
||||||
|
status: nextStatus,
|
||||||
|
})
|
||||||
|
upsertMember(updated)
|
||||||
|
ElMessage.success(nextStatus === 'active' ? '成员已启用' : '成员已禁用')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMember(member: MemberSummary) {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认删除成员 ${member.displayName}?`,
|
||||||
|
'删除成员',
|
||||||
|
{
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await devopsApi.deleteMember(member.id)
|
||||||
|
members.value = members.value.filter((item) => item.id !== member.id)
|
||||||
|
ElMessage.success('成员已删除')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markMessageHandled(message: PlatformMessageSummary) {
|
||||||
|
await devopsApi.markMessageHandled(message.id)
|
||||||
|
await loadMessages()
|
||||||
|
await loadMembers()
|
||||||
|
ElMessage.success('消息已处理')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMembers() {
|
||||||
|
members.value = await devopsApi.getMembers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages() {
|
||||||
|
messages.value = await devopsApi.getMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertMember(member: MemberSummary) {
|
||||||
|
members.value = [
|
||||||
|
member,
|
||||||
|
...members.value.filter((item) => item.id !== member.id),
|
||||||
|
].sort((left, right) => roleOrder(left) - roleOrder(right))
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleOrder(member: MemberSummary) {
|
||||||
|
return member.role === 'super_admin' ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPermissionMap(target: PermissionMap, member?: MemberSummary) {
|
||||||
|
for (const key of Object.keys(target)) {
|
||||||
|
delete target[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionByProject = new Map(
|
||||||
|
member?.projectPermissions.map((permission) => [
|
||||||
|
permission.projectKey,
|
||||||
|
permission.level,
|
||||||
|
]) ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const project of platform.projects) {
|
||||||
|
target[project.key] = permissionByProject.get(project.key) ?? 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPermissionPayload(source: PermissionMap) {
|
||||||
|
return Object.entries(source).map(([projectKey, level]) => ({
|
||||||
|
projectKey,
|
||||||
|
level,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function canMutate(member: MemberSummary) {
|
||||||
|
return member.role !== 'super_admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string) {
|
||||||
|
if (!value) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-shell" v-loading="loading">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>成员权限</h1>
|
||||||
|
<p>维护平台账号、项目只读/构建权限和密码重置消息。</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<ElInput
|
||||||
|
v-model="accountKeyword"
|
||||||
|
class="member-search"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索账号或名称"
|
||||||
|
/>
|
||||||
|
<ElButton :icon="Refresh" @click="loadData">刷新</ElButton>
|
||||||
|
<ElButton type="primary" :icon="Plus" @click="openCreateDialog">
|
||||||
|
新增成员
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>密码消息</h2>
|
||||||
|
<span>{{ pendingMessages.length }} 条待处理</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div v-if="messages.length" class="message-list">
|
||||||
|
<article
|
||||||
|
v-for="message in messages"
|
||||||
|
:key="message.id"
|
||||||
|
class="message-row"
|
||||||
|
:class="{ handled: message.status === 'handled' }"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>{{ message.title }}</strong>
|
||||||
|
<p>{{ message.content }}</p>
|
||||||
|
<small>
|
||||||
|
{{ message.requesterAccount }} · {{ formatDateTime(message.createdAt) }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<ElButton
|
||||||
|
v-if="message.status === 'pending'"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:icon="Check"
|
||||||
|
@click="markMessageHandled(message)"
|
||||||
|
>
|
||||||
|
标记处理
|
||||||
|
</ElButton>
|
||||||
|
<ElTag v-else type="success" round>已处理</ElTag>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<ElEmpty v-else description="暂无平台消息" :image-size="72" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>成员列表</h2>
|
||||||
|
<span>{{ filteredMembers.length }} / {{ members.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<ElTable :data="filteredMembers" row-key="id" border>
|
||||||
|
<ElTableColumn label="成员" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="member-cell">
|
||||||
|
<ElIcon><User /></ElIcon>
|
||||||
|
<span>
|
||||||
|
<strong>{{ row.displayName }}</strong>
|
||||||
|
<small class="mono">{{ row.account }}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="角色" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="row.role === 'super_admin' ? 'danger' : 'info'" round>
|
||||||
|
{{ row.roleName }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="row.status === 'active' ? 'success' : 'warning'" round>
|
||||||
|
{{ row.statusName }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="权限" min-width="340">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="permission-tags">
|
||||||
|
<ElTag
|
||||||
|
v-for="permission in row.projectPermissions"
|
||||||
|
:key="permission.projectKey"
|
||||||
|
:type="permission.level === 'build' ? 'success' : permission.level === 'read' ? 'info' : 'warning'"
|
||||||
|
effect="plain"
|
||||||
|
>
|
||||||
|
{{ permission.projectName }}:{{ permission.levelName }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="安全状态" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="security-tags">
|
||||||
|
<ElTag v-if="row.mustChangePassword" type="warning" round>
|
||||||
|
待改密
|
||||||
|
</ElTag>
|
||||||
|
<ElTag v-if="row.hasPendingPasswordResetRequest" type="danger" round>
|
||||||
|
请求重置
|
||||||
|
</ElTag>
|
||||||
|
<span v-if="!row.mustChangePassword && !row.hasPendingPasswordResetRequest" class="muted">
|
||||||
|
正常
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="最后登录" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.lastLoginAt) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作" width="330" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="row-actions">
|
||||||
|
<ElButton size="small" :disabled="!canMutate(row)" @click="openPermissionDialog(row)">
|
||||||
|
权限
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
size="small"
|
||||||
|
:icon="Lock"
|
||||||
|
:disabled="!canMutate(row)"
|
||||||
|
@click="resetPassword(row)"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
size="small"
|
||||||
|
:disabled="!canMutate(row)"
|
||||||
|
@click="toggleStatus(row)"
|
||||||
|
>
|
||||||
|
{{ row.status === 'active' ? '禁用' : '启用' }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:icon="Delete"
|
||||||
|
:disabled="!canMutate(row)"
|
||||||
|
@click="deleteMember(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ElDialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="editingMember ? '调整项目权限' : '新增成员'"
|
||||||
|
width="680px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<ElForm label-position="top">
|
||||||
|
<template v-if="!editingMember">
|
||||||
|
<ElFormItem label="账号">
|
||||||
|
<ElInput v-model="createForm.account" autocomplete="off" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="名称">
|
||||||
|
<ElInput v-model="createForm.displayName" autocomplete="off" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
<div class="permission-editor">
|
||||||
|
<div
|
||||||
|
v-for="project in platform.projects"
|
||||||
|
:key="project.key"
|
||||||
|
class="permission-editor-row"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>{{ project.name }}</strong>
|
||||||
|
<small class="mono">{{ project.key }}</small>
|
||||||
|
</span>
|
||||||
|
<ElSelect
|
||||||
|
v-if="editingMember"
|
||||||
|
v-model="editPermissions[project.key]"
|
||||||
|
class="permission-select"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="option in permissionOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
<ElSelect
|
||||||
|
v-else
|
||||||
|
v-model="createForm.permissions[project.key]"
|
||||||
|
class="permission-select"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="option in permissionOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElForm>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||||
|
<ElButton type="primary" @click="submitDialog">
|
||||||
|
{{ editingMember ? '保存权限' : '创建成员' }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.member-search {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #fde6bf;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffaf1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row.handled {
|
||||||
|
border-color: #e4ebf5;
|
||||||
|
background: #fbfcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row p {
|
||||||
|
margin: 6px 0;
|
||||||
|
color: #4b5565;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row small {
|
||||||
|
color: #7b8798;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-cell strong,
|
||||||
|
.member-cell small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-cell strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-cell small {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #7b8798;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-tags,
|
||||||
|
.security-tags,
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-editor-row strong,
|
||||||
|
.permission-editor-row small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-editor-row small {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #7b8798;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-select {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.member-search,
|
||||||
|
.permission-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row,
|
||||||
|
.permission-editor-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+168
-1
@@ -2,17 +2,29 @@
|
|||||||
/**
|
/**
|
||||||
* 系统配置页展示集成变量说明、配置边界和审计日志脱敏 JSON。
|
* 系统配置页展示集成变量说明、配置边界和审计日志脱敏 JSON。
|
||||||
*/
|
*/
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
import { Connection, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import AuditJsonCell from '../components/AuditJsonCell.vue'
|
import AuditJsonCell from '../components/AuditJsonCell.vue'
|
||||||
import StatusPill from '../components/StatusPill.vue'
|
import StatusPill from '../components/StatusPill.vue'
|
||||||
|
import { devopsApi } from '../services/devopsApi'
|
||||||
import { usePlatformStore } from '../stores/platform'
|
import { usePlatformStore } from '../stores/platform'
|
||||||
import type {
|
import type {
|
||||||
|
AgentConfigSummary,
|
||||||
AuditLogEntry,
|
AuditLogEntry,
|
||||||
IntegrationConfigItem,
|
IntegrationConfigItem,
|
||||||
IntegrationConfigState,
|
IntegrationConfigState,
|
||||||
} from '../types/devops'
|
} from '../types/devops'
|
||||||
|
|
||||||
const platform = usePlatformStore()
|
const platform = usePlatformStore()
|
||||||
|
const agentConfigLoading = ref(false)
|
||||||
|
const agentConfigTesting = ref(false)
|
||||||
|
const agentConfig = ref<AgentConfigSummary | null>(null)
|
||||||
|
const agentConfigForm = reactive({
|
||||||
|
key: '',
|
||||||
|
baseURL: '',
|
||||||
|
model: '',
|
||||||
|
})
|
||||||
type VariableTagType = 'success' | 'warning' | 'danger' | 'info'
|
type VariableTagType = 'success' | 'warning' | 'danger' | 'info'
|
||||||
type VariableGroup = {
|
type VariableGroup = {
|
||||||
key: string
|
key: string
|
||||||
@@ -31,6 +43,10 @@ const configStatusMeta: Record<
|
|||||||
missing: { label: '未配置', type: 'danger' },
|
missing: { label: '未配置', type: 'danger' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadAgentConfig()
|
||||||
|
})
|
||||||
|
|
||||||
function configStatusLabel(status: IntegrationConfigState): string {
|
function configStatusLabel(status: IntegrationConfigState): string {
|
||||||
return configStatusMeta[status]?.label ?? '未配置'
|
return configStatusMeta[status]?.label ?? '未配置'
|
||||||
}
|
}
|
||||||
@@ -150,6 +166,56 @@ function variableDescription(
|
|||||||
function variableSourceLabel(item: IntegrationConfigItem, name: string): string {
|
function variableSourceLabel(item: IntegrationConfigItem, name: string): string {
|
||||||
return variableHelp(item, name)?.description ? '后端返回' : '等待后端说明'
|
return variableHelp(item, name)?.description ? '后端返回' : '等待后端说明'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAgentConfig() {
|
||||||
|
agentConfigLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await devopsApi.getAgentConfig()
|
||||||
|
applyAgentConfig(config)
|
||||||
|
} finally {
|
||||||
|
agentConfigLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAgentConfig() {
|
||||||
|
agentConfigLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await devopsApi.saveAgentConfig({
|
||||||
|
key: agentConfigForm.key.trim() || undefined,
|
||||||
|
baseURL: agentConfigForm.baseURL.trim(),
|
||||||
|
model: agentConfigForm.model.trim(),
|
||||||
|
})
|
||||||
|
applyAgentConfig(config)
|
||||||
|
agentConfigForm.key = ''
|
||||||
|
ElMessage.success('Agent 配置已保存')
|
||||||
|
await platform.loadAll()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : 'Agent 配置保存失败')
|
||||||
|
} finally {
|
||||||
|
agentConfigLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testAgentConfig() {
|
||||||
|
agentConfigTesting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await devopsApi.testAgentConfig()
|
||||||
|
ElMessage.success(result.message)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : 'Agent 连接测试失败')
|
||||||
|
} finally {
|
||||||
|
agentConfigTesting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAgentConfig(config: AgentConfigSummary) {
|
||||||
|
agentConfig.value = config
|
||||||
|
agentConfigForm.baseURL = config.baseURL
|
||||||
|
agentConfigForm.model = config.model
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -253,6 +319,73 @@ function variableSourceLabel(item: IntegrationConfigItem, name: string): string
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Agent 模型配置</h2>
|
||||||
|
<span>{{ agentConfig?.hasKey ? '已保存密钥' : '未保存密钥' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body agent-config-grid" v-loading="agentConfigLoading">
|
||||||
|
<div class="agent-config-form">
|
||||||
|
<ElForm label-position="top">
|
||||||
|
<ElFormItem label="Base URL">
|
||||||
|
<ElInput
|
||||||
|
v-model="agentConfigForm.baseURL"
|
||||||
|
placeholder="https://api.example.com/v1"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="模型">
|
||||||
|
<ElInput
|
||||||
|
v-model="agentConfigForm.model"
|
||||||
|
placeholder="gpt-4.1"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="API Key">
|
||||||
|
<ElInput
|
||||||
|
v-model="agentConfigForm.key"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="留空保留已保存密钥"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<div class="toolbar">
|
||||||
|
<ElButton type="primary" :loading="agentConfigLoading" @click="saveAgentConfig">
|
||||||
|
保存配置
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
:icon="Connection"
|
||||||
|
:loading="agentConfigTesting"
|
||||||
|
@click="testAgentConfig"
|
||||||
|
>
|
||||||
|
测试连接
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
<div class="agent-config-summary">
|
||||||
|
<div class="secret-boundary">
|
||||||
|
{{ agentConfig?.warning || '模型密钥仅保存在服务端,前端只提交到后端配置接口。' }}
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<dt>密钥状态</dt>
|
||||||
|
<dd>
|
||||||
|
<ElTag :type="agentConfig?.hasKey ? 'success' : 'warning'" round>
|
||||||
|
{{ agentConfig?.hasKey ? agentConfig.maskedKey : '未配置' }}
|
||||||
|
</ElTag>
|
||||||
|
</dd>
|
||||||
|
<dt>Base URL</dt>
|
||||||
|
<dd class="mono">{{ agentConfig?.baseURL || '-' }}</dd>
|
||||||
|
<dt>模型</dt>
|
||||||
|
<dd class="mono">{{ agentConfig?.model || '-' }}</dd>
|
||||||
|
<dt>更新时间</dt>
|
||||||
|
<dd>{{ formatCheckedAt(agentConfig?.updatedAt || '') }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>最近运维审计</h2>
|
<h2>最近运维审计</h2>
|
||||||
@@ -347,6 +480,36 @@ function variableSourceLabel(item: IntegrationConfigItem, name: string): string
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.75fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-config-form {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-config-summary dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 82px minmax(0, 1fr);
|
||||||
|
gap: 10px 12px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-config-summary dt {
|
||||||
|
color: #7b8798;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-config-summary dd {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
color: #172033;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.config-list {
|
.config-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -450,6 +613,10 @@ function variableSourceLabel(item: IntegrationConfigItem, name: string): string
|
|||||||
.variable-grid {
|
.variable-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-config-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user