Merge remote-tracking branch 'origin/master'
# Conflicts: # clinical-web/src/components/layout/Menu.vue
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;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,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>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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