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 使用同一份答案。
|
真实 PAD 任务的勾选表格、结构化单选/多选、文本与日期项,使用后端 `/v1/sign-tasks/{taskId}/form` 返回的字段定义和规范化正文。每次修改串行保存完整患者答案,使用 `expectedRevision` 防止覆盖其他端的修改;保存失败会阻止签署。系统快照及医护已填项只读。上传签名时带 `signer.answerRevision`,后端完成最终必填校验并固化答案。已签任务和记录详情读取签署快照,下载 PDF 使用同一份答案。
|
||||||
|
|
||||||
旧文书的勾选表格由后端按行生成独立编号;裸方框按独立勾选处理。需要互斥单选时应在模板中定义 `choice` 及选项。创建任务时单选/勾选项留给患者填写,文本项仍可由医护预填。页面显示“填写内容已保存”后可刷新回显。
|
旧文书的勾选表格由后端按行生成独立编号;裸方框按独立勾选处理。需要互斥单选时应在模板中定义 `choice` 及选项。创建任务时单选/勾选项留给患者填写,文本项仍可由医护预填。页面显示“填写内容已保存”后可刷新回显。
|
||||||
|
|
||||||
|
### 左下角账户菜单
|
||||||
|
|
||||||
|
点击头像区域可查看个人信息、修改密码或退出登录。个人资料使用 `GET /v1/auth/me`,联系方式通过 `PATCH /v1/auth/me` 保存,仅更新当前登录用户。手机号和邮箱保持脱敏值时不提交,填写完整值表示替换,空字符串表示清空;`expectedVersion` 防止并发覆盖。基本信息只读,ID 优先展示 `employeeId`,无编号时显示用户 UUID。改密遵循后端 12–128 位强密码策略,成功后清理登录态并重新登录。
|
||||||
|
|||||||
@@ -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,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">
|
<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 { RouterLink, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useLoginStore } from '@/stores/login'
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
|
||||||
const loginStore = useLoginStore()
|
const loginStore = useLoginStore()
|
||||||
const router = useRouter()
|
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
|
||||||
@@ -31,11 +47,15 @@ const visibleMenuGroups = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
|
if (loggingOut.value) return
|
||||||
|
loggingOut.value = true
|
||||||
|
accountMenuOpen.value = false
|
||||||
try {
|
try {
|
||||||
await loginStore.logout()
|
await loginStore.logout()
|
||||||
} catch {
|
} catch {
|
||||||
// 即使服务暂时不可用,也要清理本地会话。
|
// 即使服务暂时不可用,也要清理本地会话。
|
||||||
} finally {
|
} finally {
|
||||||
|
loggingOut.value = false
|
||||||
await router.push('/login')
|
await router.push('/login')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,20 +103,59 @@ async function handleLogout() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="menu-profile">
|
<el-popover
|
||||||
<div class="menu-profile-avatar">{{ loginStore.userName.slice(0, 1) }}</div>
|
v-model:visible="accountMenuOpen"
|
||||||
<div class="menu-profile-copy">
|
trigger="click"
|
||||||
<strong>{{ loginStore.userName }}</strong>
|
placement="top-start"
|
||||||
<span>{{ loginStore.department }}</span>
|
: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>
|
</div>
|
||||||
<button class="menu-logout" type="button" @click="handleLogout">退出</button>
|
</el-popover>
|
||||||
</div>
|
<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);
|
||||||
@@ -142,6 +201,7 @@ async function handleLogout() {
|
|||||||
|
|
||||||
.menu-nav {
|
.menu-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
padding: 12px 10px;
|
padding: 12px 10px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -198,6 +258,10 @@ async function handleLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-profile {
|
.menu-profile {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -220,6 +284,7 @@ async function handleLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-profile-copy {
|
.menu-profile-copy {
|
||||||
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,16 +307,52 @@ async function handleLogout() {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-logout {
|
.menu-profile:hover,
|
||||||
flex-shrink: 0;
|
.menu-profile:focus-visible {
|
||||||
margin-left: auto;
|
background: rgb(255 255 255 / 7%);
|
||||||
padding: 0;
|
|
||||||
color: #8fb3c2;
|
|
||||||
font-size: 11px;
|
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
.menu-profile:focus-visible {
|
||||||
.menu-logout:hover {
|
outline: 2px solid #18a0c0;
|
||||||
color: #fff;
|
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>
|
||||||
|
|||||||
@@ -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
|
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: '密码重置触发限流',
|
||||||
|
|||||||
Reference in New Issue
Block a user