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. 在企业微信群中添加群机器人。 1. 在企业微信群中添加群机器人。
2. 复制机器人 webhook,保存到服务端环境变量 `WECOM_WEBHOOK_URL` 2. 复制机器人 webhook,保存到服务端环境变量 `WECOM_WEBHOOK_URL`
+35 -4
View File
@@ -44,13 +44,44 @@ const previewText = computed(() => {
return formattedJson.value.replace(/\s+/g, ' ') return formattedJson.value.replace(/\s+/g, ' ')
}) })
const canToggle = computed( const hasContent = computed(() => formattedJson.value !== '-')
() => formattedJson.value.length > 80 || formattedJson.value.includes('\n'),
)
async function copyJson() { async function copyJson() {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(formattedJson.value) await navigator.clipboard.writeText(formattedJson.value)
} else {
fallbackCopy(formattedJson.value)
}
ElMessage.success('已复制 JSON') 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> </script>
@@ -61,7 +92,7 @@ async function copyJson() {
:icon="expanded ? ArrowDown : ArrowRight" :icon="expanded ? ArrowDown : ArrowRight"
size="small" size="small"
text text
:disabled="!canToggle" :disabled="!hasContent"
@click="expanded = !expanded" @click="expanded = !expanded"
> >
{{ expanded ? '收起' : '展开' }} {{ expanded ? '收起' : '展开' }}
+5 -5
View File
@@ -50,9 +50,9 @@ onMounted(() => {
} }
}) })
function logout() { async function logout() {
session.logout() await session.logout()
void router.push('/login') await router.push('/login')
} }
</script> </script>
@@ -98,12 +98,12 @@ function logout() {
</ElTag> </ElTag>
<ElDropdown> <ElDropdown>
<ElButton> <ElButton>
{{ session.operator }} {{ session.displayName || session.operator }}
<ElIcon class="el-icon--right"><Tools /></ElIcon> <ElIcon class="el-icon--right"><Tools /></ElIcon>
</ElButton> </ElButton>
<template #dropdown> <template #dropdown>
<ElDropdownMenu> <ElDropdownMenu>
<ElDropdownItem disabled>角色{{ session.role }}</ElDropdownItem> <ElDropdownItem disabled>角色{{ session.roleLabel }}</ElDropdownItem>
<ElDropdownItem :icon="SwitchButton" @click="logout">退出登录</ElDropdownItem> <ElDropdownItem :icon="SwitchButton" @click="logout">退出登录</ElDropdownItem>
</ElDropdownMenu> </ElDropdownMenu>
</template> </template>
+4 -3
View File
@@ -60,10 +60,11 @@ const router = createRouter({
routes, routes,
}) })
router.beforeEach((to) => { router.beforeEach(async (to) => {
const session = useSessionStore() const session = useSessionStore()
const authenticated = await session.bootstrap()
if (!to.meta.public && !session.isAuthenticated) { if (!to.meta.public && (!authenticated || session.mustChangePassword)) {
return { return {
name: 'login', name: 'login',
query: { query: {
@@ -72,7 +73,7 @@ router.beforeEach((to) => {
} }
} }
if (to.name === 'login' && session.isAuthenticated) { if (to.name === 'login' && authenticated && !session.mustChangePassword) {
return { return {
name: 'overview', name: 'overview',
} }
+27
View File
@@ -4,7 +4,9 @@
import type { import type {
AgentCard, AgentCard,
AgentInvocation, AgentInvocation,
AuthenticatedUser,
AuditLogEntry, AuditLogEntry,
ChangePasswordPayload,
CreateAgentInvocationPayload, CreateAgentInvocationPayload,
CreateDeployRunPayload, CreateDeployRunPayload,
DashboardSummary, DashboardSummary,
@@ -16,6 +18,8 @@ import type {
IntegrationVariableHelp, IntegrationVariableHelp,
IntegrationStatus, IntegrationStatus,
JenkinsSyncSummary, JenkinsSyncSummary,
LoginPayload,
LoginResult,
PipelineStep, PipelineStep,
ProjectConfig, ProjectConfig,
ProjectRefs, ProjectRefs,
@@ -216,6 +220,29 @@ export const devopsApi = {
return lastDataSource 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> { async getDashboard(): Promise<DashboardSummary> {
const [health, backendProjects, backendRuns, integrationConfig] = await Promise.all([ const [health, backendProjects, backendRuns, integrationConfig] = await Promise.all([
fetchHealth(), fetchHealth(),
+12
View File
@@ -9,6 +9,18 @@ export const http = axios.create({
withCredentials: true, 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( http.interceptors.response.use(
(response) => { (response) => {
response.data = unwrapApiPayload(response.data) response.data = unwrapApiPayload(response.data)
+3
View File
@@ -1,3 +1,6 @@
/**
* 平台 Pinia store 聚合后端项目、发布、设置和审计数据。
*/
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { devopsApi } from '../services/devopsApi' import { devopsApi } from '../services/devopsApi'
+151 -8
View File
@@ -1,28 +1,171 @@
/**
* 维护后端真实登录态,前端只保存 DevOps API token,不保存任何第三方密钥。
*/
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { devopsApi } from '../services/devopsApi'
import { setAuthToken } from '../services/http'
import type {
AuthenticatedUser,
ChangePasswordPayload,
LoginPayload,
} from '../types/devops'
const STORAGE_KEY = 'devops-platform-session'
type StoredSession = {
token: string
expiresAt: string
user: AuthenticatedUser
}
export const useSessionStore = defineStore('session', () => { export const useSessionStore = defineStore('session', () => {
const operator = ref('ops-admin') const restored = loadStoredSession()
const role = ref<'admin' | 'operator' | 'viewer'>('admin') const operator = ref(restored?.user.account ?? '')
const token = ref('local-session') 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)
const isAuthenticated = computed(() => Boolean(token.value)) setAuthToken(token.value)
function login(name: string) { const isAuthenticated = computed(() => Boolean(token.value && currentUser.value))
operator.value = name || 'ops-admin' const mustChangePassword = computed(() =>
token.value = 'local-session' Boolean(currentUser.value?.mustChangePassword),
)
const roleLabel = computed(() => {
if (role.value === 'super_admin') {
return '超级管理员'
} }
function logout() { 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 = '' token.value = ''
expiresAt.value = ''
currentUser.value = null
setAuthToken()
localStorage.removeItem(STORAGE_KEY)
} }
return { return {
operator, operator,
displayName,
role, role,
roleLabel,
token, token,
expiresAt,
currentUser,
isAuthenticated, isAuthenticated,
mustChangePassword,
bootstrap,
login, login,
changePassword,
logout, 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' | 'release-note'
| 'incident-review' | '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 = export type IntegrationConfigKey =
| 'jenkins' | 'jenkins'
| 'gitea' | 'gitea'
@@ -221,7 +251,7 @@ export interface CreateDeployRunPayload {
projectKey: string projectKey: string
environment: EnvironmentName environment: EnvironmentName
ref: string ref: string
operator: string operator?: string
remark?: string remark?: string
idempotencyKey?: string idempotencyKey?: string
} }
+157 -13
View File
@@ -1,22 +1,94 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* 登录页对接后端真实鉴权,并处理普通成员首次登录强制改密。
*/
import { Lock, Monitor } from '@element-plus/icons-vue' 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 { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '../stores/session' import { useSessionStore } from '../stores/session'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const session = useSessionStore() const session = useSessionStore()
const loading = ref(false)
const form = reactive({ const loginForm = reactive({
operator: 'ops-admin', account: '',
password: '', 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() { function submit() {
session.login(form.operator) if (session.mustChangePassword) {
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/overview' void submitPasswordChange()
void router.push(redirect) return
}
void submitLogin()
}
function accountLabel() {
return session.displayName || session.operator
} }
</script> </script>
@@ -26,24 +98,78 @@ function submit() {
<div class="login-copy"> <div class="login-copy">
<ElIcon><Monitor /></ElIcon> <ElIcon><Monitor /></ElIcon>
<h1>DevOps 运维平台</h1> <h1>DevOps 运维平台</h1>
<p>当前使用本地操作员会话进入控制台真实身份RBAC 和审计由后端认证模块接入</p> <p>使用平台账号进入控制台</p>
</div> </div>
<ElForm class="login-form" label-position="top" @submit.prevent="submit"> <ElForm class="login-form" label-position="top" @submit.prevent="submit">
<ElFormItem label="操作员"> <template v-if="!session.mustChangePassword">
<ElInput v-model="form.operator" size="large" autocomplete="username" /> <ElFormItem label="账号">
<ElInput v-model="loginForm.account" size="large" autocomplete="username" />
</ElFormItem> </ElFormItem>
<ElFormItem label="口令"> <ElFormItem label="密码">
<ElInput <ElInput
v-model="form.password" v-model="loginForm.password"
size="large" size="large"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
show-password show-password
/> />
</ElFormItem> </ElFormItem>
<ElButton type="primary" size="large" :icon="Lock" native-type="submit"> <ElButton
进入控制台 type="primary"
size="large"
:icon="Lock"
:loading="loading"
native-type="submit"
>
登录
</ElButton> </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> </ElForm>
</section> </section>
</main> </main>
@@ -103,6 +229,24 @@ function submit() {
margin-top: 8px; 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) { @media (max-width: 780px) {
.login-panel { .login-panel {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+3
View File
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* 发布中心负责创建发布单、同步 Jenkins 状态并展示当前运行流程。
*/
import { CircleClose, Promotion, RefreshRight, VideoPause } from '@element-plus/icons-vue' import { CircleClose, Promotion, RefreshRight, VideoPause } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
+3
View File
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* 运行记录页展示历史发布单、Jenkins 同步入口和后端步骤流转图。
*/
import { RefreshRight } from '@element-plus/icons-vue' import { RefreshRight } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
+22 -1
View File
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* 系统配置页展示集成变量说明、配置边界和审计日志脱敏 JSON。
*/
import { Refresh } from '@element-plus/icons-vue' import { Refresh } from '@element-plus/icons-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'
@@ -68,8 +71,26 @@ function auditResource(record: AuditLogEntry): string {
: record.resourceType : 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 { 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[] { function variableGroups(item: IntegrationConfigItem): VariableGroup[] {