feat: 完善登录角色切换和本地代理

This commit is contained in:
湛兮
2026-06-12 10:20:10 +08:00
parent da3bbb09e1
commit 0503ec2e0a
6 changed files with 160 additions and 7 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ pnpm install
pnpm dev pnpm dev
``` ```
The local web app listens on `http://localhost:4301` by default. Set `VITE_API_BASE_URL=http://localhost:4300` to point the web app at the local API. 本地前端默认监听 `http://localhost:4301`。如使用 `VITE_API_BASE_URL=http://localhost:4300`,浏览器会直接请求本地 API;如不设置该变量,Vite 会把 `/api` 默认代理到 `http://localhost:4300`,并自动去掉 `/api` 前缀。需要通过 `/api` 代理联调远端 API 时,可临时设置 `DEVOPS_API_PROXY_TARGET=https://devops.mrzhan.top/api`
## 构建检查 ## 构建检查
+12
View File
@@ -14,6 +14,7 @@ import type {
CreateMemberPayload, CreateMemberPayload,
DashboardSummary, DashboardSummary,
EnvironmentName, EnvironmentName,
ForgotPasswordPayload,
HealthState, HealthState,
IntegrationConfigKey, IntegrationConfigKey,
IntegrationConfigState, IntegrationConfigState,
@@ -140,6 +141,8 @@ interface BackendIntegrationConfigItem {
configured?: string[] configured?: string[]
missing?: string[] missing?: string[]
optional?: string[] optional?: string[]
enabled?: string[]
disabled?: string[]
note?: string note?: string
variableHelp?: Record<string, BackendVariableHelpValue> variableHelp?: Record<string, BackendVariableHelpValue>
variableDescriptions?: Record<string, unknown> variableDescriptions?: Record<string, unknown>
@@ -259,6 +262,11 @@ export const devopsApi = {
return data return data
}, },
async forgotPassword(payload: ForgotPasswordPayload): Promise<void> {
await http.post('/auth/forgot-password', payload)
lastDataSource = 'backend'
},
async logout(): Promise<void> { async logout(): Promise<void> {
await http.post('/auth/logout') await http.post('/auth/logout')
lastDataSource = 'backend' lastDataSource = 'backend'
@@ -594,6 +602,8 @@ function sanitizeIntegrationConfigStatus(
const configured = sanitizeVariableNames(item.configured) const configured = sanitizeVariableNames(item.configured)
const missing = sanitizeVariableNames(item.missing) const missing = sanitizeVariableNames(item.missing)
const optional = sanitizeVariableNames(item.optional) const optional = sanitizeVariableNames(item.optional)
const enabled = sanitizeVariableNames(item.enabled)
const disabled = sanitizeVariableNames(item.disabled)
const variableHelp = sanitizeVariableHelp(item) const variableHelp = sanitizeVariableHelp(item)
return { return {
@@ -604,6 +614,8 @@ function sanitizeIntegrationConfigStatus(
configured, configured,
missing, missing,
optional, optional,
enabled,
disabled,
note: sanitizeIntegrationNote(item.note), note: sanitizeIntegrationNote(item.note),
...(Object.keys(variableHelp).length > 0 ? { variableHelp } : {}), ...(Object.keys(variableHelp).length > 0 ? { variableHelp } : {}),
} }
+6
View File
@@ -56,6 +56,10 @@ export interface LoginPayload {
password: string password: string
} }
export interface ForgotPasswordPayload {
account: string
}
export interface LoginResult { export interface LoginResult {
token: string token: string
expiresAt: string expiresAt: string
@@ -156,6 +160,8 @@ export interface IntegrationConfigItem {
configured: string[] configured: string[]
missing: string[] missing: string[]
optional?: string[] optional?: string[]
enabled?: string[]
disabled?: string[]
note?: string note?: string
variableHelp?: Record<string, IntegrationVariableHelp> variableHelp?: Record<string, IntegrationVariableHelp>
} }
+124 -4
View File
@@ -4,17 +4,23 @@
*/ */
import { Lock, Monitor } from '@element-plus/icons-vue' import { Lock, Monitor } from '@element-plus/icons-vue'
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { devopsApi } from '../services/devopsApi'
import { useSessionStore } from '../stores/session' import { useSessionStore } from '../stores/session'
type LoginRole = 'super_admin' | 'member'
const SUPER_ADMIN_ACCOUNT = 'zhanxi'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const session = useSessionStore() const session = useSessionStore()
const loading = ref(false) const loading = ref(false)
const forgotPasswordLoading = ref(false)
const loginRole = ref<LoginRole>('super_admin')
const loginForm = reactive({ const loginForm = reactive({
account: '', account: SUPER_ADMIN_ACCOUNT,
password: '', password: '',
}) })
@@ -27,12 +33,33 @@ const passwordForm = reactive({
const redirect = computed(() => const redirect = computed(() =>
typeof route.query.redirect === 'string' ? route.query.redirect : '/overview', typeof route.query.redirect === 'string' ? route.query.redirect : '/overview',
) )
const roleHint = computed(() =>
loginRole.value === 'super_admin'
? '超级管理员不提供忘记密码入口,请通过服务器运维方式重置。'
: '普通成员首次登录或重置密码后,必须先修改初始密码。',
)
const isSuperAdminLogin = computed(() => loginRole.value === 'super_admin')
function switchLoginRole(role: LoginRole) {
loginRole.value = role
loginForm.account = role === 'super_admin' ? SUPER_ADMIN_ACCOUNT : ''
loginForm.password = ''
}
function handleLoginRoleChange(value: unknown) {
if (value === 'super_admin' || value === 'member') {
switchLoginRole(value)
}
}
async function submitLogin() { async function submitLogin() {
loading.value = true loading.value = true
try { try {
const user = await session.login(loginForm) const user = await session.login({
account: isSuperAdminLogin.value ? SUPER_ADMIN_ACCOUNT : loginForm.account.trim(),
password: loginForm.password,
})
passwordForm.currentPassword = loginForm.password passwordForm.currentPassword = loginForm.password
if (user.mustChangePassword) { if (user.mustChangePassword) {
@@ -48,6 +75,44 @@ async function submitLogin() {
} }
} }
async function requestPasswordReset() {
if (isSuperAdminLogin.value) {
return
}
const account = loginForm.account.trim()
if (!account) {
ElMessage.warning('请先填写需要重置密码的成员账号')
return
}
try {
await ElMessageBox.confirm(
`确认向超级管理员发送账号 ${account} 的密码重置请求?`,
'忘记密码',
{
confirmButtonText: '发送请求',
cancelButtonText: '取消',
type: 'warning',
},
)
} catch {
return
}
forgotPasswordLoading.value = true
try {
await devopsApi.forgotPassword({ account })
ElMessage.success('已通知超级管理员协助重置密码')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '发送重置请求失败')
} finally {
forgotPasswordLoading.value = false
}
}
async function submitPasswordChange() { async function submitPasswordChange() {
if (passwordForm.newPassword !== passwordForm.confirmPassword) { if (passwordForm.newPassword !== passwordForm.confirmPassword) {
ElMessage.error('两次输入的新密码不一致') ElMessage.error('两次输入的新密码不一致')
@@ -102,8 +167,24 @@ function accountLabel() {
</div> </div>
<ElForm class="login-form" label-position="top" @submit.prevent="submit"> <ElForm class="login-form" label-position="top" @submit.prevent="submit">
<template v-if="!session.mustChangePassword"> <template v-if="!session.mustChangePassword">
<div class="role-switch">
<ElRadioGroup
:model-value="loginRole"
size="large"
@change="handleLoginRoleChange"
>
<ElRadioButton value="super_admin">超级管理员</ElRadioButton>
<ElRadioButton value="member">普通成员</ElRadioButton>
</ElRadioGroup>
<p>{{ roleHint }}</p>
</div>
<ElFormItem label="账号"> <ElFormItem label="账号">
<ElInput v-model="loginForm.account" size="large" autocomplete="username" /> <ElInput
v-model="loginForm.account"
size="large"
autocomplete="username"
:readonly="isSuperAdminLogin"
/>
</ElFormItem> </ElFormItem>
<ElFormItem label="密码"> <ElFormItem label="密码">
<ElInput <ElInput
@@ -123,6 +204,15 @@ function accountLabel() {
> >
登录 登录
</ElButton> </ElButton>
<ElButton
v-if="!isSuperAdminLogin"
class="forgot-password"
text
:loading="forgotPasswordLoading"
@click="requestPasswordReset"
>
忘记密码
</ElButton>
</template> </template>
<template v-else> <template v-else>
@@ -224,11 +314,41 @@ function accountLabel() {
padding: 44px 36px; padding: 44px 36px;
} }
.role-switch {
display: grid;
gap: 10px;
margin-bottom: 22px;
}
.role-switch :deep(.el-radio-group) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
}
.role-switch :deep(.el-radio-button__inner) {
width: 100%;
letter-spacing: 0;
}
.role-switch p {
min-height: 20px;
margin: 0;
color: #697386;
font-size: 13px;
line-height: 1.6;
}
.login-form .el-button { .login-form .el-button {
width: 100%; width: 100%;
margin-top: 8px; margin-top: 8px;
} }
.forgot-password {
width: 100%;
margin-top: 10px;
}
.change-title { .change-title {
display: grid; display: grid;
gap: 4px; gap: 4px;
+14
View File
@@ -178,6 +178,20 @@ function variableGroups(item: IntegrationConfigItem): VariableGroup[] {
emptyText: '无', emptyText: '无',
tagType: 'danger', tagType: 'danger',
}, },
{
key: 'enabled',
label: '已开启',
names: item.enabled ?? [],
emptyText: '暂无',
tagType: 'success',
},
{
key: 'disabled',
label: '已关闭',
names: item.disabled ?? [],
emptyText: '暂无',
tagType: 'warning',
},
{ {
key: 'optional', key: 'optional',
label: '可选变量', label: '可选变量',
+3 -2
View File
@@ -11,9 +11,10 @@ export default defineConfig({
port: 4301, port: 4301,
proxy: { proxy: {
'/api': { '/api': {
target: process.env.DEVOPS_API_PROXY_TARGET ?? 'https://devops.mrzhan.top', target: process.env.DEVOPS_API_PROXY_TARGET ?? 'http://localhost:4300',
changeOrigin: true, changeOrigin: true,
secure: true, secure: false,
rewrite: (path) => path.replace(/^\/api/, ''),
}, },
}, },
}, },