refactor(account): 账户弹窗与退出确认改用页面通用弹窗外壳

This commit is contained in:
yelan
2026-10-07 10:24:40 +08:00
parent 2a296d470a
commit ee7e90ab01
3 changed files with 488 additions and 144 deletions
@@ -1,137 +1,300 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue'
import { useAccountPanel } from '@/composables/useAccountPanel' import { useAccountPanel } from '@/composables/useAccountPanel'
const { panel, profile, loading, saving, error, contacts, password, strength, open, close, save } = const { panel, profile, loading, saving, error, contacts, password, strength, open, close, save } =
useAccountPanel() useAccountPanel()
defineExpose({ open }) defineExpose({ open })
/**
* 弹窗挂在 body 上,卡片本身不持有焦点,`@keydown` 收不到按键,
* 所以 Esc 关闭(原先由 el-dialog 的 `close-on-press-escape` 提供)要挂在 document 上。
* `close()` 内部已挡掉保存中,这里不必再判一次。
*/
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') close()
}
watch(panel, (value) => {
if (value) document.addEventListener('keydown', handleEscape)
else document.removeEventListener('keydown', handleEscape)
})
onBeforeUnmount(() => document.removeEventListener('keydown', handleEscape))
</script> </script>
<template> <template>
<el-dialog <!-- 外壳走页面通用弹窗样式(与作废确认、文书预览等同一套 .dialog-* / .action-button),
:model-value="panel !== null" 不再用 Element Plus 默认的 el-dialog / el-button —— 那套默认蓝与全局品牌色不一致。 -->
:title="panel === 'profile' ? '个人信息' : '修改密码'" <Teleport to="body">
:width="panel === 'profile' ? '560px' : '460px'" <div v-if="panel !== null" class="account-mask" role="presentation">
class="account-dialog" <section
append-to-body class="account-card"
:close-on-click-modal="false" :class="panel === 'profile' ? 'account-card--profile' : 'account-card--password'"
:close-on-press-escape="!saving" role="dialog"
:show-close="!saving" aria-modal="true"
@update:model-value="close" aria-labelledby="account-dialog-title"
>
<form id="account-form" @submit.prevent="save">
<template v-if="panel === 'profile'">
<p class="hint">基本信息由信息科统一维护,联系方式可自助修改</p>
<p v-if="loading" role="status">正在加载个人信息…</p>
<div v-else-if="profile" class="profile-grid">
<label class="full"
>ID(员工编号)<input :value="profile.employeeId ?? '—'" disabled
/></label>
<label>工号<input :value="profile.employeeNo || '—'" disabled /></label>
<label>姓名<input :value="profile.displayName" disabled /></label>
<label
>状态<input
:value="{ ENABLED: '启用', DISABLED: '停用', LOCKED: '锁定' }[profile.status]"
disabled
/></label>
<label
>院区 / 科室<input
:value="[profile.campusName || '—', profile.departmentName || '—'].join(' / ')"
disabled
/></label>
<label class="full"
>角色 / 岗位<input
:value="
[
profile.roles.map((role) => role.name).join('、'),
profile.position,
profile.professionalTitle,
]
.filter(Boolean)
.join(' / ') || '—'
"
disabled
/></label>
<label
>手机号<input
v-model="contacts.phone"
type="tel"
maxlength="32"
:disabled="saving"
autocomplete="tel"
/></label>
<label
>邮箱<input
v-model="contacts.email"
type="text"
inputmode="email"
maxlength="255"
:disabled="saving"
autocomplete="email"
/></label>
<p class="hint full">联系方式已脱敏;保持原值不修改,输入完整内容可替换,清空可删除。</p>
</div>
</template>
<div v-else class="password-fields">
<p class="hint">12–128 位,须同时包含大小写字母、数字和特殊字符。修改后需重新登录。</p>
<label
>当前密码<input
v-model="password.current"
type="password"
autocomplete="current-password"
maxlength="128"
:disabled="saving"
/></label>
<label
>新密码<input
v-model="password.next"
type="password"
autocomplete="new-password"
maxlength="128"
:disabled="saving"
/></label>
<p class="hint" aria-live="polite">强度:{{ strength }}</p>
<label
>确认新密码<input
v-model="password.confirm"
type="password"
autocomplete="new-password"
maxlength="128"
:disabled="saving"
/></label>
</div>
<p v-if="error" class="error" role="alert">{{ error }}</p>
</form>
<template #footer>
<el-button :disabled="saving" @click="close">取消</el-button>
<el-button v-if="panel === 'profile' && !profile && !loading" @click="open('profile')"
>重新加载</el-button
> >
<el-button <header class="account-header">
type="primary" <strong id="account-dialog-title">
native-type="submit" {{ panel === 'profile' ? '个人信息' : '修改密码' }}
form="account-form" </strong>
:loading="saving" <button
:disabled="loading || (panel === 'profile' && !profile)" v-if="!saving"
>{{ panel === 'profile' ? '保存' : '确认修改' }}</el-button type="button"
> class="account-close"
</template> aria-label="关闭"
</el-dialog> @click="close"
>
×
</button>
</header>
<form id="account-form" class="account-body" @submit.prevent="save">
<template v-if="panel === 'profile'">
<p class="hint">基本信息由信息科统一维护,联系方式可自助修改</p>
<p v-if="loading" role="status">正在加载个人信息…</p>
<div v-else-if="profile" class="profile-grid">
<label class="full"
>ID(员工编号)<input :value="profile.employeeId ?? '—'" disabled
/></label>
<label>工号<input :value="profile.employeeNo || '—'" disabled /></label>
<label>姓名<input :value="profile.displayName" disabled /></label>
<label
>状态<input
:value="{ ENABLED: '启用', DISABLED: '停用', LOCKED: '锁定' }[profile.status]"
disabled
/></label>
<label
>院区 / 科室<input
:value="[profile.campusName || '—', profile.departmentName || '—'].join(' / ')"
disabled
/></label>
<label class="full"
>角色 / 岗位<input
:value="
[
profile.roles.map((role) => role.name).join('、'),
profile.position,
profile.professionalTitle,
]
.filter(Boolean)
.join(' / ') || '—'
"
disabled
/></label>
<label
>手机号<input
v-model="contacts.phone"
type="tel"
maxlength="32"
:disabled="saving"
autocomplete="tel"
/></label>
<label
>邮箱<input
v-model="contacts.email"
type="text"
inputmode="email"
maxlength="255"
:disabled="saving"
autocomplete="email"
/></label>
<p class="hint full">
联系方式已脱敏;保持原值不修改,输入完整内容可替换,清空可删除。
</p>
</div>
</template>
<div v-else class="password-fields">
<p class="hint">12–128 位,须同时包含大小写字母、数字和特殊字符。修改后需重新登录。</p>
<label
>当前密码<input
v-model="password.current"
type="password"
autocomplete="current-password"
maxlength="128"
:disabled="saving"
/></label>
<label
>新密码<input
v-model="password.next"
type="password"
autocomplete="new-password"
maxlength="128"
:disabled="saving"
/></label>
<p class="hint" aria-live="polite">强度:{{ strength }}</p>
<label
>确认新密码<input
v-model="password.confirm"
type="password"
autocomplete="new-password"
maxlength="128"
:disabled="saving"
/></label>
</div>
<p v-if="error" class="error" role="alert">{{ error }}</p>
</form>
<footer class="account-footer">
<span class="account-spacer" />
<button
type="button"
class="action-button action-button--ghost"
:disabled="saving"
@click="close"
>
取消
</button>
<button
v-if="panel === 'profile' && !profile && !loading"
type="button"
class="action-button action-button--ghost"
@click="open('profile')"
>
重新加载
</button>
<button
type="submit"
form="account-form"
class="action-button"
:disabled="saving || loading || (panel === 'profile' && !profile)"
>
{{ saving ? '处理中…' : panel === 'profile' ? '保存' : '确认修改' }}
</button>
</footer>
</section>
</div>
</Teleport>
</template> </template>
<style scoped> <style scoped>
.account-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.account-card {
display: flex;
max-width: 96vw;
max-height: calc(100vh - 32px);
flex-direction: column;
margin: auto;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: account-pop 0.18s ease;
}
.account-card--profile {
width: 560px;
}
.account-card--password {
width: 460px;
}
.account-header {
display: flex;
flex-shrink: 0;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.account-header strong {
color: var(--ink);
font-size: 15px;
}
.account-close {
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.account-close:hover {
color: var(--ink);
}
.account-body {
min-height: 0;
padding: 12px 18px;
overflow-y: auto;
}
.account-footer {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 10px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
border-radius: 0 0 14px 14px;
}
.account-spacer {
flex: 1;
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.action-button:hover:not(:disabled) {
background: var(--brand-d);
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover:not(:disabled) {
background: var(--brand-l);
}
.action-button:disabled {
color: #9aabb3;
cursor: not-allowed;
background: #eef3f5;
}
.profile-grid { .profile-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }
.full { .full {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
label { label {
display: grid; display: grid;
gap: 6px; gap: 6px;
font-size: 12px; font-size: 12px;
color: var(--mut); color: var(--mut);
} }
input { input {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -140,32 +303,48 @@ input {
border-radius: 6px; border-radius: 6px;
color: var(--ink); color: var(--ink);
} }
input:disabled { input:disabled {
background: #f3f7f9; background: #f3f7f9;
} }
input:focus { input:focus {
outline: 2px solid var(--brand); outline: 2px solid var(--brand);
outline-offset: 1px; outline-offset: 1px;
} }
.hint { .hint {
margin: 0 0 14px; margin: 0 0 14px;
color: var(--mut); color: var(--mut);
font-size: 12px; font-size: 12px;
line-height: 1.6; line-height: 1.6;
} }
.password-fields { .password-fields {
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
.password-fields .hint { .password-fields .hint {
margin: 0; margin: 0;
} }
.error { .error {
color: var(--err); color: var(--err);
} }
:global(.account-dialog) {
max-width: calc(100vw - 32px); @keyframes account-pop {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
} }
@media (max-width: 520px) { @media (max-width: 520px) {
.profile-grid { .profile-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
+199 -25
View File
@@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import AccountPanel from './AccountPanel.vue' import AccountPanel from './AccountPanel.vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { useLoginStore } from '@/stores/login' import { useLoginStore } from '@/stores/login'
const loginStore = useLoginStore() const loginStore = useLoginStore()
const accountPanel = ref<InstanceType<typeof AccountPanel>>() const accountPanel = ref<InstanceType<typeof AccountPanel>>()
const accountMenuOpen = ref(false) const accountMenuOpen = ref(false)
const logoutConfirmOpen = ref(false)
const loggingOut = ref(false) const loggingOut = ref(false)
const identity = computed(() => { const identity = computed(() => {
const labels = [ const labels = [
@@ -46,6 +46,18 @@ const visibleMenuGroups = computed(() =>
.filter((group) => group.items.length), .filter((group) => group.items.length),
) )
/** 打开二次确认(确认弹窗用页面通用样式,不再走 ElMessageBox 的 Element 默认外观) */
function requestLogout() {
if (loggingOut.value) return
accountMenuOpen.value = false
logoutConfirmOpen.value = true
}
function cancelLogout() {
if (loggingOut.value) return
logoutConfirmOpen.value = false
}
/** /**
* 退出登录:二次确认 → 注销服务端会话 → 清掉本地身份数据 → **整页**回登录页。 * 退出登录:二次确认 → 注销服务端会话 → 清掉本地身份数据 → **整页**回登录页。
* *
@@ -58,21 +70,8 @@ const visibleMenuGroups = computed(() =>
* 本地身份数据(token / tokenExpiresAt / userInfo,其中 userInfo 里含 permissions) * 本地身份数据(token / tokenExpiresAt / userInfo,其中 userInfo 里含 permissions)
* 由 `loginStore.logout()` → `clearSession()` → `clearAuthStorage()` 删除。 * 由 `loginStore.logout()` → `clearSession()` → `clearAuthStorage()` 删除。
*/ */
async function handleLogout() { async function confirmLogout() {
if (loggingOut.value) return if (loggingOut.value) return
accountMenuOpen.value = false
try {
await ElMessageBox.confirm('退出后需要重新登录才能继续使用,确定退出当前账号吗?', '退出登录', {
confirmButtonText: '退出登录',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'logout-confirm-button',
})
} catch {
// 用户点了取消,保持在当前页面
return
}
loggingOut.value = true loggingOut.value = true
try { try {
@@ -84,6 +83,18 @@ async function handleLogout() {
// 用 replace 而不是 assign:退出后按浏览器后退不该再回到已登录页面 // 用 replace 而不是 assign:退出后按浏览器后退不该再回到已登录页面
window.location.replace('/login') window.location.replace('/login')
} }
/** 确认弹窗挂在 body 上,Esc 关闭要挂 document 才收得到(与 AccountPanel 同一处理) */
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') cancelLogout()
}
watch(logoutConfirmOpen, (value) => {
if (value) document.addEventListener('keydown', handleEscape)
else document.removeEventListener('keydown', handleEscape)
})
onBeforeUnmount(() => document.removeEventListener('keydown', handleEscape))
</script> </script>
<template> <template>
@@ -160,18 +171,59 @@ async function handleLogout() {
<span>工号 {{ loginStore.user?.employeeNo || '—' }}</span> <span>工号 {{ loginStore.user?.employeeNo || '—' }}</span>
<span>{{ loginStore.user?.campusName || '—' }} · {{ loginStore.department }}</span> <span>{{ loginStore.user?.campusName || '—' }} · {{ loginStore.department }}</span>
</div> </div>
<button type="button" @click="openAccount('profile')"> <button type="button" @click="openAccount('profile')">个人信息</button>
<span aria-hidden="true">👤</span>个人信息 <button type="button" @click="openAccount('password')">修改密码</button>
</button> <button class="account-logout" type="button" :disabled="loggingOut" @click="requestLogout">
<button type="button" @click="openAccount('password')"> 退出登录
<span aria-hidden="true">🔑</span>修改密码
</button>
<button class="account-logout" type="button" :disabled="loggingOut" @click="handleLogout">
<span aria-hidden="true">⏻</span>{{ loggingOut ? '退出中…' : '退出登录' }}
</button> </button>
</div> </div>
</el-popover> </el-popover>
<AccountPanel ref="accountPanel" /> <AccountPanel ref="accountPanel" />
<!-- 退出二次确认:与账户弹窗同一套页面通用外壳与按钮(不再用 ElMessageBox 默认外观) -->
<Teleport to="body">
<div v-if="logoutConfirmOpen" class="logout-mask" role="presentation">
<section
class="logout-card"
role="alertdialog"
aria-modal="true"
aria-labelledby="logout-dialog-title"
>
<header class="logout-header">
<strong id="logout-dialog-title">退出登录</strong>
<button
v-if="!loggingOut"
type="button"
class="logout-close"
aria-label="关闭"
@click="cancelLogout"
>
×
</button>
</header>
<div class="logout-body">退出后需要重新登录才能继续使用,确定退出当前账号吗?</div>
<footer class="logout-footer">
<span class="logout-spacer" />
<button
type="button"
class="action-button action-button--ghost"
:disabled="loggingOut"
@click="cancelLogout"
>
取消
</button>
<button
type="button"
class="action-button action-button--danger"
:disabled="loggingOut"
@click="confirmLogout"
>
{{ loggingOut ? '退出中…' : '退出登录' }}
</button>
</footer>
</section>
</div>
</Teleport>
</aside> </aside>
</template> </template>
@@ -360,7 +412,6 @@ async function handleLogout() {
.account-actions button { .account-actions button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
width: 100%; width: 100%;
padding: 9px 12px; padding: 9px 12px;
background: transparent; background: transparent;
@@ -378,4 +429,127 @@ async function handleLogout() {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
color: #d0534f; color: #d0534f;
} }
/* 退出二次确认弹窗:外壳与按钮沿用页面通用弹窗样式(与 AccountPanel 同一套) */
.logout-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.logout-card {
display: flex;
width: 440px;
max-width: 96vw;
max-height: calc(100vh - 32px);
flex-direction: column;
margin: auto;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: logout-pop 0.18s ease;
}
.logout-header {
display: flex;
flex-shrink: 0;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.logout-header strong {
color: var(--ink);
font-size: 15px;
}
.logout-close {
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.logout-close:hover {
color: var(--ink);
}
.logout-body {
min-height: 0;
padding: 16px 18px;
overflow-y: auto;
color: var(--ink);
font-size: 13px;
line-height: 1.7;
}
.logout-footer {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 10px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
border-radius: 0 0 14px 14px;
}
.logout-spacer {
flex: 1;
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover:not(:disabled) {
background: var(--brand-l);
}
.action-button--danger {
color: var(--err);
background: #fff;
border: 1px solid var(--err);
}
.action-button--danger:hover:not(:disabled) {
background: var(--err-l);
}
.action-button:disabled {
color: #9aabb3;
cursor: not-allowed;
background: #eef3f5;
}
@keyframes logout-pop {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
</style> </style>
-9
View File
@@ -72,12 +72,3 @@ a {
min-width: 0; min-width: 0;
} }
} }
/* 「退出登录」二次确认的确认按钮:终止会话不可逆,按危险操作着色。
MessageBox 挂在 body 上,组件里的 scoped 选择器命中不了,只能放全局。 */
.el-message-box .logout-confirm-button,
.el-message-box .logout-confirm-button:hover,
.el-message-box .logout-confirm-button:focus {
background-color: var(--err);
border-color: var(--err);
}