diff --git a/clinical-web/README.md b/clinical-web/README.md index 9b87d1c..f619be8 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -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;UUID 仅用于内部关联和接口请求。改密遵循后端 12–128 位强密码策略,成功后清理登录态并重新登录。 diff --git a/clinical-web/src/api/auth.ts b/clinical-web/src/api/auth.ts index dd40b20..71bf8e5 100644 --- a/clinical-web/src/api/auth.ts +++ b/clinical-web/src/api/auth.ts @@ -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 { + const response = await request.request>({ + url: '/v1/auth/me', + method: 'PATCH', + data: payload, + }) + return unwrapApiResponse(response) +} diff --git a/clinical-web/src/components/layout/AccountPanel.vue b/clinical-web/src/components/layout/AccountPanel.vue new file mode 100644 index 0000000..d4bd09e --- /dev/null +++ b/clinical-web/src/components/layout/AccountPanel.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/clinical-web/src/components/layout/Menu.vue b/clinical-web/src/components/layout/Menu.vue index f71e8a7..7b3a1f5 100644 --- a/clinical-web/src/components/layout/Menu.vue +++ b/clinical-web/src/components/layout/Menu.vue @@ -1,11 +1,27 @@ diff --git a/clinical-web/src/components/layout/ProfileDialog.vue b/clinical-web/src/components/layout/ProfileDialog.vue deleted file mode 100644 index 3dc76a2..0000000 --- a/clinical-web/src/components/layout/ProfileDialog.vue +++ /dev/null @@ -1,155 +0,0 @@ - - - - - diff --git a/clinical-web/src/components/layout/UserMenu.vue b/clinical-web/src/components/layout/UserMenu.vue deleted file mode 100644 index cc2f426..0000000 --- a/clinical-web/src/components/layout/UserMenu.vue +++ /dev/null @@ -1,280 +0,0 @@ - - - - - diff --git a/clinical-web/src/composables/useAccountPanel.ts b/clinical-web/src/composables/useAccountPanel.ts new file mode 100644 index 0000000..cffd5de --- /dev/null +++ b/clinical-web/src/composables/useAccountPanel.ts @@ -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(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 } +} diff --git a/clinical-web/src/types/auth.ts b/clinical-web/src/types/auth.ts index ec4f211..7719313 100644 --- a/clinical-web/src/types/auth.ts +++ b/clinical-web/src/types/auth.ts @@ -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 +} diff --git a/clinical-web/src/views/management/audit/index.vue b/clinical-web/src/views/management/audit/index.vue index c548d60..47e1785 100644 --- a/clinical-web/src/views/management/audit/index.vue +++ b/clinical-web/src/views/management/audit/index.vue @@ -47,6 +47,7 @@ const actionLabels: Record = { DISABLE: '停用', ENABLE: '启用', PASSWORD_CHANGE: '修改密码', + PROFILE_UPDATE: '修改本人联系方式', PASSWORD_RESET: '重置密码', PASSWORD_RESET_REJECTED: '密码重置被拒绝', PASSWORD_RESET_RATE_LIMITED: '密码重置触发限流',