feat(account): 按原型完善左下角账户菜单与个人信息
This commit is contained in:
@@ -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 位强密码策略,成功后清理登录态并重新登录。
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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: '密码重置触发限流',
|
||||
|
||||
Reference in New Issue
Block a user