feat: 修改密码全局
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 56s

This commit is contained in:
luo
2026-09-28 11:17:39 +08:00
parent 015f990fb6
commit e79d599da1
12 changed files with 319 additions and 5 deletions

View File

@@ -0,0 +1,33 @@
## Context
头像菜单位于 `src/components/core/layouts/art-header-bar/index.vue`,当前“个人中心”通过路由跳转,“锁定屏幕”通过事件打开。`AuthService.changePassword` 已封装 `PUT /api/auth/password`,但当前 TypeScript 参数 `ChangePasswordParams` 使用 `oldPassword`、`newPassword`、`confirmPassword`,与 OpenAPI 的 `old_password`、`new_password` 不一致。HTTP 层只序列化 JSON,不会将 camelCase 自动转换成 snake_case。
## Goals / Non-Goals
- Goals: 提供头像菜单入口和简单的自助修改密码流程;遵循给定 OpenAPI 请求契约;不增加权限编码或权限判断。
- Non-Goals: 修改后端接口、增加路由、改变锁屏行为、增加权限管理配置。
## Decisions
- Decision: 使用头像菜单中的对话框承载表单,不为此功能新增页面路由。入口依次位于个人中心、修改密码、锁定屏幕。
- Decision: 表单收集当前密码、新密码和确认密码;确认密码仅用于客户端一致性校验,提交时仅构造 `{ old_password, new_password }`。
- Decision: 将 API DTO 类型调整为精确表达 OpenAPI 的 snake_case 字段,避免依赖 HTTP 层不存在的字段转换。
- Decision: 所有已登录用户均可见并可操作该入口,不使用 RBAC、按钮权限或菜单权限配置进行隐藏/拦截;API 请求仍携带现有 Bearer Token。
- Decision: 接口成功时清空敏感表单数据、提示成功,再调用 `userStore.clearLocalState()` 清除旧 token 并跳转登录页;接口失败时展示错误反馈,不记录密码值。
## Risks / Trade-offs
- 当前类型与接口字段不一致,直接复用旧 camelCase 参数会发送错误字段;以 DTO 对齐及请求验证覆盖此风险。
- 密码策略细节未在给定 OpenAPI 中定义;本次不自行新增密码复杂度规则,沿用后端校验结果并反馈错误。
## Migration Plan
无需数据迁移或后端变更。前端发布后,已登录用户可从头像菜单使用入口;回滚时可移除菜单项及对话框实现。
## Open Questions
- None.
## Session Handling
After a successful password change, clear the current client authentication state with `userStore.clearLocalState()` and redirect to the login route. Do not attempt the logout API after changing the password because the old token may already be invalid.

View File

@@ -0,0 +1,21 @@
# Change: 添加自助修改密码功能
## Why
当前头像菜单提供个人中心和锁定屏幕入口,但用户无法从该处修改自己的登录密码。认证服务已提供 `PUT /api/auth/password`,需要补齐可发现的前端入口和修改流程。
## What Changes
- 在头像菜单的“个人中心”和“锁定屏幕”之间增加“修改密码”入口。
- 点击后打开修改密码表单,提交当前密码和新密码至 `PUT /api/auth/password`。
- 按 OpenAPI 使用 `old_password`、`new_password` 请求字段;确认密码(如表单提供)只在前端校验,不发送给接口。
- 修改密码入口面向所有已登录用户,不新增权限编码、路由权限或按钮权限。
- 修改成功后清理本地认证状态并跳转登录页,要求用户使用新密码重新登录。
- 为新增文案提供中英文国际化。
## Impact
- Affected specs: `admin-authentication`
- Affected code: `src/components/core/layouts/art-header-bar/index.vue`、`src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue`、`src/components/core/layouts/art-header-bar/style.scss`、`src/api/modules/auth.ts`、`src/types/api/auth.ts`、`src/locales/langs/zh.json`、`src/locales/langs/en.json`
- API: 已有 `PUT /api/auth/password`;请求体按 OpenAPI 发送 `old_password` 和 `new_password`,使用当前登录 Bearer Token。
- Authorization: 不新增前端权限配置;接口仍按认证 API 的 Bearer 认证要求调用。

View File

@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Self-Service Password Change Entry
The authenticated admin interface SHALL provide a password-change entry in the avatar menu between “个人中心” and “锁定屏幕”. The entry SHALL be available to every authenticated user without a frontend route, button, or menu permission check.
#### Scenario: User opens password-change form from avatar menu
- **GIVEN** the user is authenticated in the admin interface
- **WHEN** the user opens the avatar menu
- **THEN** the menu shows “个人中心”, “修改密码”, and “锁定屏幕” in that order
- **AND** selecting “修改密码” opens the password-change form
- **AND** the entry is not hidden or disabled based on a permission code
### Requirement: Change Current User Password
The password-change form SHALL collect the current password and new password, and SHALL submit them through `PUT /api/auth/password` using the current Bearer token. The request body MUST contain exactly the API fields `old_password` and `new_password`; any confirmation value used for client-side validation MUST NOT be sent. Password values MUST NOT be written to application logs.
#### Scenario: Password change succeeds
- **GIVEN** the user has entered a current password and matching new-password values
- **WHEN** the user submits the form and the API returns success
- **THEN** the frontend sends `old_password` and `new_password` to `PUT /api/auth/password`
- **AND** the frontend displays success feedback and clears the password form values
- **AND** the frontend clears the local authentication state and redirects the user to the login page
- **AND** the user must authenticate again with the new password
#### Scenario: Password change is rejected
- **GIVEN** the user submits the password-change form
- **WHEN** the API rejects the request
- **THEN** the frontend displays error feedback and does not report success
- **AND** the frontend does not log any password value

