feat(auth): 用户自助修改密码

- PATCH /auth/me/password(校验原密码 + tokenVersion 吊销其它会话)
- 顶栏用户下拉新增「修改密码」

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 16:34:14 +08:00
parent 23da579b26
commit b03e9ef62b
5 changed files with 75 additions and 0 deletions

2
components.d.ts vendored
View File

@ -12,6 +12,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ChangePasswordForm: typeof import('./src/components/change-password-form.vue')['default']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
@ -70,6 +71,7 @@ declare module 'vue' {
// For TSX support
declare global {
const ChangePasswordForm: typeof import('./src/components/change-password-form.vue')['default']
const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
const ElButton: typeof import('element-plus/es')['ElButton']

View File

@ -21,3 +21,8 @@ export function register(data: {
}): Promise<{ id: string; username: string }> {
return http.post<{ id: string; username: string }>('/auth/register', data)
}
/** 修改本人密码。 */
export function changePassword(oldPassword: string, newPassword: string): Promise<unknown> {
return http.patch('/auth/me/password', { oldPassword, newPassword })
}

View File

@ -0,0 +1,48 @@
<template>
<el-form ref="formRef" :model="model" :rules="rules" label-width="80px">
<el-form-item label="原密码" prop="oldPassword">
<el-input v-model="model.oldPassword" type="password" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="model.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="model.confirmPassword" type="password" show-password />
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'ChangePasswordForm' })
const formRef = ref<FormInstance>()
const model = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
const rules: FormRules = {
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (value !== model.newPassword) callback(new Error('两次输入的密码不一致'))
else callback()
},
trigger: 'blur',
},
],
}
async function submit(): Promise<{ oldPassword: string; newPassword: string }> {
await formRef.value?.validate()
return { oldPassword: model.oldPassword, newPassword: model.newPassword }
}
defineExpose({ submit })
</script>

View File

@ -56,6 +56,7 @@
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="change-password">修改密码</el-dropdown-item>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
@ -143,6 +144,7 @@ const emit = defineEmits<{
(e: 'toggle-fullscreen'): void
(e: 'open-settings'): void
(e: 'navigate', path: string): void
(e: 'change-password'): void
(e: 'logout'): void
}>()
@ -152,6 +154,7 @@ function onNavSelect(index: string): void {
function onUserCommand(command: string): void {
if (command === 'logout') emit('logout')
else if (command === 'change-password') emit('change-password')
}
</script>

View File

@ -22,6 +22,7 @@
@toggle-fullscreen="toggleFullscreen"
@open-settings="settingsOpen = true"
@navigate="handleNavigate"
@change-password="handleChangePassword"
@logout="handleLogout"
/>
@ -69,6 +70,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAppStore } from '@/stores/modules/app'
import { HOME_PATH, useTabsStore } from '@/stores/modules/tabs'
import { useAuthStore } from '@/stores/modules/auth'
@ -80,6 +82,9 @@ import type { LayoutSettings } from '@/config/layout'
import type { ResourceNode } from '@/types/resource'
import type { BreadcrumbItem } from '@/components/ty-breadcrumb.vue'
import { applyTheme } from '@/utils/theme'
import { showDialog } from '@/utils/dialog'
import { changePassword } from '@/api/auth'
import ChangePasswordForm from '@/components/change-password-form.vue'
const app = useAppStore()
const tabsStore = useTabsStore()
@ -231,6 +236,18 @@ async function handleLogout(): Promise<void> {
router.push('/login')
}
async function handleChangePassword(): Promise<void> {
const result = await showDialog({
component: ChangePasswordForm,
title: '修改密码',
width: '420px',
})
if (result == null) return
await changePassword(result.oldPassword, result.newPassword)
ElMessage.success('密码已修改,请重新登录')
await handleLogout()
}
/** 把菜单索引(path 或 code)解析为可跳转的叶子路径。 */
function resolveTarget(index: string): string | null {
const node = findMenu(index)