feat: 接入真实平台登录态

- login/session: 对接后端登录、鉴权恢复和首次改密
- api/store: 恢复 BPMN XML 真实接口并携带 Bearer token
- layout/settings: 展示真实角色、审计 JSON 和通知平台配置文档
This commit is contained in:
湛兮
2026-06-12 00:38:31 +08:00
parent a37f641c6c
commit 366f1d667e
13 changed files with 467 additions and 46 deletions
+4 -1
View File
@@ -45,7 +45,10 @@ NOTIFICATION_WEBHOOK_URL=https://notification.example.internal/webhook/replace-w
## 企业微信配置
官方参考:`https://developer.work.weixin.qq.com/document/path/91770`
参考文档:
- 企业微信开发者文档:`https://developer.work.weixin.qq.com/document/path/91770`
- 腾讯云消息中心企业微信机器人接收消息:`https://cloud.tencent.com/document/product/1263/71731`
1. 在企业微信群中添加群机器人。
2. 复制机器人 webhook,保存到服务端环境变量 `WECOM_WEBHOOK_URL`
+35 -4
View File
@@ -44,13 +44,44 @@ const previewText = computed(() => {
return formattedJson.value.replace(/\s+/g, ' ')
})
const canToggle = computed(
() => formattedJson.value.length > 80 || formattedJson.value.includes('\n'),
)
const hasContent = computed(() => formattedJson.value !== '-')
async function copyJson() {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(formattedJson.value)
} else {
fallbackCopy(formattedJson.value)
}
ElMessage.success('已复制 JSON')
} catch {
try {
fallbackCopy(formattedJson.value)
ElMessage.success('已复制 JSON')
} catch {
ElMessage.error('复制失败,请手动选择 JSON 内容')
}
}
}
function fallbackCopy(text: string) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'readonly')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
try {
textarea.select()
if (!document.execCommand('copy')) {
throw new Error('copy command rejected')
}
} finally {
document.body.removeChild(textarea)
}
}
</script>
@@ -61,7 +92,7 @@ async function copyJson() {
:icon="expanded ? ArrowDown : ArrowRight"
size="small"
text
:disabled="!canToggle"
:disabled="!hasContent"
@click="expanded = !expanded"
>
{{ expanded ? '收起' : '展开' }}
+5 -5
View File
@@ -50,9 +50,9 @@ onMounted(() => {
}
})
function logout() {
session.logout()
void router.push('/login')
async function logout() {
await session.logout()
await router.push('/login')
}
</script>
@@ -98,12 +98,12 @@ function logout() {
</ElTag>
<ElDropdown>
<ElButton>
{{ session.operator }}
{{ session.displayName || session.operator }}
<ElIcon class="el-icon--right"><Tools /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem disabled>角色{{ session.role }}</ElDropdownItem>
<ElDropdownItem disabled>角色{{ session.roleLabel }}</ElDropdownItem>
<ElDropdownItem :icon="SwitchButton" @click="logout">退出登录</ElDropdownItem>
</ElDropdownMenu>
</template>
+4 -3
View File
@@ -60,10 +60,11 @@ const router = createRouter({
routes,
})
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const session = useSessionStore()
const authenticated = await session.bootstrap()
if (!to.meta.public && !session.isAuthenticated) {
if (!to.meta.public && (!authenticated || session.mustChangePassword)) {
return {
name: 'login',
query: {
@@ -72,7 +73,7 @@ router.beforeEach((to) => {
}
}
if (to.name === 'login' && session.isAuthenticated) {
if (to.name === 'login' && authenticated && !session.mustChangePassword) {
return {
name: 'overview',
}
+27
View File
@@ -4,7 +4,9 @@
import type {
AgentCard,
AgentInvocation,
AuthenticatedUser,
AuditLogEntry,
ChangePasswordPayload,
CreateAgentInvocationPayload,
CreateDeployRunPayload,
DashboardSummary,
@@ -16,6 +18,8 @@ import type {
IntegrationVariableHelp,
IntegrationStatus,
JenkinsSyncSummary,
LoginPayload,
LoginResult,
PipelineStep,
ProjectConfig,
ProjectRefs,
@@ -216,6 +220,29 @@ export const devopsApi = {
return lastDataSource
},
async login(payload: LoginPayload): Promise<LoginResult> {
const { data } = await http.post<LoginResult>('/auth/login', payload)
lastDataSource = 'backend'
return data
},
async getCurrentUser(): Promise<AuthenticatedUser> {
const { data } = await http.get<AuthenticatedUser>('/auth/me')
lastDataSource = 'backend'
return data
},
async changePassword(payload: ChangePasswordPayload): Promise<AuthenticatedUser> {
const { data } = await http.post<AuthenticatedUser>('/auth/change-password', payload)
lastDataSource = 'backend'
return data
},
async logout(): Promise<void> {
await http.post('/auth/logout')
lastDataSource = 'backend'
},
async getDashboard(): Promise<DashboardSummary> {
const [health, backendProjects, backendRuns, integrationConfig] = await Promise.all([
fetchHealth(),
+12
View File
@@ -9,6 +9,18 @@ export const http = axios.create({
withCredentials: true,
})
/**
* 登录令牌只用于调用后端 API,不会传给 Jenkins/Gitea/LLM 等第三方服务。
*/
export function setAuthToken(token?: string) {
if (token) {
http.defaults.headers.common.Authorization = `Bearer ${token}`
return
}
delete http.defaults.headers.common.Authorization
}
http.interceptors.response.use(
(response) => {
response.data = unwrapApiPayload(response.data)
+3
View File
@@ -1,3 +1,6 @@
/**
* 平台 Pinia store 聚合后端项目、发布、设置和审计数据。
*/
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { devopsApi } from '../services/devopsApi'
+153 -10
View File
@@ -1,28 +1,171 @@
/**
* 维护后端真实登录态,前端只保存 DevOps API token,不保存任何第三方密钥。
*/
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { devopsApi } from '../services/devopsApi'
import { setAuthToken } from '../services/http'
import type {
AuthenticatedUser,
ChangePasswordPayload,
LoginPayload,
} from '../types/devops'
export const useSessionStore = defineStore('session', () => {
const operator = ref('ops-admin')
const role = ref<'admin' | 'operator' | 'viewer'>('admin')
const token = ref('local-session')
const STORAGE_KEY = 'devops-platform-session'
const isAuthenticated = computed(() => Boolean(token.value))
function login(name: string) {
operator.value = name || 'ops-admin'
token.value = 'local-session'
type StoredSession = {
token: string
expiresAt: string
user: AuthenticatedUser
}
function logout() {
export const useSessionStore = defineStore('session', () => {
const restored = loadStoredSession()
const operator = ref(restored?.user.account ?? '')
const displayName = ref(restored?.user.displayName ?? '')
const role = ref<AuthenticatedUser['role'] | ''>(restored?.user.role ?? '')
const token = ref(restored?.token ?? '')
const expiresAt = ref(restored?.expiresAt ?? '')
const currentUser = ref<AuthenticatedUser | null>(restored?.user ?? null)
const bootstrapped = ref(false)
setAuthToken(token.value)
const isAuthenticated = computed(() => Boolean(token.value && currentUser.value))
const mustChangePassword = computed(() =>
Boolean(currentUser.value?.mustChangePassword),
)
const roleLabel = computed(() => {
if (role.value === 'super_admin') {
return '超级管理员'
}
if (role.value === 'member') {
return '普通成员'
}
return '未登录'
})
async function bootstrap(): Promise<boolean> {
if (bootstrapped.value) {
return isAuthenticated.value
}
bootstrapped.value = true
if (!token.value) {
return false
}
try {
const user = await devopsApi.getCurrentUser()
applyUser(user)
persist()
return true
} catch {
clearSession()
return false
}
}
async function login(payload: LoginPayload) {
const result = await devopsApi.login(payload)
token.value = result.token
expiresAt.value = result.expiresAt
setAuthToken(result.token)
applyUser(result.user)
persist()
bootstrapped.value = true
return result.user
}
async function changePassword(payload: ChangePasswordPayload) {
const user = await devopsApi.changePassword(payload)
applyUser(user)
persist()
return user
}
async function logout() {
if (token.value) {
try {
await devopsApi.logout()
} catch {
// 退出以清理本地登录态为准,后端当前无状态 token 无需强依赖 logout 成功。
}
}
clearSession()
}
function applyUser(user: AuthenticatedUser) {
currentUser.value = user
operator.value = user.account
displayName.value = user.displayName
role.value = user.role
}
function persist() {
if (!token.value || !currentUser.value) {
localStorage.removeItem(STORAGE_KEY)
return
}
const stored: StoredSession = {
token: token.value,
expiresAt: expiresAt.value,
user: currentUser.value,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
}
function clearSession() {
operator.value = ''
displayName.value = ''
role.value = ''
token.value = ''
expiresAt.value = ''
currentUser.value = null
setAuthToken()
localStorage.removeItem(STORAGE_KEY)
}
return {
operator,
displayName,
role,
roleLabel,
token,
expiresAt,
currentUser,
isAuthenticated,
mustChangePassword,
bootstrap,
login,
changePassword,
logout,
}
})
function loadStoredSession(): StoredSession | null {
try {
const rawValue = localStorage.getItem(STORAGE_KEY)
if (!rawValue) {
return null
}
const parsed = JSON.parse(rawValue) as StoredSession
if (!parsed.token || !parsed.user?.account) {
return null
}
return parsed
} catch {
localStorage.removeItem(STORAGE_KEY)
return null
}
}
+31 -1
View File
@@ -31,6 +31,36 @@ export type AgentPurpose =
| 'release-note'
| 'incident-review'
export type AuthRole = 'super_admin' | 'member'
export type AuthStatus = 'active' | 'disabled'
export interface AuthenticatedUser {
id: string
account: string
displayName: string
role: AuthRole
status: AuthStatus
mustChangePassword: boolean
passwordVersion: number
}
export interface LoginPayload {
account: string
password: string
}
export interface LoginResult {
token: string
expiresAt: string
user: AuthenticatedUser
}
export interface ChangePasswordPayload {
currentPassword: string
newPassword: string
}
export type IntegrationConfigKey =
| 'jenkins'
| 'gitea'
@@ -221,7 +251,7 @@ export interface CreateDeployRunPayload {
projectKey: string
environment: EnvironmentName
ref: string
operator: string
operator?: string
remark?: string
idempotencyKey?: string
}
+157 -13
View File
@@ -1,22 +1,94 @@
<script setup lang="ts">
/**
* 登录页对接后端真实鉴权,并处理普通成员首次登录强制改密。
*/
import { Lock, Monitor } from '@element-plus/icons-vue'
import { reactive } from 'vue'
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '../stores/session'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const form = reactive({
operator: 'ops-admin',
const loginForm = reactive({
account: '',
password: '',
})
const passwordForm = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
const redirect = computed(() =>
typeof route.query.redirect === 'string' ? route.query.redirect : '/overview',
)
async function submitLogin() {
loading.value = true
try {
const user = await session.login(loginForm)
passwordForm.currentPassword = loginForm.password
if (user.mustChangePassword) {
ElMessage.warning('首次登录请先修改密码')
return
}
await router.push(redirect.value)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '登录失败')
} finally {
loading.value = false
}
}
async function submitPasswordChange() {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
ElMessage.error('两次输入的新密码不一致')
return
}
loading.value = true
try {
await session.changePassword({
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
})
ElMessage.success('密码已更新')
await router.push(redirect.value)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '修改密码失败')
} finally {
loading.value = false
}
}
async function logoutForAccountSwitch() {
await session.logout()
loginForm.password = ''
passwordForm.currentPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
}
function submit() {
session.login(form.operator)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/overview'
void router.push(redirect)
if (session.mustChangePassword) {
void submitPasswordChange()
return
}
void submitLogin()
}
function accountLabel() {
return session.displayName || session.operator
}
</script>
@@ -26,24 +98,78 @@ function submit() {
<div class="login-copy">
<ElIcon><Monitor /></ElIcon>
<h1>DevOps 运维平台</h1>
<p>当前使用本地操作员会话进入控制台真实身份RBAC 和审计由后端认证模块接入</p>
<p>使用平台账号进入控制台</p>
</div>
<ElForm class="login-form" label-position="top" @submit.prevent="submit">
<ElFormItem label="操作员">
<ElInput v-model="form.operator" size="large" autocomplete="username" />
<template v-if="!session.mustChangePassword">
<ElFormItem label="账号">
<ElInput v-model="loginForm.account" size="large" autocomplete="username" />
</ElFormItem>
<ElFormItem label="口令">
<ElFormItem label="密码">
<ElInput
v-model="form.password"
v-model="loginForm.password"
size="large"
type="password"
autocomplete="current-password"
show-password
/>
</ElFormItem>
<ElButton type="primary" size="large" :icon="Lock" native-type="submit">
进入控制台
<ElButton
type="primary"
size="large"
:icon="Lock"
:loading="loading"
native-type="submit"
>
登录
</ElButton>
</template>
<template v-else>
<div class="change-title">
<strong>{{ accountLabel() }}</strong>
<span>需要更新密码</span>
</div>
<ElFormItem label="当前密码">
<ElInput
v-model="passwordForm.currentPassword"
size="large"
type="password"
autocomplete="current-password"
show-password
/>
</ElFormItem>
<ElFormItem label="新密码">
<ElInput
v-model="passwordForm.newPassword"
size="large"
type="password"
autocomplete="new-password"
show-password
/>
</ElFormItem>
<ElFormItem label="确认新密码">
<ElInput
v-model="passwordForm.confirmPassword"
size="large"
type="password"
autocomplete="new-password"
show-password
/>
</ElFormItem>
<ElButton
type="primary"
size="large"
:icon="Lock"
:loading="loading"
native-type="submit"
>
更新并进入
</ElButton>
<ElButton class="switch-account" text @click="logoutForAccountSwitch">
切换账号
</ElButton>
</template>
</ElForm>
</section>
</main>
@@ -103,6 +229,24 @@ function submit() {
margin-top: 8px;
}
.change-title {
display: grid;
gap: 4px;
margin-bottom: 18px;
color: #111827;
font-size: 14px;
}
.change-title span {
color: #697386;
font-size: 13px;
}
.switch-account {
width: 100%;
margin-top: 10px;
}
@media (max-width: 780px) {
.login-panel {
grid-template-columns: 1fr;
+3
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
/**
* 发布中心负责创建发布单、同步 Jenkins 状态并展示当前运行流程。
*/
import { CircleClose, Promotion, RefreshRight, VideoPause } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
+3
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
/**
* 运行记录页展示历史发布单、Jenkins 同步入口和后端步骤流转图。
*/
import { RefreshRight } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
+22 -1
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
/**
* 系统配置页展示集成变量说明、配置边界和审计日志脱敏 JSON。
*/
import { Refresh } from '@element-plus/icons-vue'
import AuditJsonCell from '../components/AuditJsonCell.vue'
import StatusPill from '../components/StatusPill.vue'
@@ -68,8 +71,26 @@ function auditResource(record: AuditLogEntry): string {
: record.resourceType
}
function hasAuditRecord(value?: Record<string, unknown>): value is Record<string, unknown> {
return Boolean(value && Object.keys(value).length > 0)
}
function auditPayload(record: AuditLogEntry): unknown {
return record.after ?? record.parameterDigest ?? record.before ?? null
const payload: Record<string, unknown> = {}
if (hasAuditRecord(record.parameterDigest)) {
payload.parameterDigest = record.parameterDigest
}
if (hasAuditRecord(record.before)) {
payload.before = record.before
}
if (hasAuditRecord(record.after)) {
payload.after = record.after
}
return Object.keys(payload).length > 0 ? payload : null
}
function variableGroups(item: IntegrationConfigItem): VariableGroup[] {