feat(user): 账号管理(CRUD、Excel 导入、改密)并修掉 5 处缺陷
后端补齐 /users 全套(列表/新增/修改/软删/启停/重置密码/批量导入)与
PUT /auth/password;前端加「组织与账号 → 账号管理」一屏(左组织树 + 右列表、
新增编辑弹窗、导入体检报告、初始密码只显示一次的强确认)。
验收:e2e-m0-user.py 55/55;e2e-m0.py 回归 36/36;typecheck 0 错、build 通过。
账号管理页已在浏览器肉眼确认(首屏与切页均可见、数据与分页正确)。
本轮修掉的 5 个真问题 —— 都不是功能没做完,而是做完了但错的:
1) 学校租户也发放 PLATFORM_ADMIN 角色行
学校管理员在新增账号时传 roleCodes=[PLATFORM_ADMIN] 就能造出带
tenant:manage 的账号,而 /tenants 只校验权限码、不校验是否真在平台租户下
→ 任何学校管理员都能开通租户、拉全平台租户列表(租户级越权到平台级)。
RoleProvisioner 不再给非平台租户发该角色行 + 服务端按租户白名单校验。
2) UserUpsertReq.username 的 @NotBlank 对修改也生效
PUT /users/{id} 永远返回 40001,且明细只说存在阻断性校验问题,
看不出是登录名 —— 改账号功能整体不可用。去掉该注解,
必填性改由 UserService.create 判定。
3) Main.vue 的 <transition appear>
首次渲染被当成一次过渡,元素被加上 zoom-fade-enter-from(opacity:0)后
永不清理 → DOM 内容齐全、接口数据正常、文字可选中,但整块内容区肉眼不可见。
极易误判成页面没渲染而往路由/接口方向排查。去掉 appear。
4) assertAnotherAdminExists 在停用/删除路径上不可达
能调这两个接口的人自己必然是活跃管理员,所以除目标外还有管理员恒成立;
而真正会锁死租户的路径 —— 把管理员改成普通角色(目标可以是操作者自己)——
当时没有拦。补 losingAdminRole() + UserMapper.countAdminRoles(),
并在方法注释里写明哪条路径真生效、哪条只是防御性的。
5) sys_user.user_type 有两套互相冲突的枚举
DDL/实体注释写「1教师/管理员 2填报人(学生) 3社会人员」,而权威契约
(03-接口设计 §2.7)与 BuiltinRole.UserType 是
「1平台运营 2学校管理员 3班主任 4学生」—— 2 和 3 完全错位。
后果:userType=3(班主任,最高频的账号)被当成社会人员(二期)直接拒掉,
建班主任账号会失败;前端还把学校管理员显示成填报人(学生)。
改法:后端从角色反推用户类型再比对;新增 V3 迁移更正库注释
(V1 已应用不能改,Flyway 会校验 checksum);更正 PRD 与 schema DDL 的同一行;
前端「用户类型」从手选改为由角色推导,从根上消除矛盾组合;
E2E 加 3 条回归断言锁住这个语义。
顺带修掉:
· components/index.ts 里 6 个指向已删组件的悬空导出(构建不报错、用到才炸)
· tsconfig 的 lib 停在 ES2020 而代码用了 Array.prototype.at
· 两处 .at() 的空值风险(useBreakpoint / useTabsStore)
· dashboard 的 userStore.menus 拼错,导致平台判断恒为 false
· http.ts 里两个永不命中的错误码常量
· 账号列表的 fixed 列缺 :scroll 配置,会浮在其它列上面
· .gitignore 补 __pycache__
新增脚本:
· scripts/e2e-m0-user.py 账号管理 55 项验收(含越权与自锁的负向断言)
· scripts/dev-clean-test-users.py 清理 E2E 残留的测试账号
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<a-modal v-model:visible="visible" :title="created ? '账号已创建' : title" :width="560"
|
||||
:mask-closable="false" :closable="!created" :footer="false">
|
||||
<!-- ── 成功态:初始密码只显示这一次 ──────────────────── -->
|
||||
<template v-if="created">
|
||||
<a-alert type="success" :show-icon="false">
|
||||
<template #title>
|
||||
<strong>「{{ created.realName }}」的账号已创建。</strong>
|
||||
请把下面的初始密码发给本人。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="user-form__pwd-box">
|
||||
<div class="user-form__pwd-label">初始密码</div>
|
||||
<div class="user-form__pwd-row">
|
||||
<code class="user-form__pwd">{{ created.initialPassword }}</code>
|
||||
<a-button type="primary" size="small" @click="copyPassword">
|
||||
<template #icon><IconCopy /></template>
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
<a-alert type="warning" :show-icon="false" class="user-form__pwd-warn">
|
||||
<strong>这个密码只显示这一次。</strong>
|
||||
关闭窗口后就查不到了 —— 库里存的是不可逆的哈希,忘了只能重新重置。
|
||||
</a-alert>
|
||||
</div>
|
||||
|
||||
<a-descriptions :column="1" size="small">
|
||||
<a-descriptions-item label="登录账号">{{ created.username }}</a-descriptions-item>
|
||||
<a-descriptions-item label="姓名">{{ created.realName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="角色">{{ created.roleNames.join('、') || '—' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="user-form__footer">
|
||||
<a-button type="primary" @click="closeCreated">我已记下密码</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── 表单态 ──────────────────────────────────────── -->
|
||||
<template v-else>
|
||||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="username" label="登录账号">
|
||||
<a-input v-model="form.username" :disabled="isEdit" placeholder="如 zhangsan" allow-clear />
|
||||
<template #extra>
|
||||
<span v-if="isEdit" class="user-form__hint">
|
||||
登录名不可修改:改了会让历史通知、导出包里的账号全对不上。
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="realName" label="姓名">
|
||||
<a-input v-model="form.realName" placeholder="如 张三" :max-length="64" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12">
|
||||
<a-form-item field="phone" label="手机号">
|
||||
<a-input v-model="form.phone" placeholder="11 位手机号" :max-length="11" allow-clear />
|
||||
<template #extra>
|
||||
<span class="user-form__hint">加密存储,列表只显示脱敏值。</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="orgId" label="所属组织">
|
||||
<a-tree-select v-model="form.orgId" :data="orgTree" :field-names="ORG_FIELD_NAMES"
|
||||
placeholder="不选表示不归属任何组织" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-form-item label="用户类型">
|
||||
<a-space>
|
||||
<a-tag v-if="derivedUserType" color="arcoblue">
|
||||
{{ USER_TYPE_NAMES[derivedUserType] ?? derivedUserType }}
|
||||
</a-tag>
|
||||
<span v-else-if="conflictTypes.length > 1" class="user-form__hint">
|
||||
所选角色分属不同类别({{ conflictTypes.join('、') }}),请只选同一类的角色
|
||||
</span>
|
||||
<span v-else class="user-form__hint">选择角色后自动确定</span>
|
||||
</a-space>
|
||||
<template #extra>
|
||||
<span class="user-form__hint">
|
||||
由所选角色自动决定,不需要手选 —— 这样不会出现「学生拿着学校管理员权限」这类账号。
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-form-item field="roleCodes" label="角色">
|
||||
<a-checkbox-group v-model="form.roleCodes">
|
||||
<a-checkbox v-for="r in roleOptions" :key="r.value" :value="r.value">
|
||||
{{ r.label }}
|
||||
</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
<template #extra>
|
||||
<span class="user-form__hint">
|
||||
可多选,权限取并集。内置角色的权限由系统决定,不能在这里改。
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col v-if="isEdit" :span="24">
|
||||
<a-form-item field="status" label="状态">
|
||||
<a-switch v-model="form.status" :checked-value="1" :unchecked-value="0" />
|
||||
<span class="user-form__hint user-form__hint--inline">
|
||||
{{ form.status === 1 ? '正常,可登录' : '停用,不能登录(数据保留)' }}
|
||||
</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<div class="user-form__footer">
|
||||
<a-space>
|
||||
<a-button @click="close">取消</a-button>
|
||||
<a-button type="primary" :loading="submitting" @click="confirm">
|
||||
{{ isEdit ? '保存' : '创建账号' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import type { OrgTreeVO } from '@/apis/xt/org'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import { getOrgTree } from '@/apis/xt/org'
|
||||
import * as userApi from '@/apis/xt/user'
|
||||
import { deriveUserType, PLATFORM_ROLE_OPTIONS, ROLE_TO_USER_TYPE, SCHOOL_ROLE_OPTIONS, USER_TYPE_NAMES } from '@/apis/xt/user'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
/**
|
||||
* 账号新增 / 编辑弹窗。
|
||||
*
|
||||
* 三处值得留意的设计:
|
||||
* 1. **初始密码只显示一次** —— 创建成功后不弹 toast 就完事,而是把弹窗切成成功态,
|
||||
* 停下来让用户当场抄下来。库里是 BCrypt 哈希,关了窗口谁也还原不出来。
|
||||
* 2. **角色选项按租户过滤** —— 平台运营角色只对平台租户显示。学校管理员
|
||||
* 拿到它会直接越权到平台级(能开通租户、看全平台租户列表),
|
||||
* 所以前端不显示、后端也会用 40301 拒绝。
|
||||
* 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定,
|
||||
* 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号,
|
||||
* 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。
|
||||
*/
|
||||
defineOptions({ name: 'UserFormModal' })
|
||||
|
||||
const emit = defineEmits<{ 'save-success': [] }>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
const orgTree = ref<OrgTreeVO[]>([])
|
||||
const created = ref<{ username: string, realName: string, initialPassword: string, roleNames: string[] } | null>(null)
|
||||
|
||||
const formRef = useTemplateRef('formRef')
|
||||
|
||||
const isEdit = computed(() => !!detailId.value)
|
||||
const title = computed(() => (isEdit.value ? '修改账号' : '新增账号'))
|
||||
|
||||
/** 平台租户下才提供平台运营角色 */
|
||||
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
||||
|
||||
const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
|
||||
|
||||
const getInitForm = () => ({
|
||||
username: '',
|
||||
realName: '',
|
||||
phone: '',
|
||||
orgId: undefined as number | undefined,
|
||||
roleCodes: [] as string[],
|
||||
status: 1
|
||||
})
|
||||
|
||||
// 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前
|
||||
const form = reactive(getInitForm())
|
||||
|
||||
const roleOptions = computed(() =>
|
||||
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS
|
||||
)
|
||||
|
||||
/**
|
||||
* 从所选角色推导用户类型。
|
||||
*
|
||||
* 用户类型不让用户手选:它与角色是**一对一绑定**的(见 ROLE_TO_USER_TYPE),
|
||||
* 让用户分别选就会出现"学生 + 学校管理员"这种自相矛盾的账号,
|
||||
* 而服务端还会因此拒掉整个提交。改成推导之后这类组合在界面上根本产生不了。
|
||||
*/
|
||||
const derivedUserType = computed(() => deriveUserType(form.roleCodes))
|
||||
|
||||
/** 所选角色跨越了多个用户类型时的类别名(用于提示为什么不能提交) */
|
||||
const conflictTypes = computed(() => {
|
||||
const names = new Set<string>()
|
||||
for (const c of form.roleCodes) {
|
||||
const t = ROLE_TO_USER_TYPE[c]
|
||||
if (t) names.add(USER_TYPE_NAMES[t] ?? String(t))
|
||||
}
|
||||
return [...names]
|
||||
})
|
||||
|
||||
const rules: FormInstance['rules'] = {
|
||||
username: [
|
||||
{ required: true, message: '请填写登录账号' },
|
||||
{
|
||||
match: /^[A-Z][\w.-]{1,63}$/i,
|
||||
message: '只能以字母开头,可含字母、数字、下划线、点或短横线'
|
||||
}
|
||||
],
|
||||
realName: [{ required: true, message: '请填写姓名' }],
|
||||
phone: [
|
||||
{ required: true, message: '请填写手机号' },
|
||||
{ match: /^1[3-9]\d{9}$/, message: '手机号格式不正确' }
|
||||
],
|
||||
roleCodes: [{ required: true, message: '请至少选择一个角色' }]
|
||||
}
|
||||
|
||||
async function loadOrgTree() {
|
||||
if (orgTree.value.length) return
|
||||
const res = await getOrgTree()
|
||||
orgTree.value = res.data ?? []
|
||||
}
|
||||
|
||||
/** 新增 */
|
||||
async function add() {
|
||||
detailId.value = null
|
||||
created.value = null
|
||||
Object.assign(form, getInitForm())
|
||||
visible.value = true
|
||||
await loadOrgTree()
|
||||
}
|
||||
|
||||
/** 编辑 */
|
||||
async function edit(row: userApi.UserVO) {
|
||||
detailId.value = row.id
|
||||
created.value = null
|
||||
Object.assign(form, getInitForm())
|
||||
form.username = row.username ?? ''
|
||||
form.realName = row.realName
|
||||
// 手机号只回脱敏值,编辑时留空表示"不改手机号"(后端会把空的手机号当作不修改)
|
||||
form.phone = ''
|
||||
form.orgId = row.orgId ?? undefined
|
||||
// 用户类型不在这里赋值:它由 roleCodes 推导,见 derivedUserType
|
||||
form.roleCodes = [...row.roleCodes]
|
||||
form.status = row.status ?? 1
|
||||
visible.value = true
|
||||
await loadOrgTree()
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function closeCreated() {
|
||||
created.value = null
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
async function copyPassword() {
|
||||
const pwd = created.value?.initialPassword
|
||||
if (!pwd) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(pwd)
|
||||
Message.success('已复制到剪贴板')
|
||||
} catch {
|
||||
Message.warning('浏览器不允许自动复制,请手动选中后复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (valid) return
|
||||
|
||||
const phone = form.phone.trim()
|
||||
if (!phone) {
|
||||
// 编辑态也要求重填手机号:列表接口按 §1.7 只返回脱敏值(138****8888),
|
||||
// 拿它当明文回填会把脱敏串写进库里,比"让用户重填一次"糟糕得多。
|
||||
Message.warning('请填写手机号(列表只返回脱敏值,无法回填原号码,需重填)')
|
||||
return
|
||||
}
|
||||
|
||||
const userType = derivedUserType.value
|
||||
if (userType === null) {
|
||||
Message.warning('请选择角色(且需属于同一类),用户类型由它自动确定')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await userApi.updateUser(detailId.value!, {
|
||||
realName: form.realName.trim(),
|
||||
phone,
|
||||
orgId: form.orgId ?? null,
|
||||
roleCodes: form.roleCodes,
|
||||
userType,
|
||||
status: form.status
|
||||
})
|
||||
Message.success('已保存')
|
||||
visible.value = false
|
||||
emit('save-success')
|
||||
return
|
||||
}
|
||||
|
||||
const res = await userApi.addUser({
|
||||
username: form.username.trim(),
|
||||
realName: form.realName.trim(),
|
||||
phone,
|
||||
orgId: form.orgId ?? null,
|
||||
roleCodes: form.roleCodes,
|
||||
userType
|
||||
})
|
||||
const data = res.data
|
||||
if (!data) {
|
||||
Message.error('创建失败,请重试')
|
||||
return
|
||||
}
|
||||
created.value = {
|
||||
username: form.username.trim(),
|
||||
realName: form.realName.trim(),
|
||||
initialPassword: data.initialPassword,
|
||||
roleNames: form.roleCodes.map(
|
||||
(c) => roleOptions.value.find((r) => r.value === c)?.label ?? c
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ add, edit })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-form {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text-3);
|
||||
|
||||
&--inline {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__pwd-box {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
&__pwd-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__pwd-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__pwd {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 18px;
|
||||
letter-spacing: 2px;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__pwd-warn {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user