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:
@@ -40,9 +40,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import type { OrgTreeVO, OrgUpsertReq } from '@/apis/xt/org'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import * as orgApi from '@/apis/xt/org'
|
||||
import type { OrgTreeVO, OrgUpsertReq } from '@/apis/xt/org'
|
||||
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
|
||||
|
||||
/**
|
||||
@@ -55,6 +55,7 @@ import { ORG_TYPE_NAMES } from '@/apis/xt/org'
|
||||
*/
|
||||
defineOptions({ name: 'OrgFormModal' })
|
||||
|
||||
const emit = defineEmits<{ 'save-success': [] }>()
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
@@ -148,8 +149,6 @@ async function confirm() {
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ 'save-success': [] }>()
|
||||
|
||||
defineExpose({ add, edit })
|
||||
</script>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</a-empty>
|
||||
|
||||
<a-tree v-else v-model:selected-keys="selectedKeys" :data="treeData" :field-names="{ key: 'id', title: 'orgName', children: 'children' }"
|
||||
block-node :default-expand-all @select="onSelect">
|
||||
block-node :default-expand-all="true" @select="onSelect">
|
||||
<template #title="node">
|
||||
<span class="org__node">
|
||||
<span class="org__node-name">{{ node.orgName }}</span>
|
||||
@@ -116,10 +116,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OrgTreeVO } from '@/apis/xt/org'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconDelete, IconEdit, IconFolder, IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import * as orgApi from '@/apis/xt/org'
|
||||
import type { OrgTreeVO } from '@/apis/xt/org'
|
||||
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
|
||||
import { useUserStore } from '@/stores'
|
||||
import OrgFormModal from './OrgFormModal.vue'
|
||||
@@ -187,13 +187,22 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function onSelect(keys: (string | number)[], data?: { node: OrgTreeVO }) {
|
||||
/**
|
||||
* 树节点选中。
|
||||
*
|
||||
* 第二个参数的类型要写成「宽进」的 `{ node?: unknown }` 而不是 `{ node: OrgTreeVO }`:
|
||||
* Arco 的 `@select` 回调给的是 `{ selected?, selectedNodes, node?, e? }`,
|
||||
* 把参数声明得比它窄,函数就不可赋值给它,vue-tsc 会在模板上报类型错(运行时其实是好的)。
|
||||
* 这里收窄的动作放在函数体里做。
|
||||
*/
|
||||
function onSelect(keys: (string | number)[], data?: { node?: unknown }) {
|
||||
const id = Number(keys[0] ?? '')
|
||||
if (!id) {
|
||||
selected.value = null
|
||||
return
|
||||
}
|
||||
selected.value = data?.node?.id === id ? data.node : findById(tree.value, id)
|
||||
const node = (data?.node ?? null) as OrgTreeVO | null
|
||||
selected.value = node?.id === id ? node : findById(tree.value, id)
|
||||
}
|
||||
|
||||
function onAdd(parent: OrgTreeVO | null) {
|
||||
@@ -262,8 +271,8 @@ onMounted(load)
|
||||
}
|
||||
|
||||
&__acts {
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
margin-top: 18px;
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user