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:
2026-09-28 22:25:01 +08:00
parent 87ece5a971
commit 8480f431ba
52 changed files with 4264 additions and 122 deletions
+8 -3
View File
@@ -46,12 +46,17 @@ const menu = computed(() => findMenu((route.meta.menu as string) ?? ''))
/** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */
const MILESTONES: Record<string, string> = {
task: 'M1', form: 'M1', material: 'M1',
roster: 'M2', join: 'M2',
task: 'M1',
form: 'M1',
material: 'M1',
roster: 'M2',
join: 'M2',
cleanup: 'M3',
audit: 'M5',
export: 'M6',
user: 'M0 剩余', role: '二期', log: 'M0 剩余'
user: 'M0 剩余',
role: '二期',
log: 'M0 剩余'
}
const milestone = computed(() => MILESTONES[menu.value?.key ?? ''] ?? '规划中')
+6 -4
View File
@@ -74,11 +74,11 @@
</template>
<script setup lang="ts">
import { IconCheckCircle, IconFolder, IconIdcard, IconSafe, IconStamp } from '@arco-design/web-vue/es/icon'
import type { OrgTreeVO } from '@/apis/xt/org'
import { IconFolder, IconIdcard, IconSafe, IconStamp } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org'
import * as tenantApi from '@/apis/xt/tenant'
import type { OrgTreeVO } from '@/apis/xt/org'
import { XT_MENUS, ROLE_NAMES } from '@/config/xt-routes'
import { ROLE_NAMES, XT_MENUS } from '@/config/xt-routes'
import { useUserStore } from '@/stores'
/**
@@ -101,7 +101,9 @@ const avatarText = computed(() => (userStore.name || '—').slice(0, 1))
const roleLabel = computed(() =>
userStore.roles.map((c) => ROLE_NAMES[c] ?? c).join(' / ')
)
const isPlatform = computed(() => userStore.menus?.includes('tenant') ?? false)
// 注意是 menuKeys 而不是 menus —— 写错不会报错,
// 只会让这个判断恒为 false(之前就是这样:平台管理员也看到"仅平台运营可见")
const isPlatform = computed(() => userStore.menuKeys?.includes('tenant') ?? false)
/** 已实现且当前角色可访问的页面 */
const shortcuts = computed(() =>
@@ -267,7 +267,6 @@ const features = [
}
@keyframes login-float {
0%,
100% {
transform: translateY(0);
+3 -4
View File
@@ -104,10 +104,10 @@
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import { Message } from '@arco-design/web-vue'
import { encryptPassword, fetchPublicKey, resetPublicKey } from '@/utils/rsa'
import { getCaptcha } from '@/apis/xt'
import { useLoading } from '@/hooks'
import { useTabsStore, useUserStore } from '@/stores'
import { encryptPassword, fetchPublicKey, resetPublicKey } from '@/utils/rsa'
import LoginLeft from './LoginLeft.vue'
import LoginPalette from './LoginPalette.vue'
@@ -128,7 +128,6 @@ defineOptions({ name: 'Login' })
const router = useRouter()
const userStore = useUserStore()
const tabsStore = useTabsStore()
const year = new Date().getFullYear()
const form = reactive({
username: '',
@@ -364,8 +363,8 @@ onMounted(() => {
&__captcha {
display: flex;
gap: 10px;
width: 100%;
align-items: center;
width: 100%;
}
&__captcha-img {
@@ -405,7 +404,7 @@ onMounted(() => {
border-top: 1px solid var(--color-border-2);
}
@media (max-width: 768px) {
@media (width <= 768px) {
&__mobile-brand {
display: flex;
}
+2 -3
View File
@@ -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>
+14 -5
View File
@@ -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);
}
}
+1 -1
View File
@@ -173,10 +173,10 @@
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import type { TenantCreateResult, TenantVO } from '@/apis/xt/tenant'
import { Message } from '@arco-design/web-vue'
import { IconCopy, IconPlus } from '@arco-design/web-vue/es/icon'
import * as tenantApi from '@/apis/xt/tenant'
import type { TenantCreateResult, TenantVO } from '@/apis/xt/tenant'
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
/**
@@ -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>
@@ -0,0 +1,381 @@
<template>
<a-modal v-model:visible="visible" :title="report ? '导入结果' : '批量导入账号'" :width="report ? 760 : 560"
:mask-closable="false" :footer="false" @cancel="close">
<!-- ── 第一步:选文件 ────────────────────────────── -->
<template v-if="!report">
<a-alert type="info" :show-icon="false" class="user-import__intro">
<template #title>
表格里需要有 <strong>姓名</strong> 与 <strong>账号</strong> 两列,
<strong>手机号</strong> 必填;<strong>组织</strong>、<strong>角色</strong> 可选。
表头在第几行<b>不用管</b>,系统会自己找。
</template>
</a-alert>
<a-form :model="formModel" layout="vertical">
<a-form-item label="Excel 文件">
<div class="user-import__pick">
<!--
这里用原生 input 而不是 a-upload:
a-upload 的 auto-upload=false 时,拿原始 File 对象要经由 fileItem.file,
各版本字段名不尽相同;而本场景只需要"选一个本地文件然后自己发请求",
原生 input 的行为是完全确定的,少一层不确定性。
-->
<input ref="fileInput" type="file" accept=".xlsx,.xls" class="user-import__file"
@change="onFileChange" />
<a-button @click="pickFile">
<template #icon><IconUpload /></template>
选择文件
</a-button>
<span v-if="file" class="user-import__filename">{{ file.name }}</span>
<span v-else class="user-import__hint">支持 .xlsx / .xls,不超过 10MB</span>
</div>
</a-form-item>
<a-form-item label="没有「角色」列时的默认角色">
<a-select v-model="formModel.defaultRoleCode" class="user-import__role">
<a-option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</a-option>
</a-select>
<template #extra>
<span class="user-import__hint">
表格里有「角色」列时以列为准;那一行没填角色才用这里的默认值。
</span>
</template>
</a-form-item>
</a-form>
<div class="user-import__footer">
<a-space>
<a-button @click="close">取消</a-button>
<a-button type="primary" :loading="submitting" :disabled="!file" @click="submit">
开始导入
</a-button>
</a-space>
</div>
</template>
<!-- ── 第二步:体检报告 ─────────────────────────── -->
<template v-else>
<a-row :gutter="12" class="user-import__stats">
<a-col :span="8">
<div class="user-import__stat">
<div class="user-import__stat-num">{{ report.total }}</div>
<div class="user-import__stat-label">参与导入的行数</div>
</div>
</a-col>
<a-col :span="8">
<div class="user-import__stat user-import__stat--ok">
<div class="user-import__stat-num">{{ report.success }}</div>
<div class="user-import__stat-label">成功创建</div>
</div>
</a-col>
<a-col :span="8">
<div class="user-import__stat" :class="report.failed ? 'user-import__stat--bad' : ''">
<div class="user-import__stat-num">{{ report.failed }}</div>
<div class="user-import__stat-label">未能导入</div>
</div>
</a-col>
</a-row>
<!-- 表头识别结果:行号 + 原文 + 列映射,三样一起给才核对得动 -->
<a-alert :type="report.errors.some((e) => e.field === 'realName' || e.field === 'username') ? 'warning' : 'success'"
:show-icon="false" class="user-import__header-alert">
<template #title>
系统把第 <strong>{{ report.headerRow }}</strong> 行当成了表头。
</template>
<template #default>
<div v-if="report.headerText" class="user-import__header-text">{{ report.headerText }}</div>
<div class="user-import__cols">
<span v-for="c in report.columns" :key="c.field" class="user-import__col-tag">
{{ c.fieldName }}
<em>{{ c.column }} 列</em>
</span>
<span v-if="!report.columns.length" class="user-import__hint">
没有识别到任何列 —— 请确认表格里有「姓名」和「账号」两列。
</span>
</div>
<div class="user-import__hint">
如果上面的行或列不对,请调整表格后重新导入;已经导入成功的账号不会重复创建。
</div>
</template>
</a-alert>
<!-- 失败明细 -->
<template v-if="report.errors.length">
<div class="user-import__section">未能导入的行({{ report.errors.length }})</div>
<a-table :data="report.errors" :pagination="false" row-key="row" size="small"
:max-height="220" :scroll="{ y: 220 }">
<template #columns>
<a-table-column title="Excel 行号" :width="100" align="center">
<template #cell="{ record }">第 {{ record.row }} 行</template>
</a-table-column>
<a-table-column title="字段" :width="90">
<template #cell="{ record }">
{{ IMPORT_FIELD_NAMES[record.field] ?? record.field }}
</template>
</a-table-column>
<a-table-column title="原值" data-index="value" :width="150" ellipsis tooltip />
<a-table-column title="原因" data-index="reason" />
</template>
</a-table>
</template>
<!-- 初始密码:只这一次 -->
<template v-if="report.initialPasswords.length">
<div class="user-import__section">
新账号的初始密码({{ report.initialPasswords.length }})
</div>
<a-alert type="warning" :show-icon="false">
<template #title>
<strong>这些密码只显示这一次。</strong>
关闭后无法再查看,请现在就复制下来分发给本人。
</template>
</a-alert>
<a-table :data="report.initialPasswords" :pagination="false" row-key="username" size="small"
:max-height="200" :scroll="{ y: 200 }">
<template #columns>
<a-table-column title="账号" data-index="username" :width="180" />
<a-table-column title="初始密码">
<template #cell="{ record }">
<code class="user-import__pwd">{{ record.password }}</code>
</template>
</a-table-column>
</template>
</a-table>
</template>
<div class="user-import__footer">
<a-space>
<a-button v-if="report.initialPasswords.length" @click="copyAll">
<template #icon><IconCopy /></template>
复制全部密码
</a-button>
<a-button type="primary" @click="finish">完成</a-button>
</a-space>
</div>
</template>
</a-modal>
</template>
<script setup lang="ts">
import type { UserImportResult } from '@/apis/xt/user'
import { Message } from '@arco-design/web-vue'
import { IconCopy, IconUpload } from '@arco-design/web-vue/es/icon'
import * as userApi from '@/apis/xt/user'
import { IMPORT_FIELD_NAMES, PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS } from '@/apis/xt/user'
import { useUserStore } from '@/stores'
/**
* 账号批量导入弹窗。
*
* 这个弹窗的重点不是"上传",而是**体检报告**:
* 实测的真实 Excel 里,表头一份在第 1 行、一份在第 6 行,列名也有多种写法。
* 所以导入不可能"静默全成功或全失败",必须把
* ① 系统认的是第几行、那行长什么样、认成了哪几列
* ② 哪几行没进去、为什么
* ③ 新账号的初始密码(只此一次)
* 三件事摊开给用户看,让他当场就能判断"这次导入能不能信"。
*/
defineOptions({ name: 'UserImportModal' })
const emit = defineEmits<{ 'save-success': [] }>()
const userStore = useUserStore()
const visible = ref(false)
const submitting = ref(false)
const file = ref<File | null>(null)
const report = ref<UserImportResult | null>(null)
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const roleOptions = computed(() =>
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS.filter((r) => r.value !== 'STUDENT')
)
/** a-form 需要一个 model;文件选择走原生 input,不进这个对象 */
const formModel = reactive({ defaultRoleCode: 'HEAD_TEACHER' })
function open() {
file.value = null
report.value = null
visible.value = true
}
function close() {
visible.value = false
}
function pickFile() {
fileInput.value?.click()
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const picked = input.files?.[0] ?? null
if (picked && picked.size > 10 * 1024 * 1024) {
Message.warning('文件超过 10MB,请拆分后分批导入')
input.value = ''
file.value = null
return
}
file.value = picked
}
async function submit() {
if (!file.value) return
submitting.value = true
try {
const res = await userApi.importUsers(file.value, formModel.defaultRoleCode)
report.value = res.data ?? null
emit('save-success')
} finally {
submitting.value = false
}
}
async function copyAll() {
const rows = report.value?.initialPasswords ?? []
if (!rows.length) return
const text = rows.map((r) => `${r.username}\t${r.password}`).join('\r\n')
try {
await navigator.clipboard.writeText(text)
Message.success(`已复制 ${rows.length} 条(账号 + 密码,制表符分隔,可直接粘进 Excel)`)
} catch {
Message.warning('浏览器不允许自动复制,请手动选中后复制')
}
}
function finish() {
if (report.value?.initialPasswords.length) {
Message.warning('请确认已经复制并保存了初始密码 —— 关闭后无法再查看')
}
visible.value = false
}
defineExpose({ open })
</script>
<style lang="scss" scoped>
.user-import {
&__intro {
margin-bottom: 16px;
line-height: 1.8;
}
&__pick {
display: flex;
gap: 12px;
align-items: center;
}
&__file {
display: none;
}
&__filename {
font-size: 13px;
color: var(--color-text-1);
}
&__hint {
font-size: 12px;
line-height: 1.7;
color: var(--color-text-3);
}
&__role {
width: 200px;
}
&__footer {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
&__stats {
margin-bottom: 16px;
}
&__stat {
padding: 12px 16px;
text-align: center;
background: var(--color-fill-2);
border-radius: 6px;
&--ok .user-import__stat-num {
color: rgb(var(--success-6));
}
&--bad .user-import__stat-num {
color: rgb(var(--danger-6));
}
}
&__stat-num {
font-size: 24px;
font-weight: 600;
line-height: 1.4;
color: var(--color-text-1);
}
&__stat-label {
font-size: 12px;
color: var(--color-text-3);
}
&__header-alert {
margin-bottom: 16px;
}
&__header-text {
padding: 8px 10px;
margin: 6px 0 8px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.8;
color: var(--color-text-2);
word-break: break-all;
background: var(--color-bg-2);
border-radius: 4px;
}
&__cols {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
&__col-tag {
padding: 2px 8px;
font-size: 12px;
color: var(--color-text-2);
background: var(--color-fill-2);
border-radius: 3px;
em {
margin-left: 4px;
font-style: normal;
color: rgb(var(--primary-6));
}
}
&__section {
margin: 16px 0 8px;
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
}
&__pwd {
padding: 2px 6px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 13px;
letter-spacing: 1px;
background: var(--color-fill-2);
border-radius: 3px;
}
}
</style>
+437
View File
@@ -0,0 +1,437 @@
<template>
<GiPageLayout>
<!-- 左:组织树,用于按院系/班级过滤账号 -->
<template #left>
<a-card :bordered="false" class="user__tree-card">
<template #title>
<a-space>
<IconFolder />
<span>组织</span>
</a-space>
</template>
<a-spin v-if="orgLoading" class="user__loading" />
<template v-else>
<a-button long size="small" :type="orgId === undefined ? 'primary' : 'outline'"
class="user__all-btn" @click="onOrgSelect(undefined)">
全部账号
</a-button>
<a-tree v-if="orgTree.length" :data="orgTree" :field-names="ORG_FIELD_NAMES" block-node
:default-expand-all="true" :selected-keys="orgId === undefined ? [] : [orgId]"
@select="onOrgSelect" />
<a-empty v-else description="还没有组织" />
</template>
</a-card>
</template>
<a-card :bordered="false">
<template #title>账号</template>
<template #extra>
<span class="user__hint">共 {{ total }} 个账号</span>
</template>
<div class="g-row-tool">
<a-space wrap>
<a-button v-if="canManage" type="primary" size="small" @click="onAdd">
<template #icon><IconPlus /></template>
新增账号
</a-button>
<a-button v-if="canManage" size="small" @click="onImport">
<template #icon><IconUpload /></template>
批量导入
</a-button>
</a-space>
<a-space wrap>
<a-button v-for="c in statusChips" :key="String(c.value)"
:type="statusFilter === c.value ? 'primary' : 'outline'" size="small" @click="setStatus(c.value)">
{{ c.label }}
</a-button>
<a-select v-model="roleFilter" placeholder="全部角色" allow-clear class="user__role-filter"
@change="search">
<a-option v-for="r in allRoleOptions" :key="r.value" :value="r.value">{{ r.label }}</a-option>
</a-select>
<a-input-search v-model="query.keyword" placeholder="搜索姓名或账号" allow-clear class="user__search"
@search="search" @clear="search" />
</a-space>
</div>
<!--
⚠️ `:scroll` 不是可选的:Arco 的 `fixed: 'right'` 列**必须**配合 scroll 配置
才会预留位置;不加的话那一列会直接浮在其它列上面,
看起来像"操作列插到手机号前面了"。
总列宽 1140,这里给 1160 让它在常见分辨率下不出现横向滚动条、
窄屏时又能正常滚动并把操作列钉在右边。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 1160 }" @page-change="onPageChange">
<template #columns>
<a-table-column title="姓名 / 账号" :width="190">
<template #cell="{ record }">
<div class="user__name">{{ record.realName }}</div>
<div class="user__account">{{ record.username ?? '(未设账号)' }}</div>
</template>
</a-table-column>
<a-table-column title="角色" :width="200">
<template #cell="{ record }">
<a-space v-if="record.roleNames.length" wrap size="mini">
<a-tag v-for="n in record.roleNames" :key="n" size="small">{{ n }}</a-tag>
</a-space>
<span v-else class="user__muted">—</span>
</template>
</a-table-column>
<a-table-column title="所属组织" data-index="orgName" :width="160">
<template #cell="{ record }">
{{ record.orgName ?? '—' }}
</template>
</a-table-column>
<a-table-column title="手机号" :width="140">
<template #cell="{ record }">
<span class="user__mono">{{ record.phoneMasked ?? '—' }}</span>
</template>
</a-table-column>
<!--
这里刻意**不放「用户类型」列**。
`userType` 与角色是一对一推导的(见 apis/xt/user.ts 的 ROLE_TO_USER_TYPE),
单独列出来就是「角色:学校管理员 / 类型:学校管理员」这种重复,
看着像 bug。类型仍由接口返回,需要时在详情里看即可 —— 列表以「角色」为准。
-->
<a-table-column title="状态" :width="100" align="center">
<template #cell="{ record }">
<a-tag :color="USER_STATUS[record.status]?.color ?? 'gray'">
{{ USER_STATUS[record.status]?.text ?? '未知' }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="最后登录" :width="150">
<template #cell="{ record }">
<span v-if="record.lastLoginTime" class="user__muted">
{{ record.lastLoginTime.replace('T', ' ').slice(0, 16) }}
</span>
<span v-else class="user__muted">从未登录</span>
</template>
</a-table-column>
<a-table-column title="操作" :width="200" align="center" fixed="right">
<template #cell="{ record }">
<a-space v-if="canManage" size="mini">
<a-link @click="onEdit(record)">编辑</a-link>
<a-link @click="onResetPassword(record)">重置密码</a-link>
<a-link v-if="record.status === 1" status="warning" @click="onToggleStatus(record, 0)">
停用
</a-link>
<a-link v-else status="success" @click="onToggleStatus(record, 1)">启用</a-link>
</a-space>
<span v-else class="user__muted">只读</span>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<UserFormModal ref="formModalRef" @save-success="load" />
<UserImportModal ref="importModalRef" @save-success="load" />
<!-- 重置密码结果:与创建一样,只显示这一次 -->
<a-modal v-model:visible="resetVisible" title="密码已重置" :width="460" :mask-closable="false"
:footer="false" :closable="false">
<a-alert type="success" :show-icon="false">
<template #title>
账号 <strong>{{ resetResult?.username }}</strong> 的密码已重置。
该账号此前的<b>登录锁定也已一并解除</b>。
</template>
</a-alert>
<div class="user__pwd-box">
<div class="user__pwd-row">
<code class="user__pwd">{{ resetResult?.newPassword }}</code>
<a-button type="primary" size="small" @click="copyResetPassword">
<template #icon><IconCopy /></template>
复制
</a-button>
</div>
<a-alert type="warning" :show-icon="false" class="user__pwd-warn">
<strong>只显示这一次。</strong>关闭后无法再查看,请现在发给本人。
</a-alert>
</div>
<div class="user__footer">
<a-button type="primary" @click="resetVisible = false">我已记下密码</a-button>
</div>
</a-modal>
</GiPageLayout>
</template>
<script setup lang="ts">
import type { OrgTreeVO } from '@/apis/xt/org'
import type { UserVO } from '@/apis/xt/user'
import { Message, Modal } from '@arco-design/web-vue'
import { IconCopy, IconFolder, IconPlus, IconUpload } from '@arco-design/web-vue/es/icon'
import { getOrgTree } from '@/apis/xt/org'
import * as userApi from '@/apis/xt/user'
import { PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS, USER_STATUS } from '@/apis/xt/user'
import { useUserStore } from '@/stores'
import UserFormModal from './UserFormModal.vue'
import UserImportModal from './UserImportModal.vue'
/**
* 账号管理(《03-接口设计》§5.6 ~ §5.9)。
*
* 形态:左组织树 + 右列表,与「组织架构」页同一套主从布局 —— 学校最常问的
* 是"这个班有哪些账号",从树点进去比在搜索框里回想姓名快得多。
*
* 两处按产品约束做的取舍:
* 1. **手机号只显示脱敏值**,因此编辑时无法回填原号码,"不改手机号"是不支持的 ——
* 要改就得重填。这是「列表接口不返回明文」(§1.7)带来的必然结果。
* 2. **停用 ≠ 删除**。停用只改 status、可再启用;删除是软删、会从列表消失。
* 日常"某人离职"用停用,建错了才用删除。
*/
defineOptions({ name: 'User' })
const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('user:manage'))
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
const loading = ref(false)
const orgLoading = ref(false)
const list = ref<UserVO[]>([])
const total = ref(0)
const orgTree = ref<OrgTreeVO[]>([])
const orgId = ref<number | undefined>(undefined)
const roleFilter = ref<string | undefined>(undefined)
const statusFilter = ref<number | undefined>(1)
const query = reactive({ pageNum: 1, pageSize: 10, keyword: '' })
const formModalRef = useTemplateRef('formModalRef')
const importModalRef = useTemplateRef('importModalRef')
const resetVisible = ref(false)
const resetResult = ref<userApi.UserResetResult | null>(null)
const statusChips = [
{ label: '正常', value: 1 },
{ label: '已停用', value: 0 },
{ label: '全部', value: undefined }
]
/** 角色筛选的候选:平台租户用平台那套,学校用学校那套 */
const allRoleOptions = computed(() =>
isPlatformTenant.value
? PLATFORM_ROLE_OPTIONS
: [...SCHOOL_ROLE_OPTIONS]
)
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
async function loadOrgTree() {
orgLoading.value = true
try {
const res = await getOrgTree()
orgTree.value = res.data ?? []
} finally {
orgLoading.value = false
}
}
async function load() {
loading.value = true
try {
const res = await userApi.getUserList({
keyword: query.keyword.trim() || undefined,
orgId: orgId.value,
roleCode: roleFilter.value,
status: statusFilter.value,
pageNum: query.pageNum,
pageSize: query.pageSize
})
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
loading.value = false
}
}
function search() {
query.pageNum = 1
load()
}
function setStatus(v: number | undefined) {
statusFilter.value = v
search()
}
function onPageChange(page: number) {
query.pageNum = page
load()
}
function onOrgSelect(keys: (string | number)[] | number | undefined) {
// a-button 直接调时传的是 undefined;a-tree 的 @select 传的是 keys 数组
const raw = Array.isArray(keys) ? keys[0] : keys
orgId.value = raw === undefined || raw === '' ? undefined : Number(raw)
search()
}
function onAdd() {
formModalRef.value?.add()
}
function onEdit(row: UserVO) {
formModalRef.value?.edit(row)
}
function onImport() {
importModalRef.value?.open()
}
async function onToggleStatus(row: UserVO, status: number) {
const action = status === 1 ? '启用' : '停用'
if (status === 0) {
return new Promise<void>((resolve) => {
Modal.warning({
title: `确认${action}「${row.realName}」?`,
content: '停用后该账号将无法登录,但数据保留,可以随时再启用。',
hideCancel: false,
onBeforeOk: async () => {
await userApi.setUserStatus(row.id, status)
Message.success(`已${action}`)
await load()
resolve()
return true
},
onCancel: () => resolve()
})
})
}
await userApi.setUserStatus(row.id, status)
Message.success(`已${action}`)
await load()
}
async function onResetPassword(row: UserVO) {
Modal.warning({
title: `确认重置「${row.realName}」的密码?`,
content: '系统会生成一个新的初始密码,旧密码立即失效。该账号的登录锁定也会一并解除。',
hideCancel: false,
onBeforeOk: async () => {
const res = await userApi.resetUserPassword(row.id, { mode: 'GENERATE' })
resetResult.value = res.data ?? null
resetVisible.value = true
return true
}
})
}
async function copyResetPassword() {
const pwd = resetResult.value?.newPassword
if (!pwd) return
try {
await navigator.clipboard.writeText(pwd)
Message.success('已复制到剪贴板')
} catch {
Message.warning('浏览器不允许自动复制,请手动选中后复制')
}
}
onMounted(() => {
loadOrgTree()
load()
})
</script>
<style lang="scss" scoped>
.user {
&__tree-card {
height: 100%;
}
&__loading {
display: block;
padding: 40px 0;
text-align: center;
}
&__all-btn {
margin-bottom: 10px;
}
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__role-filter {
width: 130px;
}
&__search {
width: 220px;
}
&__name {
font-weight: 500;
color: var(--color-text-1);
}
&__account {
font-size: 12px;
color: var(--color-text-3);
}
&__mono {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 13px;
}
&__muted {
color: var(--color-text-3);
}
&__pwd-box {
margin: 16px 0 0;
}
&__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>