Merge remote-tracking branch 'origin/master'

# Conflicts:
#	clinical-web/src/components/layout/Menu.vue
This commit is contained in:
yelan
2026-10-06 16:57:28 +08:00
9 changed files with 498 additions and 439 deletions
+4
View File
@@ -285,3 +285,7 @@ API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/mana
真实 PAD 任务的勾选表格、结构化单选/多选、文本与日期项,使用后端 `/v1/sign-tasks/{taskId}/form` 返回的字段定义和规范化正文。每次修改串行保存完整患者答案,使用 `expectedRevision` 防止覆盖其他端的修改;保存失败会阻止签署。系统快照及医护已填项只读。上传签名时带 `signer.answerRevision`,后端完成最终必填校验并固化答案。已签任务和记录详情读取签署快照,下载 PDF 使用同一份答案。 真实 PAD 任务的勾选表格、结构化单选/多选、文本与日期项,使用后端 `/v1/sign-tasks/{taskId}/form` 返回的字段定义和规范化正文。每次修改串行保存完整患者答案,使用 `expectedRevision` 防止覆盖其他端的修改;保存失败会阻止签署。系统快照及医护已填项只读。上传签名时带 `signer.answerRevision`,后端完成最终必填校验并固化答案。已签任务和记录详情读取签署快照,下载 PDF 使用同一份答案。
旧文书的勾选表格由后端按行生成独立编号;裸方框按独立勾选处理。需要互斥单选时应在模板中定义 `choice` 及选项。创建任务时单选/勾选项留给患者填写,文本项仍可由医护预填。页面显示“填写内容已保存”后可刷新回显。 旧文书的勾选表格由后端按行生成独立编号;裸方框按独立勾选处理。需要互斥单选时应在模板中定义 `choice` 及选项。创建任务时单选/勾选项留给患者填写,文本项仍可由医护预填。页面显示“填写内容已保存”后可刷新回显。
### 左下角账户菜单
点击头像区域可查看个人信息、修改密码或退出登录。个人资料使用 `GET /v1/auth/me`,联系方式通过 `PATCH /v1/auth/me` 保存,仅更新当前登录用户。手机号和邮箱保持脱敏值时不提交,填写完整值表示替换,空字符串表示清空;`expectedVersion` 防止并发覆盖。基本信息只读,账户菜单与个人信息的 ID 统一展示 `employeeId`,无编号时显示“—”,不回退展示用户 UUID;UUID 仅用于内部关联和接口请求。改密遵循后端 12–128 位强密码策略,成功后清理登录态并重新登录。
+10
View File
@@ -14,6 +14,7 @@ import type {
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
PasswordChangeRequest, PasswordChangeRequest,
ProfileUpdateRequest,
} from '@/types/auth' } from '@/types/auth'
import type { ApiResponse } from '@/types/common' import type { ApiResponse } from '@/types/common'
@@ -115,3 +116,12 @@ export function getAuthErrorMessage(error: unknown) {
return '登录失败,请稍后重试' return '登录失败,请稍后重试'
} }
export async function updateProfile(payload: ProfileUpdateRequest): Promise<CurrentUserResponse> {
const response = await request.request<ApiResponse<CurrentUserResponse>>({
url: '/v1/auth/me',
method: 'PATCH',
data: payload,
})
return unwrapApiResponse(response)
}
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { useAccountPanel } from '@/composables/useAccountPanel'
const { panel, profile, loading, saving, error, contacts, password, strength, open, close, save } =
useAccountPanel()
defineExpose({ open })
</script>
<template>
<el-dialog
:model-value="panel !== null"
:title="panel === 'profile' ? '个人信息' : '修改密码'"
:width="panel === 'profile' ? '560px' : '460px'"
class="account-dialog"
append-to-body
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
@update:model-value="close"
>
<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
type="primary"
native-type="submit"
form="account-form"
:loading="saving"
:disabled="loading || (panel === 'profile' && !profile)"
>{{ panel === 'profile' ? '保存' : '确认修改' }}</el-button
>
</template>
</el-dialog>
</template>
<style scoped>
.profile-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.full {
grid-column: 1 / -1;
}
label {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--mut);
}
input {
width: 100%;
min-width: 0;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink);
}
input:disabled {
background: #f3f7f9;
}
input:focus {
outline: 2px solid var(--brand);
outline-offset: 1px;
}
.hint {
margin: 0 0 14px;
color: var(--mut);
font-size: 12px;
line-height: 1.6;
}
.password-fields {
display: grid;
gap: 12px;
}
.password-fields .hint {
margin: 0;
}
.error {
color: var(--err);
}
:global(.account-dialog) {
max-width: calc(100vw - 32px);
}
@media (max-width: 520px) {
.profile-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+178 -4
View File
@@ -1,11 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router' import AccountPanel from './AccountPanel.vue'
import { RouterLink, useRouter } from 'vue-router'
import UserMenu from '@/components/layout/UserMenu.vue'
import { useLoginStore } from '@/stores/login' import { useLoginStore } from '@/stores/login'
const loginStore = useLoginStore() const loginStore = useLoginStore()
const router = useRouter()
const accountPanel = ref<InstanceType<typeof AccountPanel>>()
const accountMenuOpen = ref(false)
const loggingOut = ref(false)
const identity = computed(() => {
const labels = [
...(loginStore.user?.roles?.map((role) => role.name) ?? []),
loginStore.user?.position,
loginStore.user?.professionalTitle,
].filter(Boolean)
return [...new Set(labels)].join(' · ') || loginStore.department
})
function openAccount(kind: 'profile' | 'password') {
accountMenuOpen.value = false
void accountPanel.value?.open(kind)
}
const visibleMenuGroups = computed(() => const visibleMenuGroups = computed(() =>
loginStore.visibleNavigation loginStore.visibleNavigation
@@ -29,6 +45,20 @@ const visibleMenuGroups = computed(() =>
})) }))
.filter((group) => group.items.length), .filter((group) => group.items.length),
) )
async function handleLogout() {
if (loggingOut.value) return
loggingOut.value = true
accountMenuOpen.value = false
try {
await loginStore.logout()
} catch {
// 即使服务暂时不可用,也要清理本地会话。
} finally {
loggingOut.value = false
await router.push('/login')
}
}
</script> </script>
<template> <template>
@@ -73,13 +103,57 @@ const visibleMenuGroups = computed(() =>
</div> </div>
</nav> </nav>
<UserMenu /> <el-popover
v-model:visible="accountMenuOpen"
trigger="click"
placement="top-start"
:width="280"
:offset="8"
popper-class="account-popover"
>
<template #reference>
<button
class="menu-profile"
type="button"
:aria-expanded="accountMenuOpen"
aria-label="打开账户菜单"
@keydown.esc="accountMenuOpen = false"
>
<span class="menu-profile-avatar">{{ loginStore.userName.slice(0, 1) }}</span>
<span class="menu-profile-copy">
<strong>{{ loginStore.userName }}</strong>
<span :title="identity">{{ identity }}</span>
<span>ID {{ loginStore.user?.employeeId ?? '—' }}</span>
</span>
<span class="menu-profile-arrow" aria-hidden="true">⌃</span>
</button>
</template>
<div class="account-actions" @keydown.esc="accountMenuOpen = false">
<div class="account-summary">
<strong>{{ loginStore.userName }}</strong>
<span>ID {{ loginStore.user?.employeeId ?? '—' }}</span>
<span>工号 {{ loginStore.user?.employeeNo || '—' }}</span>
<span>{{ loginStore.user?.campusName || '—' }} · {{ loginStore.department }}</span>
</div>
<button type="button" @click="openAccount('profile')">
<span aria-hidden="true">👤</span>个人信息
</button>
<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>
</div>
</el-popover>
<AccountPanel ref="accountPanel" />
</aside> </aside>
</template> </template>
<style scoped> <style scoped>
.clinical-menu { .clinical-menu {
display: flex; display: flex;
min-height: 0;
flex: 0 0 var(--sidebar-width); flex: 0 0 var(--sidebar-width);
flex-direction: column; flex-direction: column;
width: var(--sidebar-width); width: var(--sidebar-width);
@@ -125,6 +199,7 @@ const visibleMenuGroups = computed(() =>
.menu-nav { .menu-nav {
flex: 1; flex: 1;
min-height: 0;
padding: 12px 10px; padding: 12px 10px;
overflow-y: auto; overflow-y: auto;
} }
@@ -179,4 +254,103 @@ const visibleMenuGroups = computed(() =>
color: #fff; color: #fff;
background: rgb(255 255 255 / 20%); background: rgb(255 255 255 / 20%);
} }
.menu-profile {
width: 100%;
text-align: left;
background: transparent;
cursor: pointer;
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 14px 16px;
border-top: 1px solid rgb(255 255 255 / 8%);
}
.menu-profile-avatar {
display: grid;
flex-shrink: 0;
width: 34px;
height: 34px;
color: #fff;
font-size: 14px;
font-weight: 700;
place-items: center;
border-radius: 50%;
background: #18a0c0;
}
.menu-profile-copy {
flex: 1;
min-width: 0;
}
.menu-profile-copy strong,
.menu-profile-copy span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-profile-copy strong {
color: #fff;
font-size: 13px;
}
.menu-profile-copy span {
margin-top: 2px;
color: #8fb3c2;
font-size: 11px;
}
.menu-profile:hover,
.menu-profile:focus-visible {
background: rgb(255 255 255 / 7%);
}
.menu-profile:focus-visible {
outline: 2px solid #18a0c0;
outline-offset: -2px;
}
.menu-profile-arrow {
color: #8fb3c2;
}
.account-summary {
display: grid;
gap: 4px;
padding: 8px 12px 12px;
margin-bottom: 4px;
border-bottom: 1px solid var(--line);
}
.account-summary strong {
color: var(--ink);
font-size: 13px;
}
.account-summary span {
color: var(--mut);
font-size: 11px;
overflow-wrap: anywhere;
}
.account-actions button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 9px 12px;
background: transparent;
color: var(--ink);
border-radius: 7px;
text-align: left;
font-size: 13px;
}
.account-actions button:hover,
.account-actions button:focus-visible {
background: var(--brand-l);
}
.account-actions .account-logout {
margin-top: 4px;
border-top: 1px solid var(--line);
color: #d0534f;
}
</style> </style>
@@ -1,155 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getCurrentUser } from '@/api/auth'
import { useLoginStore } from '@/stores/login'
import { toUserMessage } from '@/utils/api-response'
import type { CurrentUserResponse } from '@/types/auth'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const loginStore = useLoginStore()
const loading = ref(false)
const errorMessage = ref('')
const profile = ref<CurrentUserResponse | null>(null)
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
function formatDateTime(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const pad = (input: number) => String(input).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/**
* 只读展示当前登录账户。
*
* 原型里手机号 / 邮箱可以本人修改,但后端没有「修改本人资料」接口,
* 所以这里只展示 `/v1/auth/me` 返回的真实字段,不提供编辑入口,
* 也不在没有数据时编造占位值。
*/
const rows = computed(() => {
const detail = profile.value
const user = loginStore.user
const roleNames = (detail?.roles ?? user?.roles ?? [])
.map((role) => role.name)
.filter((name): name is string => Boolean(name))
return [
{ label: '姓名', value: detail?.displayName || user?.displayName || '—' },
{ label: '登录账号', value: detail?.username || user?.username || '—' },
{ label: '工号', value: detail?.employeeNo || '—' },
{ label: '角色', value: roleNames.length ? roleNames.join(' / ') : '—' },
{ label: '所属院区', value: detail?.campusName || user?.campusName || '—' },
{ label: '所属科室', value: detail?.departmentName || user?.departmentName || '—' },
{ label: '手机号', value: detail?.phone || '—' },
{ label: '上次登录', value: formatDateTime(detail?.lastLoginAt) },
]
})
async function loadProfile() {
loading.value = true
errorMessage.value = ''
try {
profile.value = await getCurrentUser()
} catch (error: unknown) {
errorMessage.value = toUserMessage(error, '个人信息加载失败,请稍后重试')
} finally {
loading.value = false
}
}
watch(
() => props.modelValue,
(open) => {
if (open) {
void loadProfile()
}
},
)
</script>
<template>
<el-dialog v-model="visible" title="个人信息" width="520px" destroy-on-close>
<p class="profile-hint">账号资料由系统管理员维护,如需变更请联系管理员。</p>
<p v-if="loading" class="profile-state">加载中…</p>
<p v-else-if="errorMessage" class="profile-state is-error" role="alert">
{{ errorMessage }}
</p>
<dl v-else class="profile-list">
<div v-for="row in rows" :key="row.label" class="profile-row">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
<template #footer>
<el-button type="primary" @click="visible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.profile-hint {
margin: 0 0 12px;
padding: 8px 10px;
color: var(--mut);
font-size: 12px;
background: var(--brand-l);
border-radius: 7px;
}
.profile-state {
margin: 16px 0;
color: var(--mut);
font-size: 13px;
text-align: center;
}
.profile-state.is-error {
color: var(--err);
}
.profile-list {
margin: 0;
}
.profile-row {
display: flex;
gap: 12px;
padding: 9px 0;
border-bottom: 1px dashed var(--line);
}
.profile-row:last-child {
border-bottom: 0;
}
.profile-row dt {
flex-shrink: 0;
width: 84px;
color: var(--mut);
font-size: 13px;
}
.profile-row dd {
flex: 1;
margin: 0;
color: var(--ink);
font-size: 13px;
word-break: break-all;
}
</style>
@@ -1,280 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import ProfileDialog from '@/components/layout/ProfileDialog.vue'
import { useLoginStore } from '@/stores/login'
const router = useRouter()
const loginStore = useLoginStore()
const rootRef = ref<HTMLElement | null>(null)
const menuOpen = ref(false)
const profileVisible = ref(false)
const displayName = computed(() => loginStore.userName)
const avatarText = computed(() => displayName.value.slice(0, 1) || '医')
const department = computed(() => loginStore.department)
const roleLabel = computed(() => {
const names = (loginStore.user?.roles ?? [])
.map((role) => role.name)
.filter((name): name is string => Boolean(name))
return names.length ? names.join(' / ') : '—'
})
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
function closeMenu() {
menuOpen.value = false
}
/** 点击浮层以外任意位置收起菜单(按钮自身的点击已由 rootRef.contains 排除) */
function handleDocumentClick(event: MouseEvent) {
if (!menuOpen.value) return
const target = event.target as Node | null
if (rootRef.value && target && !rootRef.value.contains(target)) {
closeMenu()
}
}
function handleDocumentKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMenu()
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
document.addEventListener('keydown', handleDocumentKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick)
document.removeEventListener('keydown', handleDocumentKeydown)
})
function openProfile() {
closeMenu()
profileVisible.value = true
}
async function openChangePassword() {
closeMenu()
await router.push({ name: 'change-password' })
}
/**
* 退出登录:二次确认 → 注销服务端会话 → 清掉本地身份数据 → **整页**回登录页。
*
* 为什么最后一步是 `window.location.replace` 而不是 `router.replace`:
* SPA 内跳转只清得掉 store 里被显式重置过的字段,而模块级缓存
* (如文档库的「哈希 → 正文」缓存)、其余 Pinia store、各页面组件持有的数据
* 都会留在内存里。整页重载一次把这些一并清空,才算真正的退出。
* 这与 `utils/request.ts` 收到 401 时的处理方式是同一套。
*
* 本地身份数据(token / tokenExpiresAt / userInfo,其中 userInfo 里含 permissions)
* 由 `loginStore.logout()` → `clearSession()` → `clearAuthStorage()` 删除。
*/
async function handleLogout() {
closeMenu()
try {
await ElMessageBox.confirm('退出后需要重新登录才能继续使用,确定退出当前账号吗?', '退出登录', {
confirmButtonText: '退出登录',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'logout-confirm-button',
})
} catch {
// 用户点了取消,保持在当前页面
return
}
try {
await loginStore.logout()
} catch {
// 服务端注销失败不阻断退出流程,本地会话已在 logout() 的 finally 里清理
}
// 用 replace 而不是 assign:退出后按浏览器后退不该再回到已登录页面
window.location.replace('/login')
}
</script>
<template>
<div ref="rootRef" class="menu-profile">
<button
class="menu-profile-trigger"
type="button"
aria-haspopup="menu"
:aria-expanded="menuOpen"
@click="toggleMenu"
>
<span class="menu-profile-avatar" aria-hidden="true">{{ avatarText }}</span>
<span class="menu-profile-copy">
<strong>{{ displayName }}</strong>
<span>{{ department }}</span>
</span>
<span class="menu-profile-caret" :class="{ 'is-open': menuOpen }" aria-hidden="true">▾</span>
</button>
<div v-if="menuOpen" class="user-menu" role="menu" aria-label="账号菜单">
<div class="user-menu-head">
<strong>{{ displayName }}</strong>
<span>{{ roleLabel }}</span>
</div>
<button class="user-menu-item" type="button" role="menuitem" @click="openProfile">
<span class="user-menu-icon" aria-hidden="true">👤</span>个人信息
</button>
<button class="user-menu-item" type="button" role="menuitem" @click="openChangePassword">
<span class="user-menu-icon" aria-hidden="true">🔑</span>修改密码
</button>
<button class="user-menu-item is-danger" type="button" role="menuitem" @click="handleLogout">
<span class="user-menu-icon" aria-hidden="true">⏻</span>退出登录
</button>
</div>
<ProfileDialog v-model="profileVisible" />
</div>
</template>
<style scoped>
.menu-profile {
position: relative;
flex-shrink: 0;
border-top: 1px solid rgb(255 255 255 / 8%);
}
.menu-profile-trigger {
display: flex;
gap: 10px;
align-items: center;
width: 100%;
padding: 14px 16px;
color: inherit;
text-align: left;
background: transparent;
transition: background 0.15s ease;
}
.menu-profile-trigger:hover {
background: rgb(255 255 255 / 6%);
}
.menu-profile-avatar {
display: grid;
flex-shrink: 0;
width: 34px;
height: 34px;
color: #fff;
font-size: 14px;
font-weight: 700;
place-items: center;
border-radius: 50%;
background: #18a0c0;
}
.menu-profile-copy {
min-width: 0;
}
.menu-profile-copy strong,
.menu-profile-copy span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-profile-copy strong {
color: #fff;
font-size: 13px;
}
.menu-profile-copy span {
margin-top: 2px;
color: #8fb3c2;
font-size: 11px;
}
.menu-profile-caret {
flex-shrink: 0;
margin-left: auto;
color: #8fb3c2;
font-size: 10px;
transition: transform 0.15s ease;
}
.menu-profile-caret.is-open {
transform: rotate(180deg);
}
/* 浮层贴在用户区上方展开,对齐原型 #userMenu 的位置与投影 */
.user-menu {
position: absolute;
right: 12px;
bottom: calc(100% + 6px);
left: 12px;
z-index: 60;
padding: 6px;
background: #fff;
border-radius: 10px;
box-shadow: 0 10px 34px rgb(10 40 60 / 28%);
}
.user-menu-head {
padding: 8px 12px;
margin-bottom: 4px;
border-bottom: 1px solid var(--line);
}
.user-menu-head strong {
display: block;
color: var(--ink);
font-size: 13px;
}
.user-menu-head span {
display: block;
margin-top: 2px;
color: var(--mut);
font-size: 11.5px;
}
.user-menu-item {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
padding: 9px 12px;
color: var(--ink);
font-size: 13px;
text-align: left;
background: transparent;
border-radius: 7px;
}
.user-menu-item:hover {
background: var(--brand-l);
}
.user-menu-item.is-danger {
margin-top: 4px;
color: var(--err);
border-top: 1px solid var(--line);
border-radius: 0 0 7px 7px;
}
.user-menu-item.is-danger:hover {
background: var(--err-l);
}
.user-menu-icon {
font-size: 13px;
}
</style>
@@ -0,0 +1,121 @@
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { toUserMessage } from '@/utils/api-response'
import { getCurrentUser, updateProfile } from '@/api/auth'
import { useLoginStore } from '@/stores/login'
import type { CurrentUserResponse } from '@/types/auth'
export function useAccountPanel() {
const loginStore = useLoginStore()
const router = useRouter()
const panel = ref<'profile' | 'password' | null>(null)
const profile = ref<CurrentUserResponse | null>(null)
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const contacts = reactive({ phone: '', email: '' })
const password = reactive({ current: '', next: '', confirm: '' })
const strength = computed(() => {
const value = password.next
if (!value) return '—'
if (
value.length < 12 ||
!/[A-Z]/.test(value) ||
!/[a-z]/.test(value) ||
!/\d/.test(value) ||
!/[^A-Za-z0-9]/.test(value)
)
return '弱(未满足要求)'
return value.length >= 16 ? '很强' : '强'
})
let loadId = 0
async function open(kind: 'profile' | 'password') {
const currentLoad = ++loadId
loading.value = false
error.value = ''
profile.value = null
Object.assign(contacts, { phone: '', email: '' })
Object.assign(password, { current: '', next: '', confirm: '' })
panel.value = kind
if (kind !== 'profile') return
loading.value = true
try {
const result = await getCurrentUser()
if (currentLoad !== loadId) return
profile.value = result
contacts.phone = profile.value.phone ?? ''
contacts.email = profile.value.email ?? ''
} catch (cause) {
if (currentLoad === loadId) error.value = toUserMessage(cause, '个人信息加载失败,请重试')
} finally {
if (currentLoad === loadId) loading.value = false
}
}
function close() {
if (saving.value) return
loadId++
panel.value = null
Object.assign(password, { current: '', next: '', confirm: '' })
}
async function save() {
if (saving.value || loading.value) return
error.value = ''
if (panel.value === 'password') {
if (!password.current) error.value = '请输入当前密码'
else if (strength.value.startsWith('弱') || !password.next || password.next.length > 128)
error.value = '新密码须为 12–128 位,同时包含大小写字母、数字和特殊字符'
else if (password.next !== password.confirm) error.value = '两次输入的新密码不一致'
else if (password.current === password.next) error.value = '新密码不能与当前密码相同'
} else {
if (!profile.value) return
const phone = contacts.phone.trim()
const email = contacts.email.trim()
if (phone !== (profile.value.phone ?? '') && phone && !/^[0-9+() -]{5,32}$/.test(phone))
error.value = '请输入有效的完整手机号'
else if (
email !== (profile.value.email ?? '') &&
email &&
(email.includes('*') || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
)
error.value = '请输入有效的完整邮箱'
}
if (error.value) return
saving.value = true
try {
if (panel.value === 'password') {
await loginStore.changePassword({
currentPassword: password.current,
newPassword: password.next,
})
Object.assign(password, { current: '', next: '', confirm: '' })
panel.value = null
await router.replace({ name: 'login', query: { passwordChanged: '1' } })
} else if (profile.value) {
await updateProfile({
phone:
contacts.phone.trim() === (profile.value.phone ?? '')
? undefined
: contacts.phone.trim(),
email:
contacts.email.trim() === (profile.value.email ?? '')
? undefined
: contacts.email.trim(),
expectedVersion: profile.value.version,
})
panel.value = null
ElMessage.success('联系方式已保存')
}
} catch (cause) {
error.value = toUserMessage(cause, '保存失败,请稍后重试')
} finally {
saving.value = false
}
}
return { panel, profile, loading, saving, error, contacts, password, strength, open, close, save }
}
+10
View File
@@ -35,6 +35,10 @@ export interface AuthenticatedUser {
departmentName?: string | null departmentName?: string | null
permissions?: string[] permissions?: string[]
passwordChangeRequired?: boolean passwordChangeRequired?: boolean
employeeId?: number | null
employeeNo?: string | null
position?: string | null
professionalTitle?: string | null
roles?: AuthRoleSummary[] roles?: AuthRoleSummary[]
} }
@@ -84,3 +88,9 @@ export interface CurrentUserResponse extends AuthenticatedUser {
roles: AuthRoleSummary[] roles: AuthRoleSummary[]
passwordChangeRequired: boolean passwordChangeRequired: boolean
} }
export interface ProfileUpdateRequest {
phone?: string
email?: string
expectedVersion: number
}
@@ -47,6 +47,7 @@ const actionLabels: Record<string, string> = {
DISABLE: '停用', DISABLE: '停用',
ENABLE: '启用', ENABLE: '启用',
PASSWORD_CHANGE: '修改密码', PASSWORD_CHANGE: '修改密码',
PROFILE_UPDATE: '修改本人联系方式',
PASSWORD_RESET: '重置密码', PASSWORD_RESET: '重置密码',
PASSWORD_RESET_REJECTED: '密码重置被拒绝', PASSWORD_RESET_REJECTED: '密码重置被拒绝',
PASSWORD_RESET_RATE_LIMITED: '密码重置触发限流', PASSWORD_RESET_RATE_LIMITED: '密码重置触发限流',