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:
湛兮
2026-06-12 00:59:26 +08:00
parent 366f1d667e
commit 8ffbdb4d45
9 changed files with 992 additions and 252 deletions
+3 -2
View File
@@ -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
浏览器端不得保存或直连以下密钥和服务:
+5 -3
View File
@@ -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。
+9 -3
View File
@@ -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'
+15
View File
@@ -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',
+90
View File
@@ -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',
+87
View File
@@ -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
}
-243
View File
@@ -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>
+615
View File
@@ -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
View File
@@ -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) {