diff --git a/docs/notification-platform-config.md b/docs/notification-platform-config.md index 53681b3..0a2635b 100644 --- a/docs/notification-platform-config.md +++ b/docs/notification-platform-config.md @@ -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`。 diff --git a/src/components/AuditJsonCell.vue b/src/components/AuditJsonCell.vue index 819c7d8..c677f56 100644 --- a/src/components/AuditJsonCell.vue +++ b/src/components/AuditJsonCell.vue @@ -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() { - await navigator.clipboard.writeText(formattedJson.value) - ElMessage.success('已复制 JSON') + 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) + } } @@ -61,7 +92,7 @@ async function copyJson() { :icon="expanded ? ArrowDown : ArrowRight" size="small" text - :disabled="!canToggle" + :disabled="!hasContent" @click="expanded = !expanded" > {{ expanded ? '收起' : '展开' }} diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 125660a..7a8130b 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -50,9 +50,9 @@ onMounted(() => { } }) -function logout() { - session.logout() - void router.push('/login') +async function logout() { + await session.logout() + await router.push('/login') } @@ -98,12 +98,12 @@ function logout() { - {{ session.operator }} + {{ session.displayName || session.operator }} diff --git a/src/router/index.ts b/src/router/index.ts index 7ca68fe..91d6bfb 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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', } diff --git a/src/services/devopsApi.ts b/src/services/devopsApi.ts index 9594728..6c38b23 100644 --- a/src/services/devopsApi.ts +++ b/src/services/devopsApi.ts @@ -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 { + const { data } = await http.post('/auth/login', payload) + lastDataSource = 'backend' + return data + }, + + async getCurrentUser(): Promise { + const { data } = await http.get('/auth/me') + lastDataSource = 'backend' + return data + }, + + async changePassword(payload: ChangePasswordPayload): Promise { + const { data } = await http.post('/auth/change-password', payload) + lastDataSource = 'backend' + return data + }, + + async logout(): Promise { + await http.post('/auth/logout') + lastDataSource = 'backend' + }, + async getDashboard(): Promise { const [health, backendProjects, backendRuns, integrationConfig] = await Promise.all([ fetchHealth(), diff --git a/src/services/http.ts b/src/services/http.ts index 40ac822..1f661b0 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -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) diff --git a/src/stores/platform.ts b/src/stores/platform.ts index 7a64479..1a93700 100644 --- a/src/stores/platform.ts +++ b/src/stores/platform.ts @@ -1,3 +1,6 @@ +/** + * 平台 Pinia store 聚合后端项目、发布、设置和审计数据。 + */ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import { devopsApi } from '../services/devopsApi' diff --git a/src/stores/session.ts b/src/stores/session.ts index 3119168..81a98b4 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -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' + +const STORAGE_KEY = 'devops-platform-session' + +type StoredSession = { + token: string + expiresAt: string + user: AuthenticatedUser +} export const useSessionStore = defineStore('session', () => { - const operator = ref('ops-admin') - const role = ref<'admin' | 'operator' | 'viewer'>('admin') - const token = ref('local-session') + const restored = loadStoredSession() + const operator = ref(restored?.user.account ?? '') + const displayName = ref(restored?.user.displayName ?? '') + const role = ref(restored?.user.role ?? '') + const token = ref(restored?.token ?? '') + const expiresAt = ref(restored?.expiresAt ?? '') + const currentUser = ref(restored?.user ?? null) + const bootstrapped = ref(false) - const isAuthenticated = computed(() => Boolean(token.value)) + setAuthToken(token.value) - function login(name: string) { - operator.value = name || 'ops-admin' - token.value = 'local-session' + 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 { + 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 + } } - function logout() { + 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 + } +} diff --git a/src/types/devops.ts b/src/types/devops.ts index 227edfd..28afbfe 100644 --- a/src/types/devops.ts +++ b/src/types/devops.ts @@ -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 } diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index 7b2917a..015d139 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -1,22 +1,94 @@ @@ -26,24 +98,78 @@ function submit() { @@ -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; diff --git a/src/views/ReleaseCenterView.vue b/src/views/ReleaseCenterView.vue index 12b61e8..7b88aec 100644 --- a/src/views/ReleaseCenterView.vue +++ b/src/views/ReleaseCenterView.vue @@ -1,4 +1,7 @@