View File

@@ -0,0 +1,13 @@
## 1. 头像菜单与修改密码表单
- [x] 1.1 在头像菜单“个人中心”和“锁定屏幕”之间添加“修改密码”入口,不设置权限判断。
- [x] 1.2 实现修改密码表单,收集当前密码、新密码及确认密码,并在前端校验必填项与两次新密码一致。
- [x] 1.3 调用认证 API;请求仅包含 `old_password` 和 `new_password`,成功后清空表单并提示结果,失败时显示错误反馈。
- [x] 1.4 添加中英文菜单、表单、校验及结果提示文案。
## 2. API 契约与验证
- [x] 2.1 对齐 `ChangePasswordParams` 与 `AuthService.changePassword` 的请求 DTO,确保网络请求字段为 OpenAPI 定义的 snake_case,且不发送确认密码。
- [x] 2.2 验证菜单顺序、表单校验、成功与失败反馈,以及入口不依赖任何权限编码。
- [x] 2.3 运行相关类型检查和构建,处理本次改动引入的问题。
- [x] 2.4 密码修改成功后清理本地认证状态并跳转登录页。

View File

@@ -6,7 +6,6 @@ import { BaseService } from '../BaseService'
import type {
LoginParams,
LoginData,
UserInfo,
UserInfoResponse,
RefreshTokenParams,
RefreshTokenData,
@@ -51,7 +50,9 @@ export class AuthService extends BaseService {
* @param params 修改密码参数
*/
static changePassword(params: ChangePasswordParams): Promise<BaseResponse> {
return this.put<BaseResponse>('/api/auth/password', params)
return this.put<BaseResponse>('/api/auth/password', params, {
requestOptions: { errorMessageMode: 'none' }
})
}
/**

View File

@@ -0,0 +1,151 @@
<template>
<ElDialog
v-model="dialogVisible"
:title="t('passwordChange.title')"
width="min(420px, calc(100vw - 32px))"
:close-on-click-modal="false"
:close-on-press-escape="!submitting"
:show-close="!submitting"
@closed="resetForm"
>
<ElForm
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
@submit.prevent="handleSubmit"
>
<ElFormItem prop="old_password" :label="t('passwordChange.oldPassword')">
<ElInput
v-model="formData.old_password"
type="password"
autocomplete="current-password"
show-password
:disabled="submitting"
/>
</ElFormItem>
<ElFormItem prop="new_password" :label="t('passwordChange.newPassword')">
<ElInput
v-model="formData.new_password"
type="password"
autocomplete="new-password"
show-password
:disabled="submitting"
/>
</ElFormItem>
<ElFormItem prop="confirm_password" :label="t('passwordChange.confirmPassword')">
<ElInput
v-model="formData.confirm_password"
type="password"
autocomplete="new-password"
show-password
:disabled="submitting"
@keyup.enter="handleSubmit"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton :disabled="submitting" @click="dialogVisible = false">
{{ t('common.cancel') }}
</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ t('passwordChange.submit') }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { AuthService } from '@/api/modules'
import { useUserStore } from '@/store/modules/user'
import { normalizeApiError } from '@/utils/business/apiError'
import { useI18n } from 'vue-i18n'
defineOptions({ name: 'ChangePasswordDialog' })
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const { t } = useI18n()
const userStore = useUserStore()
const dialogVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const formData = reactive({
old_password: '',
new_password: '',
confirm_password: ''
})
const formRules = computed<FormRules>(() => ({
old_password: [
{ required: true, message: t('passwordChange.oldPasswordRequired'), trigger: 'blur' }
],
new_password: [
{ required: true, message: t('passwordChange.newPasswordRequired'), trigger: 'blur' }
],
confirm_password: [
{ required: true, message: t('passwordChange.confirmPasswordRequired'), trigger: 'blur' },
{
validator: (_rule, value: string, callback) => {
if (value && value !== formData.new_password) {
callback(new Error(t('passwordChange.passwordMismatch')))
return
}
callback()
},
trigger: 'blur'
}
]
}))
const resetForm = () => {
formData.old_password = ''
formData.new_password = ''
formData.confirm_password = ''
formRef.value?.clearValidate()
}
const handleSubmit = async () => {
if (!formRef.value || submitting.value) return
try {
await formRef.value.validate()
} catch {
return
}
if (submitting.value) return
submitting.value = true
try {
const response = await AuthService.changePassword({
old_password: formData.old_password,
new_password: formData.new_password
})
if (response.code !== 0) {
ElMessage.error(response.msg || t('passwordChange.failure'))
return
}
ElMessage.success(t('passwordChange.success'))
resetForm()
dialogVisible.value = false
userStore.clearLocalState()
} catch (error) {
const normalized = normalizeApiError(error)
ElMessage.error(normalized.message || t('passwordChange.failure'))
} finally {
submitting.value = false
}
}
</script>

View File

@@ -158,6 +158,12 @@
<i class="menu-icon iconfont-sys">&#xe734;</i>
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
</li>
<li>
<button class="user-menu-action" type="button" @click="openChangePassword">
<el-icon class="menu-icon"><Key /></el-icon>
<span class="menu-txt">{{ $t('topBar.user.changePassword') }}</span>
</button>
</li>
<li @click="lockScreen()">
<i class="menu-icon iconfont-sys">&#xe817;</i>
<span class="menu-txt">{{ $t('topBar.user.lockScreen') }}</span>
@@ -175,12 +181,15 @@
</div>
<ArtWorkTab />
<ChangePasswordDialog v-model="changePasswordDialogVisible" />
<art-notification v-model="showNotice" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { Key } from '@element-plus/icons-vue'
import ChangePasswordDialog from './ChangePasswordDialog.vue'
import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
import { useSettingStore } from '@/store/modules/setting'
import { useUserStore } from '@/store/modules/user'
@@ -217,6 +226,7 @@
const { menuList } = storeToRefs(useMenuStore())
const showNotice = ref(false)
const changePasswordDialogVisible = ref(false)
const userMenuPopover = ref()
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
@@ -348,6 +358,13 @@
mittBus.emit('openLockScreen')
}
const openChangePassword = () => {
closeUserMenu()
window.setTimeout(() => {
changePasswordDialogVisible.value = true
}, 100)
}
const closeUserMenu = () => {
setTimeout(() => {
userMenuPopover.value.hide()

View File

@@ -88,6 +88,23 @@
}
}
.user-menu-action {
display: flex;
align-items: center;
width: 100%;
padding: 0;
margin: 0;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
&:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
}
.line {
width: 100%;
height: 1px;

View File

@@ -7,6 +7,7 @@
"userCenter": "User center",
"docs": "Document",
"github": "Github",
"changePassword": "Change password",
"lockScreen": "Lock screen",
"logout": "Log out"
}
@@ -17,6 +18,19 @@
"confirm": "Confirm",
"logOutTips": "Do you want to log out?"
},
"passwordChange": {
"title": "Change password",
"oldPassword": "Current password",
"newPassword": "New password",
"confirmPassword": "Confirm new password",
"oldPasswordRequired": "Please enter your current password",
"newPasswordRequired": "Please enter a new password",
"confirmPasswordRequired": "Please enter the new password again",
"passwordMismatch": "The new passwords do not match",
"submit": "Change password",
"success": "Password changed successfully",
"failure": "Failed to change password"
},
"search": {
"placeholder": "Search page",
"historyTitle": "Search history",

View File

@@ -7,6 +7,7 @@
"userCenter": "个人中心",
"docs": "使用文档",
"github": "Github",
"changePassword": "修改密码",
"lockScreen": "锁定屏幕",
"logout": "退出登录"
}
@@ -18,6 +19,19 @@
"logOutTips": "您是否要退出登录?",
"back": "返回"
},
"passwordChange": {
"title": "修改密码",
"oldPassword": "当前密码",
"newPassword": "新密码",
"confirmPassword": "确认新密码",
"oldPasswordRequired": "请输入当前密码",
"newPasswordRequired": "请输入新密码",
"confirmPasswordRequired": "请再次输入新密码",
"passwordMismatch": "两次输入的新密码不一致",
"submit": "确认修改",
"success": "密码修改成功",
"failure": "密码修改失败"
},
"search": {
"placeholder": "搜索页面",
"historyTitle": "搜索历史",

View File

@@ -63,7 +63,6 @@ export interface RefreshTokenData {
// 修改密码参数
export interface ChangePasswordParams {
oldPassword: string
newPassword: string
confirmPassword: string
old_password: string
new_password: string
}

View File

@@ -80,6 +80,7 @@ declare module 'vue' {
BoxStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default']
CardOperationDialog: typeof import('./../components/business/CardOperationDialog.vue')['default']
CardStatusTag: typeof import('./../components/business/CardStatusTag.vue')['default']
ChangePasswordDialog: typeof import('./../components/core/layouts/art-header-bar/ChangePasswordDialog.vue')['default']
ChunkErrorBoundary: typeof import('./../components/core/others/ChunkErrorBoundary.vue')['default']
CodeGeneratorButton: typeof import('./../components/business/CodeGeneratorButton.vue')['default']
ColorSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ColorSettings.vue')['default']