Files
xiaotutongxue/frontend-pc/src/views/user/UserFormModal.vue
T
lovife 8480f431ba 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 残留的测试账号
2026-09-28 22:25:01 +08:00

392 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>