feat(account): 按原型完善左下角账户菜单与个人信息

This commit is contained in:
xy
2026-10-03 11:23:50 +08:00
parent eb4693cf65
commit 05c5723419
7 changed files with 442 additions and 18 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 使用同一份答案。
旧文书的勾选表格由后端按行生成独立编号;裸方框按独立勾选处理。需要互斥单选时应在模板中定义 `choice` 及选项。创建任务时单选/勾选项留给患者填写,文本项仍可由医护预填。页面显示“填写内容已保存”后可刷新回显。
### 左下角账户菜单
点击头像区域可查看个人信息、修改密码或退出登录。个人资料使用 `GET /v1/auth/me`,联系方式通过 `PATCH /v1/auth/me` 保存,仅更新当前登录用户。手机号和邮箱保持脱敏值时不提交,填写完整值表示替换,空字符串表示清空;`expectedVersion` 防止并发覆盖。基本信息只读,ID 优先展示 `employeeId`,无编号时显示用户 UUID。改密遵循后端 12–128 位强密码策略,成功后清理登录态并重新登录。
+10
View File
@@ -14,6 +14,7 @@ import type {
LoginRequest,
LoginResponse,
PasswordChangeRequest,
ProfileUpdateRequest,
} from '@/types/auth'
import type { ApiResponse } from '@/types/common'
@@ -115,3 +116,12 @@ export function getAuthErrorMessage(error: unknown) {
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,177 @@
<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(系统用户ID)<input
:value="profile.employeeId ?? profile.id"
disabled
:title="profile.id"
/></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>
+119 -18
View File
@@ -1,11 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import AccountPanel from './AccountPanel.vue'
import { RouterLink, useRouter } from 'vue-router'
import { useLoginStore } from '@/stores/login'
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(() =>
loginStore.visibleNavigation
@@ -31,11 +47,15 @@ const visibleMenuGroups = computed(() =>
)
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')
}
}
@@ -83,20 +103,59 @@ async function handleLogout() {
</div>
</nav>
<div class="menu-profile">
<div class="menu-profile-avatar">{{ loginStore.userName.slice(0, 1) }}</div>
<div class="menu-profile-copy">
<strong>{{ loginStore.userName }}</strong>
<span>{{ loginStore.department }}</span>
<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 :title="loginStore.user?.id"
>ID {{ loginStore.user?.employeeId ?? loginStore.user?.id ?? '—' }}</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 ?? loginStore.user?.id ?? '—' }}</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>
<button class="menu-logout" type="button" @click="handleLogout">退出</button>
</div>
</el-popover>
<AccountPanel ref="accountPanel" />
</aside>
</template>
<style scoped>
.clinical-menu {
display: flex;
min-height: 0;
flex: 0 0 var(--sidebar-width);
flex-direction: column;
width: var(--sidebar-width);
@@ -142,6 +201,7 @@ async function handleLogout() {
.menu-nav {
flex: 1;
min-height: 0;
padding: 12px 10px;
overflow-y: auto;
}
@@ -198,6 +258,10 @@ async function handleLogout() {
}
.menu-profile {
width: 100%;
text-align: left;
background: transparent;
cursor: pointer;
display: flex;
flex-shrink: 0;
gap: 10px;
@@ -220,6 +284,7 @@ async function handleLogout() {
}
.menu-profile-copy {
flex: 1;
min-width: 0;
}
@@ -242,16 +307,52 @@ async function handleLogout() {
font-size: 11px;
}
.menu-logout {
flex-shrink: 0;
margin-left: auto;
padding: 0;
color: #8fb3c2;
font-size: 11px;
background: transparent;
.menu-profile:hover,
.menu-profile:focus-visible {
background: rgb(255 255 255 / 7%);
}
.menu-logout:hover {
color: #fff;
.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>
@@ -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
permissions?: string[]
passwordChangeRequired?: boolean
employeeId?: number | null
employeeNo?: string | null
position?: string | null
professionalTitle?: string | null
roles?: AuthRoleSummary[]
}
@@ -84,3 +88,9 @@ export interface CurrentUserResponse extends AuthenticatedUser {
roles: AuthRoleSummary[]
passwordChangeRequired: boolean
}
export interface ProfileUpdateRequest {
phone?: string
email?: string
expectedVersion: number
}
@@ -47,6 +47,7 @@ const actionLabels: Record<string, string> = {
DISABLE: '停用',
ENABLE: '启用',
PASSWORD_CHANGE: '修改密码',
PROFILE_UPDATE: '修改本人联系方式',
PASSWORD_RESET: '重置密码',
PASSWORD_RESET_REJECTED: '密码重置被拒绝',
PASSWORD_RESET_RATE_LIMITED: '密码重置触发限流',