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 数据降级。
|
||||
- 成员权限页调用后端 `/members` 和 `/messages`,维护普通成员账号、项目权限、密码重置和平台消息。
|
||||
- 发布中心包含项目、环境、ref/tag、备注、生产二次确认、触发、取消、重试和 Jenkins 状态同步入口。
|
||||
- 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要。
|
||||
- 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 为准继续收敛字段。
|
||||
|
||||
通知平台配置方案见 [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`:总览,展示服务状态、最近发布和项目环境。
|
||||
- `/projects`:项目配置,展示 Gitea 仓库、Jenkins Job、通知通道和环境策略。
|
||||
- `/members`:成员权限,仅超级管理员可见,维护普通成员、项目权限和密码重置消息。
|
||||
- `/releases`:发布中心,提供发布参数、二次确认、触发、取消、重试和 BPMN 流程状态。
|
||||
- `/runs`:运行记录,展示 run 列表和流程轨迹。
|
||||
- 顶部 Agent 按钮:打开全局右侧抽屉,只保留 DevOps 场景能力;`/agent` 仅重定向到总览,不作为独立侧边栏入口。
|
||||
- `/settings`:系统配置,展示 API、BPMN、Agent、通知边界。
|
||||
- `/settings`:系统配置,展示 API、BPMN、Agent、通知边界,并维护后端 Agent 模型配置。
|
||||
|
||||
## 数据约定
|
||||
|
||||
@@ -32,8 +33,9 @@
|
||||
|
||||
## 后续接入点
|
||||
|
||||
- `devopsApi` 所有项目、发布、refs、流程和 Agent 调用均走后端接口。
|
||||
- `devopsApi` 所有项目、发布、refs、流程、成员、消息和 Agent 调用均走后端接口。
|
||||
- 发布中心触发按钮接入后端发布单创建接口。
|
||||
- 运行记录接入后端 run/step 查询接口。
|
||||
- BPMN XML 由后端流程定义接口返回。
|
||||
- Agent 面板调用后端 Agent proxy,前端只传 DevOps 场景参数。
|
||||
- Agent 配置页调用后端 `/agent/config`,前端不持久化 key、baseURL 或 model。
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SwitchButton,
|
||||
Tickets,
|
||||
Tools,
|
||||
User,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -28,13 +29,18 @@ const agentOpen = ref(false)
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
const menus = [
|
||||
const menus = computed(() => [
|
||||
{ path: '/overview', label: '总览', icon: DataAnalysis },
|
||||
{ path: '/projects', label: '项目配置', icon: Operation },
|
||||
...(session.role === 'super_admin'
|
||||
? [{ path: '/members', label: '成员权限', icon: User }]
|
||||
: []),
|
||||
{ path: '/releases', label: '发布中心', icon: DocumentChecked },
|
||||
{ path: '/runs', label: '运行记录', icon: Tickets },
|
||||
{ path: '/settings', label: '系统配置', icon: Setting },
|
||||
]
|
||||
...(session.role === 'super_admin'
|
||||
? [{ path: '/settings', label: '系统配置', icon: Setting }]
|
||||
: []),
|
||||
])
|
||||
|
||||
const jenkinsStatus = computed(() => {
|
||||
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 OverviewView from '../views/OverviewView.vue'
|
||||
import ProjectsView from '../views/ProjectsView.vue'
|
||||
import MembersView from '../views/MembersView.vue'
|
||||
import ReleaseCenterView from '../views/ReleaseCenterView.vue'
|
||||
import RunsView from '../views/RunsView.vue'
|
||||
import SettingsView from '../views/SettingsView.vue'
|
||||
@@ -32,6 +33,14 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'projects',
|
||||
component: ProjectsView,
|
||||
},
|
||||
{
|
||||
path: 'members',
|
||||
name: 'members',
|
||||
component: MembersView,
|
||||
meta: {
|
||||
superAdminOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '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) {
|
||||
return {
|
||||
name: 'overview',
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
*/
|
||||
import type {
|
||||
AgentCard,
|
||||
AgentConfigSummary,
|
||||
AgentConnectionTestResult,
|
||||
AgentInvocation,
|
||||
AuthenticatedUser,
|
||||
AuditLogEntry,
|
||||
ChangePasswordPayload,
|
||||
CreateAgentInvocationPayload,
|
||||
CreateDeployRunPayload,
|
||||
CreateMemberPayload,
|
||||
DashboardSummary,
|
||||
EnvironmentName,
|
||||
HealthState,
|
||||
@@ -20,13 +23,18 @@ import type {
|
||||
JenkinsSyncSummary,
|
||||
LoginPayload,
|
||||
LoginResult,
|
||||
MemberSummary,
|
||||
PipelineStep,
|
||||
PlatformMessageSummary,
|
||||
ProjectConfig,
|
||||
ProjectRefs,
|
||||
ReleaseRun,
|
||||
ReleaseStatus,
|
||||
SaveAgentConfigPayload,
|
||||
StepStatus,
|
||||
SystemSetting,
|
||||
UpdateMemberPermissionsPayload,
|
||||
UpdateMemberStatusPayload,
|
||||
} from '../types/devops'
|
||||
import { http } from './http'
|
||||
|
||||
@@ -243,6 +251,66 @@ export const devopsApi = {
|
||||
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> {
|
||||
const [health, backendProjects, backendRuns, integrationConfig] = await Promise.all([
|
||||
fetchHealth(),
|
||||
@@ -343,6 +411,28 @@ export const devopsApi = {
|
||||
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> {
|
||||
const { data } = await http.get<BackendProcessDefinition>(
|
||||
'/process-definitions/release',
|
||||
|
||||
@@ -35,6 +35,12 @@ export type AuthRole = 'super_admin' | 'member'
|
||||
|
||||
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 {
|
||||
id: string
|
||||
account: string
|
||||
@@ -61,6 +67,66 @@ export interface ChangePasswordPayload {
|
||||
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 =
|
||||
| 'jenkins'
|
||||
| 'gitea'
|
||||
@@ -273,3 +339,24 @@ export interface CreateAgentInvocationPayload {
|
||||
projectKey?: 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。
|
||||
*/
|
||||
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 StatusPill from '../components/StatusPill.vue'
|
||||
import { devopsApi } from '../services/devopsApi'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
import type {
|
||||
AgentConfigSummary,
|
||||
AuditLogEntry,
|
||||
IntegrationConfigItem,
|
||||
IntegrationConfigState,
|
||||
} from '../types/devops'
|
||||
|
||||
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 VariableGroup = {
|
||||
key: string
|
||||
@@ -31,6 +43,10 @@ const configStatusMeta: Record<
|
||||
missing: { label: '未配置', type: 'danger' },
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadAgentConfig()
|
||||
})
|
||||
|
||||
function configStatusLabel(status: IntegrationConfigState): string {
|
||||
return configStatusMeta[status]?.label ?? '未配置'
|
||||
}
|
||||
@@ -150,6 +166,56 @@ function variableDescription(
|
||||
function variableSourceLabel(item: IntegrationConfigItem, name: string): string {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -253,6 +319,73 @@ function variableSourceLabel(item: IntegrationConfigItem, name: string): string
|
||||
</section>
|
||||
</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">
|
||||
<div class="panel-header">
|
||||
<h2>最近运维审计</h2>
|
||||
@@ -347,6 +480,36 @@ function variableSourceLabel(item: IntegrationConfigItem, name: string): string
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -450,6 +613,10 @@ function variableSourceLabel(item: IntegrationConfigItem, name: string): string
|
||||
.variable-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.agent-config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
|
||||
Reference in New Issue
Block a user