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:
@@ -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 ?? ''] ?? '规划中')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user