feat(users): 员工表单对齐原型并优化密码策略与错误文案
- 新增胶囊开关组件,账号状态与强制改密改用开关 - 角色由多选改为互斥单选,编辑态多角色给出收敛提示 - 密码策略增加特殊字符要求,规则提示改为短标签横排 - 移除邮箱字段及相关脱敏、清除逻辑 - 接口错误经 toUserMessage 中文化,透传后端校验原因
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 胶囊开关(对齐原型 `.switch`:36×20 胶囊 + 圆形滑块,开启为 --ok)。
|
||||||
|
* 用 button + role="switch" 而不是 checkbox,避免各浏览器对 checkbox 的默认外观差异。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
/** 供读屏使用的名称;视觉标签由调用方提供 */
|
||||||
|
label?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (props.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', !props.modelValue)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="switch"
|
||||||
|
:class="{ 'is-on': modelValue }"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="modelValue"
|
||||||
|
:aria-label="label"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<span class="switch__thumb" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
background: #cbd8de;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch.is-on {
|
||||||
|
background: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch:focus-visible {
|
||||||
|
outline: 2px solid var(--brand);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch__thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: left 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch.is-on .switch__thumb {
|
||||||
|
left: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
import type { ApiResponse } from '@/types/common'
|
import type { ApiResponse } from '@/types/common'
|
||||||
|
|
||||||
export class ApiResponseError extends Error {
|
export class ApiResponseError extends Error {
|
||||||
@@ -12,6 +14,76 @@ export class ApiResponseError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 后端字段校验文案里的字段名 → 界面用词 */
|
||||||
|
const BACKEND_FIELD_LABELS: Record<string, string> = {
|
||||||
|
password: '密码',
|
||||||
|
newPassword: '新密码',
|
||||||
|
username: '登录账号',
|
||||||
|
displayName: '姓名',
|
||||||
|
employeeNo: '工号',
|
||||||
|
position: '岗位',
|
||||||
|
professionalTitle: '职称',
|
||||||
|
phone: '手机号',
|
||||||
|
reason: '重置原因',
|
||||||
|
campusId: '院区',
|
||||||
|
roleIds: '角色',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端的字段校验是 Spring Validation,message 形如
|
||||||
|
* `password: size must be between 8 and 128` / `displayName: must not be blank`(英文技术文案)。
|
||||||
|
* 直接甩给用户不友好,这里只对**已知格式**做中文化;其它文案一律原样返回,不猜。
|
||||||
|
*/
|
||||||
|
function localizeBackendMessage(message: string): string {
|
||||||
|
const text = message.trim()
|
||||||
|
|
||||||
|
const sizeRange = /^(\w+):\s*size must be between (\d+) and (\d+)$/.exec(text)
|
||||||
|
if (sizeRange) {
|
||||||
|
const [, field, min, max] = sizeRange
|
||||||
|
return `${BACKEND_FIELD_LABELS[field] ?? field}长度需在 ${min}–${max} 位之间`
|
||||||
|
}
|
||||||
|
|
||||||
|
const notBlank = /^(\w+):\s*must not be blank$/.exec(text)
|
||||||
|
if (notBlank) {
|
||||||
|
return `请填写${BACKEND_FIELD_LABELS[notBlank[1]] ?? notBlank[1]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把接口错误转成可以直接展示给用户的文案。
|
||||||
|
*
|
||||||
|
* 两处来源都要覆盖:
|
||||||
|
* - 业务错误码(HTTP 200 + code≠0)由 `unwrapApiResponse` 抛成 `ApiResponseError`;
|
||||||
|
* - 参数校验失败是 **HTTP 400**,axios 抛 `AxiosError`,文案在 `response.data.message` 里。
|
||||||
|
* 兜底才用调用方给的 fallback —— 不要把后端给的原因吞掉换成"操作失败,请稍后重试"。
|
||||||
|
*/
|
||||||
|
export function toUserMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof ApiResponseError) {
|
||||||
|
return error.message ? localizeBackendMessage(error.message) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const backendMessage = (error.response?.data as { message?: unknown } | undefined)?.message
|
||||||
|
if (typeof backendMessage === 'string' && backendMessage) {
|
||||||
|
return localizeBackendMessage(backendMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error.response) {
|
||||||
|
return '网络异常,请检查网络后重试'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${fallback}(HTTP ${error.response.status})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
export function isSuccessCode(code: number | string) {
|
export function isSuccessCode(code: number | string) {
|
||||||
return code === 0 || code === '0'
|
return code === 0 || code === '0'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
* 员工账号密码策略。
|
* 员工账号密码策略。
|
||||||
*
|
*
|
||||||
* 组织架构的「新增员工」与「重置密码」共用同一套规则,
|
* 组织架构的「新增员工」与「重置密码」共用同一套规则,
|
||||||
* 避免两处各写一份导致口径分叉:长度不少于 12 位,且同时包含大写和小写字母。
|
* 避免两处各写一份导致口径分叉:长度不少于 12 位,且同时包含大写字母、小写字母、特殊字符。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 密码最小长度 */
|
/** 密码最小长度 */
|
||||||
export const PASSWORD_MIN_LENGTH = 12
|
export const PASSWORD_MIN_LENGTH = 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特殊字符:常见 ASCII 可见符号。
|
||||||
|
* 用显式符号集而不是 `[^A-Za-z0-9]`,避免把中文、emoji、空白也算成「特殊字符」。
|
||||||
|
*/
|
||||||
|
const SPECIAL_CHAR_PATTERN = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?~`]/
|
||||||
|
|
||||||
export interface PasswordPolicyChecks {
|
export interface PasswordPolicyChecks {
|
||||||
/** 长度是否达到最小值 */
|
/** 长度是否达到最小值 */
|
||||||
minLength: boolean
|
minLength: boolean
|
||||||
@@ -15,6 +21,8 @@ export interface PasswordPolicyChecks {
|
|||||||
hasUppercase: boolean
|
hasUppercase: boolean
|
||||||
/** 是否包含小写字母 */
|
/** 是否包含小写字母 */
|
||||||
hasLowercase: boolean
|
hasLowercase: boolean
|
||||||
|
/** 是否包含特殊字符 */
|
||||||
|
hasSpecialChar: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordPolicyRule {
|
export interface PasswordPolicyRule {
|
||||||
@@ -22,11 +30,12 @@ export interface PasswordPolicyRule {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 规则清单,供表单实时展示校验进度(顺序即提示顺序) */
|
/** 规则清单,供表单实时展示校验进度(顺序即提示顺序;文案取短版,保证半列宽也能横排一行) */
|
||||||
export const PASSWORD_POLICY_RULES: PasswordPolicyRule[] = [
|
export const PASSWORD_POLICY_RULES: PasswordPolicyRule[] = [
|
||||||
{ key: 'minLength', label: `长度不少于 ${PASSWORD_MIN_LENGTH} 位` },
|
{ key: 'minLength', label: `≥ ${PASSWORD_MIN_LENGTH} 位` },
|
||||||
{ key: 'hasUppercase', label: '包含大写字母 A-Z' },
|
{ key: 'hasUppercase', label: '含大写' },
|
||||||
{ key: 'hasLowercase', label: '包含小写字母 a-z' },
|
{ key: 'hasLowercase', label: '含小写' },
|
||||||
|
{ key: 'hasSpecialChar', label: '含符号' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 逐条计算密码是否满足策略,返回每个规则的通过情况 */
|
/** 逐条计算密码是否满足策略,返回每个规则的通过情况 */
|
||||||
@@ -35,13 +44,14 @@ export function checkPasswordPolicy(password: string): PasswordPolicyChecks {
|
|||||||
minLength: password.length >= PASSWORD_MIN_LENGTH,
|
minLength: password.length >= PASSWORD_MIN_LENGTH,
|
||||||
hasUppercase: /[A-Z]/.test(password),
|
hasUppercase: /[A-Z]/.test(password),
|
||||||
hasLowercase: /[a-z]/.test(password),
|
hasLowercase: /[a-z]/.test(password),
|
||||||
|
hasSpecialChar: SPECIAL_CHAR_PATTERN.test(password),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 密码是否完全满足策略 */
|
/** 密码是否完全满足策略 */
|
||||||
export function isPasswordPolicySatisfied(password: string): boolean {
|
export function isPasswordPolicySatisfied(password: string): boolean {
|
||||||
const checks = checkPasswordPolicy(password)
|
const checks = checkPasswordPolicy(password)
|
||||||
return checks.minLength && checks.hasUppercase && checks.hasLowercase
|
return checks.minLength && checks.hasUppercase && checks.hasLowercase && checks.hasSpecialChar
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 返回第一条未满足的规则提示,全部满足时返回 null */
|
/** 返回第一条未满足的规则提示,全部满足时返回 null */
|
||||||
@@ -50,5 +60,6 @@ export function firstPasswordPolicyIssue(password: string): string | null {
|
|||||||
const checks = checkPasswordPolicy(password)
|
const checks = checkPasswordPolicy(password)
|
||||||
if (!checks.minLength) return `密码长度不少于 ${PASSWORD_MIN_LENGTH} 位`
|
if (!checks.minLength) return `密码长度不少于 ${PASSWORD_MIN_LENGTH} 位`
|
||||||
if (!checks.hasUppercase || !checks.hasLowercase) return '密码必须同时包含大写和小写字母'
|
if (!checks.hasUppercase || !checks.hasLowercase) return '密码必须同时包含大写和小写字母'
|
||||||
|
if (!checks.hasSpecialChar) return '密码必须包含特殊字符'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ const checks = computed(() => checkPasswordPolicy(props.password))
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.password-hints {
|
.password-hints {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 3px;
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 14px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--mut);
|
color: var(--mut);
|
||||||
@@ -34,8 +35,9 @@ const checks = computed(() => checkPasswordPolicy(props.password))
|
|||||||
|
|
||||||
.password-hints li {
|
.password-hints li {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-hints__mark {
|
.password-hints__mark {
|
||||||
@@ -44,6 +46,6 @@ const checks = computed(() => checkPasswordPolicy(props.password))
|
|||||||
|
|
||||||
.password-hints li.is-ok,
|
.password-hints li.is-ok,
|
||||||
.password-hints li.is-ok .password-hints__mark {
|
.password-hints li.is-ok .password-hints__mark {
|
||||||
color: var(--brand);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, reactive, watch } from 'vue'
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
|
||||||
import type { CampusRecord, DepartmentRecord, RoleRecord } from '@/api/management/types'
|
import type { CampusRecord, DepartmentRecord, RoleRecord } from '@/api/management/types'
|
||||||
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||||
import {
|
import {
|
||||||
PASSWORD_MIN_LENGTH,
|
PASSWORD_MIN_LENGTH,
|
||||||
firstPasswordPolicyIssue,
|
firstPasswordPolicyIssue,
|
||||||
@@ -36,6 +37,41 @@ const availableDepartments = computed(() =>
|
|||||||
props.departments.filter((department) => !form.campusId || department.campusId === form.campusId),
|
props.departments.filter((department) => !form.campusId || department.campusId === form.campusId),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号状态用两态胶囊开关(对齐原型),但数据模型里还有后端下发的 LOCKED。
|
||||||
|
* get 只在 ENABLED 时置开;用户不碰开关时 set 不会被调用,LOCKED 原样保留提交,
|
||||||
|
* 避免"编辑一个已锁定员工顺手把它改回启用"。
|
||||||
|
*/
|
||||||
|
const statusEnabled = computed({
|
||||||
|
get: () => form.status === 'enabled',
|
||||||
|
set: (value: boolean) => {
|
||||||
|
form.status = value ? 'enabled' : 'disabled'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 表单里该字段是可选的,开关组件要求严格 boolean,这里做一层桥接 */
|
||||||
|
const forcePasswordChange = computed({
|
||||||
|
get: () => form.passwordChangeRequired ?? false,
|
||||||
|
set: (value: boolean) => {
|
||||||
|
form.passwordChangeRequired = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色互斥单选(对齐原型 `type:'radio'`)。
|
||||||
|
* 接口 `PUT /v1/users/{id}/roles` 收的是 `roleIds` 数组,这里用「数组首元素」桥接,
|
||||||
|
* 单选后数组长度恒为 0 或 1,API 层无需改动。
|
||||||
|
*/
|
||||||
|
const selectedRoleId = computed({
|
||||||
|
get: () => form.roleIds[0] ?? '',
|
||||||
|
set: (value: string) => {
|
||||||
|
form.roleIds = value ? [value] : []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 编辑态且原本绑了多个角色:单选保存会收敛成一个,界面上得说清楚,避免静默丢角色 */
|
||||||
|
const hadMultipleRoles = computed(() => isEditing.value && form.roleIds.length > 1)
|
||||||
|
|
||||||
function createEmptyForm(): UserEditorForm {
|
function createEmptyForm(): UserEditorForm {
|
||||||
return {
|
return {
|
||||||
username: '',
|
username: '',
|
||||||
@@ -45,14 +81,12 @@ function createEmptyForm(): UserEditorForm {
|
|||||||
position: '',
|
position: '',
|
||||||
professionalTitle: '',
|
professionalTitle: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
email: '',
|
|
||||||
campusId: props.defaultCampusId ?? props.campuses[0]?.id ?? '',
|
campusId: props.defaultCampusId ?? props.campuses[0]?.id ?? '',
|
||||||
departmentId: props.defaultDepartmentId ?? '',
|
departmentId: props.defaultDepartmentId ?? '',
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
roleIds: [],
|
roleIds: [],
|
||||||
passwordChangeRequired: true,
|
passwordChangeRequired: true,
|
||||||
clearPhone: false,
|
clearPhone: false,
|
||||||
clearEmail: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,11 +121,6 @@ function markPhoneEdited() {
|
|||||||
form.clearPhone = !form.phone
|
form.clearPhone = !form.phone
|
||||||
}
|
}
|
||||||
|
|
||||||
function markEmailEdited() {
|
|
||||||
form.emailMasked = false
|
|
||||||
form.clearEmail = !form.email
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
error.message = ''
|
error.message = ''
|
||||||
|
|
||||||
@@ -140,7 +169,6 @@ function submit() {
|
|||||||
displayName: form.displayName.trim(),
|
displayName: form.displayName.trim(),
|
||||||
employeeNo: form.employeeNo.trim(),
|
employeeNo: form.employeeNo.trim(),
|
||||||
phone: form.phone.trim(),
|
phone: form.phone.trim(),
|
||||||
email: form.email.trim(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,29 +223,10 @@ watch(
|
|||||||
<span>系统用户 ID</span>
|
<span>系统用户 ID</span>
|
||||||
<input :value="isEditing ? (form.employeeId ?? '—') : '保存后自动生成'" disabled />
|
<input :value="isEditing ? (form.employeeId ?? '—') : '保存后自动生成'" disabled />
|
||||||
</label>
|
</label>
|
||||||
<label v-if="!isEditing">
|
|
||||||
<span>登录账号 <em>*</em></span>
|
|
||||||
<input v-model="form.username" autocomplete="username" placeholder="如:zhangsan" />
|
|
||||||
</label>
|
|
||||||
<label v-else>
|
|
||||||
<span>登录账号</span>
|
|
||||||
<input :value="form.username" disabled />
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
<span>姓名 <em>*</em></span>
|
<span>姓名 <em>*</em></span>
|
||||||
<input v-model="form.displayName" autocomplete="name" placeholder="请输入姓名" />
|
<input v-model="form.displayName" autocomplete="name" placeholder="请输入姓名" />
|
||||||
</label>
|
</label>
|
||||||
<label v-if="!isEditing">
|
|
||||||
<span>初始密码 <em>*</em></span>
|
|
||||||
<input
|
|
||||||
v-model="form.password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
:minlength="PASSWORD_MIN_LENGTH"
|
|
||||||
placeholder="请输入初始密码"
|
|
||||||
/>
|
|
||||||
<PasswordPolicyHints :password="form.password" />
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
<span>工号 <em>*</em></span>
|
<span>工号 <em>*</em></span>
|
||||||
<input v-model="form.employeeNo" :disabled="isEditing" placeholder="请输入工号" />
|
<input v-model="form.employeeNo" :disabled="isEditing" placeholder="请输入工号" />
|
||||||
@@ -241,16 +250,6 @@ watch(
|
|||||||
@input="markPhoneEdited"
|
@input="markPhoneEdited"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
|
||||||
<span>邮箱</span>
|
|
||||||
<input
|
|
||||||
v-model="form.email"
|
|
||||||
type="email"
|
|
||||||
autocomplete="email"
|
|
||||||
placeholder="请输入邮箱"
|
|
||||||
@input="markEmailEdited"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div class="form-section">② 角色(权限和数据范围由角色统一配置)</div>
|
<div class="form-section">② 角色(权限和数据范围由角色统一配置)</div>
|
||||||
<label>
|
<label>
|
||||||
<span>院区 <em>*</em></span>
|
<span>院区 <em>*</em></span>
|
||||||
@@ -274,17 +273,23 @@ watch(
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label v-if="canAssignRoles" class="form-grid__full">
|
<div v-if="canAssignRoles" class="form-grid__full role-field">
|
||||||
<span>角色(可多选)</span>
|
<span>角色</span>
|
||||||
<select v-model="form.roleIds" multiple size="4">
|
<div class="role-options">
|
||||||
<option v-for="role in roles" :key="role.id" :value="role.id">
|
<label v-for="role in roles" :key="role.id" class="role-option">
|
||||||
{{ role.name }}({{ role.code }}){{
|
<input v-model="selectedRoleId" type="radio" name="user-role" :value="role.id" />
|
||||||
role.status === 'DISABLED' ? '(已停用)' : ''
|
<span>
|
||||||
}}
|
{{ role.name }}({{ role.code }}){{
|
||||||
</option>
|
role.status === 'DISABLED' ? '(已停用)' : ''
|
||||||
</select>
|
}}
|
||||||
<small>多个角色的菜单、操作与数据权限按系统规则合并。</small>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
<small v-if="hadMultipleRoles" class="role-warning">
|
||||||
|
该员工当前绑定 {{ form.roleIds.length }} 个角色,改为互斥单选后保存将只保留所选角色。
|
||||||
|
</small>
|
||||||
|
<small v-else>角色的菜单、操作与数据权限互斥,一个员工只能绑定一个角色。</small>
|
||||||
|
</div>
|
||||||
<label v-else class="form-grid__full">
|
<label v-else class="form-grid__full">
|
||||||
<span>已绑定角色</span>
|
<span>已绑定角色</span>
|
||||||
<input :value="form.roleLabels?.join('、') || '未分配角色'" disabled />
|
<input :value="form.roleLabels?.join('、') || '未分配角色'" disabled />
|
||||||
@@ -292,25 +297,42 @@ watch(
|
|||||||
</label>
|
</label>
|
||||||
<div class="form-section">③ 账号与安全</div>
|
<div class="form-section">③ 账号与安全</div>
|
||||||
<label>
|
<label>
|
||||||
<span>状态</span>
|
<span>登录账号 <em>*</em></span>
|
||||||
<select v-model="form.status">
|
<input
|
||||||
<option value="enabled">启用</option>
|
v-if="!isEditing"
|
||||||
<option value="disabled">停用</option>
|
v-model="form.username"
|
||||||
<option value="locked">锁定</option>
|
autocomplete="username"
|
||||||
</select>
|
placeholder="如:zhangsan"
|
||||||
|
/>
|
||||||
|
<input v-else :value="form.username" disabled />
|
||||||
</label>
|
</label>
|
||||||
<label v-if="!isEditing" class="checkbox-field">
|
<label v-if="!isEditing">
|
||||||
<input v-model="form.passwordChangeRequired" type="checkbox" />
|
<span>初始密码 <em>*</em></span>
|
||||||
<span>首次登录强制修改密码</span>
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:minlength="PASSWORD_MIN_LENGTH"
|
||||||
|
placeholder="请输入初始密码"
|
||||||
|
/>
|
||||||
|
<PasswordPolicyHints :password="form.password" />
|
||||||
</label>
|
</label>
|
||||||
<label v-else>
|
<label>
|
||||||
<span>首次登录强制修改密码</span>
|
<span>首次登录强制修改密码</span>
|
||||||
<input :value="form.passwordChangeRequired ? '是' : '否'" disabled />
|
<ToggleSwitch
|
||||||
|
v-model="forcePasswordChange"
|
||||||
|
:disabled="isEditing"
|
||||||
|
label="首次登录强制修改密码"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>账号状态</span>
|
||||||
|
<ToggleSwitch v-model="statusEnabled" label="账号状态" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||||||
<p class="form-tip">
|
<p class="form-tip">
|
||||||
接口返回的手机号和邮箱可能已脱敏,未重新填写的脱敏字段不会提交;清空已保存联系方式会明确提交清除操作。
|
接口返回的手机号可能已脱敏,未重新填写的脱敏字段不会提交;清空已保存的联系方式会明确提交清除操作。
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -444,8 +466,51 @@ watch(
|
|||||||
background: #f5f8f9;
|
background: #f5f8f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid select[multiple] {
|
.role-field {
|
||||||
min-height: 94px;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-field > span {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 16px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-field .role-option {
|
||||||
|
display: flex;
|
||||||
|
/* 必须显式声明:.form-grid label 的 flex-direction:column 同样命中这里的 label,
|
||||||
|
只写 display:flex 的话方向仍是 column,radio 和文字会各占一行 */
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-field .role-option > span {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-field .role-option input {
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-field .role-warning {
|
||||||
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid small {
|
.form-grid small {
|
||||||
@@ -455,18 +520,6 @@ watch(
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-field {
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: row !important;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px !important;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-field input {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
color: var(--err);
|
color: var(--err);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
} from '@/api/management/types'
|
} from '@/api/management/types'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useLoginStore } from '@/stores/login'
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
import { toUserMessage } from '@/utils/api-response'
|
||||||
|
|
||||||
import OrganizationNodeDialog, {
|
import OrganizationNodeDialog, {
|
||||||
type OrganizationNodeSave,
|
type OrganizationNodeSave,
|
||||||
@@ -258,7 +259,6 @@ function mapUserRow(
|
|||||||
position: user.position,
|
position: user.position,
|
||||||
professionalTitle: user.professionalTitle,
|
professionalTitle: user.professionalTitle,
|
||||||
phone: user.phone ?? '',
|
phone: user.phone ?? '',
|
||||||
email: user.email ?? '',
|
|
||||||
campus: campusNames.get(user.campusId ?? '') ?? user.campusName ?? '未指定院区',
|
campus: campusNames.get(user.campusId ?? '') ?? user.campusName ?? '未指定院区',
|
||||||
campusId: user.campusId,
|
campusId: user.campusId,
|
||||||
department: departmentNames.get(user.departmentId ?? '') ?? user.department ?? '未指定科室',
|
department: departmentNames.get(user.departmentId ?? '') ?? user.department ?? '未指定科室',
|
||||||
@@ -426,8 +426,6 @@ async function editUser(row: UserTableRow) {
|
|||||||
professionalTitle: user.professionalTitle ?? '',
|
professionalTitle: user.professionalTitle ?? '',
|
||||||
phone: user.phone ?? '',
|
phone: user.phone ?? '',
|
||||||
phoneMasked: masked(user.phone),
|
phoneMasked: masked(user.phone),
|
||||||
email: user.email ?? '',
|
|
||||||
emailMasked: masked(user.email),
|
|
||||||
campusId: user.campusId ?? row.campusId ?? '',
|
campusId: user.campusId ?? row.campusId ?? '',
|
||||||
departmentId: user.departmentId ?? row.departmentId ?? '',
|
departmentId: user.departmentId ?? row.departmentId ?? '',
|
||||||
status: user.status,
|
status: user.status,
|
||||||
@@ -435,12 +433,11 @@ async function editUser(row: UserTableRow) {
|
|||||||
roleLabels: user.roleNames ?? row.roleNames ?? [],
|
roleLabels: user.roleNames ?? row.roleNames ?? [],
|
||||||
passwordChangeRequired: user.passwordChangeRequired ?? false,
|
passwordChangeRequired: user.passwordChangeRequired ?? false,
|
||||||
clearPhone: false,
|
clearPhone: false,
|
||||||
clearEmail: false,
|
|
||||||
version: user.version,
|
version: user.version,
|
||||||
}
|
}
|
||||||
editorVisible.value = true
|
editorVisible.value = true
|
||||||
} catch {
|
} catch (error) {
|
||||||
ElMessage.error('员工详情加载失败,请稍后重试')
|
ElMessage.error(toUserMessage(error, '员工详情加载失败,请稍后重试'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,8 +465,8 @@ async function handlePasswordReset(payload: {
|
|||||||
passwordResetVisible.value = false
|
passwordResetVisible.value = false
|
||||||
passwordResetTarget.value = null
|
passwordResetTarget.value = null
|
||||||
ElMessage.success(`员工“${target.name}”的密码已重置`)
|
ElMessage.success(`员工“${target.name}”的密码已重置`)
|
||||||
} catch {
|
} catch (error) {
|
||||||
ElMessage.error('密码重置失败,请稍后重试')
|
ElMessage.error(toUserMessage(error, '密码重置失败,请稍后重试'))
|
||||||
} finally {
|
} finally {
|
||||||
passwordResetSaving.value = false
|
passwordResetSaving.value = false
|
||||||
}
|
}
|
||||||
@@ -538,7 +535,6 @@ async function saveUser(form: UserEditorForm) {
|
|||||||
position: form.position,
|
position: form.position,
|
||||||
professionalTitle: form.professionalTitle,
|
professionalTitle: form.professionalTitle,
|
||||||
phone: form.phone,
|
phone: form.phone,
|
||||||
email: form.email,
|
|
||||||
campus: campuses.value.find((campus) => campus.id === form.campusId)?.name ?? '未指定院区',
|
campus: campuses.value.find((campus) => campus.id === form.campusId)?.name ?? '未指定院区',
|
||||||
campusId: form.campusId,
|
campusId: form.campusId,
|
||||||
department:
|
department:
|
||||||
@@ -570,11 +566,6 @@ async function saveUser(form: UserEditorForm) {
|
|||||||
: form.clearPhone
|
: form.clearPhone
|
||||||
? { clearPhone: true }
|
? { clearPhone: true }
|
||||||
: { phone: form.phone || undefined }),
|
: { phone: form.phone || undefined }),
|
||||||
...(form.emailMasked
|
|
||||||
? {}
|
|
||||||
: form.clearEmail
|
|
||||||
? { clearEmail: true }
|
|
||||||
: { email: form.email || undefined }),
|
|
||||||
campusId: form.campusId,
|
campusId: form.campusId,
|
||||||
departmentId: form.departmentId || null,
|
departmentId: form.departmentId || null,
|
||||||
status: toBackendUserStatus(form.status),
|
status: toBackendUserStatus(form.status),
|
||||||
@@ -614,8 +605,10 @@ async function saveUser(form: UserEditorForm) {
|
|||||||
} else {
|
} else {
|
||||||
ElMessage.success(form.id ? '员工资料已更新' : '员工已创建')
|
ElMessage.success(form.id ? '员工资料已更新' : '员工已创建')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
ElMessage.error(form.id ? '员工更新失败,请稍后重试' : '员工创建失败,请稍后重试')
|
ElMessage.error(
|
||||||
|
toUserMessage(error, form.id ? '员工更新失败,请稍后重试' : '员工创建失败,请稍后重试'),
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
editorSaving.value = false
|
editorSaving.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export interface UserTableRow {
|
|||||||
position?: string | null
|
position?: string | null
|
||||||
professionalTitle?: string | null
|
professionalTitle?: string | null
|
||||||
phone?: string
|
phone?: string
|
||||||
email?: string
|
|
||||||
campus: string
|
campus: string
|
||||||
campusId?: string | null
|
campusId?: string | null
|
||||||
department: string
|
department: string
|
||||||
@@ -34,8 +33,6 @@ export interface UserEditorForm {
|
|||||||
professionalTitle: string
|
professionalTitle: string
|
||||||
phone: string
|
phone: string
|
||||||
phoneMasked?: boolean
|
phoneMasked?: boolean
|
||||||
email: string
|
|
||||||
emailMasked?: boolean
|
|
||||||
campusId: string
|
campusId: string
|
||||||
departmentId: string
|
departmentId: string
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
@@ -43,6 +40,5 @@ export interface UserEditorForm {
|
|||||||
roleLabels?: string[]
|
roleLabels?: string[]
|
||||||
passwordChangeRequired?: boolean
|
passwordChangeRequired?: boolean
|
||||||
clearPhone?: boolean
|
clearPhone?: boolean
|
||||||
clearEmail?: boolean
|
|
||||||
version?: number
|
version?: number
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user