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
-1
View File
@@ -9,7 +9,6 @@
<script setup lang="ts">
import { useTheme } from '@/hooks'
import { useDictStore } from '@/stores'
defineOptions({ name: 'App' })
+5
View File
@@ -79,3 +79,8 @@ export function logout() {
export function getCaptcha() {
return http.get<CaptchaVO>('/auth/captcha')
}
/** 修改自己的密码(原密码与新密码都需 RSA 加密) */
export function changePassword(data: { oldPassword: string, newPassword: string }) {
return http.put<void>('/auth/password', data)
}
+1 -1
View File
@@ -35,7 +35,7 @@ export interface TenantCreateReq {
adminPhone: string
}
/** 开通结果:初始密码**只返回这一次** */
/** 开通结果:初始密码**只返回这一次 */
export interface TenantCreateResult {
id: number
adminInitialPassword: string
+214
View File
@@ -0,0 +1,214 @@
/**
* 账号管理接口(《03-接口设计》§5.6 ~ §5.9)。
*
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
/** 账号列表项(§5.6) */
export interface UserVO {
id: number
/** 登录名;学生走微信登录时可能为空 */
username: string | null
realName: string
/** 脱敏手机号,形如 138****8888 —— 后端不返回明文 */
phoneMasked: string | null
orgId: number | null
orgName: string | null
/** 1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生(§2.7) */
userType: number
/** 0 停用 / 1 正常 / 2 待激活 */
status: number
roleCodes: string[]
roleNames: string[]
lastLoginTime: string | null
createTime: string
}
/** 新增 / 修改请求(§5.7 / §5.9) */
export interface UserUpsertReq {
/** 修改时后端忽略此字段 */
username?: string
realName: string
phone: string
orgId?: number | null
roleCodes: string[]
userType: number
status?: number
}
/** 新增结果:初始密码仅此一次返回 */
export interface UserCreateResult {
id: number
initialPassword: string
}
/** 重置密码结果 */
export interface UserResetResult {
username: string
newPassword: string
}
/** 批量导入的「体检报告」(§5.8) */
export interface UserImportResult {
total: number
success: number
failed: number
/** 服务端识别到的表头所在行号(从 1 开始)—— 必须展示给用户确认 */
headerRow: number
/** 表头那一行的原文(用 | 连接),供用户核对"系统认对了吗" */
headerText: string
/** 识别到的列映射:哪个字段落在哪一列 */
columns: Array<{ field: string, fieldName: string, column: string }>
errors: Array<{ row: number, field: string, value: string | null, reason: string }>
initialPasswords: Array<{ username: string, password: string }>
}
export interface UserListQuery {
keyword?: string
orgId?: number
roleCode?: string
userType?: number
status?: number
pageNum?: number
pageSize?: number
}
/** 账号分页列表 */
export function getUserList(params: UserListQuery) {
return http.get<{ list: UserVO[], total: number }>('/users', params)
}
/** 新增账号,返回初始密码(仅此一次) */
export function addUser(data: UserUpsertReq) {
return http.post<UserCreateResult>('/users', data)
}
/** 修改账号;登录名与密码不在此接口修改 */
export function updateUser(id: number, data: UserUpsertReq) {
return http.put<void>(`/users/${id}`, data)
}
/** 停用(软删)。有在办任务返回 40014;不能停用自己或最后一个管理员 */
export function deleteUser(id: number) {
return http.delete<void>(`/users/${id}`)
}
/** 启用 / 停用(只改 status,数据保留可再启用) */
export function setUserStatus(id: number, status: number) {
// status 后端设计为查询参数(PUT /users/{id}/status?status=0),
// 所以走 config.params 而不是 body
return http.put<void>(`/users/${id}/status`, undefined, { params: { status } })
}
/** 重置密码。mode 省略即 GENERATE(服务端生成)会同时解除该账号的登录锁定 */
export function resetUserPassword(id: number, data: { mode?: string, newPassword?: string } = {}) {
return http.post<UserResetResult>(`/users/${id}/reset-password`, data)
}
/** 批量导入账号(Excel ≤10MB),返回体检报告 */
export function importUsers(file: File, defaultRoleCode?: string) {
const fd = new FormData()
fd.append('file', file)
return http.post<UserImportResult>('/users/import', fd, {
params: defaultRoleCode ? { defaultRoleCode } : undefined
})
}
// ── 枚举镜像 ────────────────────────────────────────
/**
* 用户类型 → 中文名。
*
* ⚠️ 口径是《03-接口设计》§2.7:`1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生。
*
* **不要照 `sys_user` 的库注释写。** 那行注释(以及 PRD 里的那行 DDL)写的是
* 「1教师/管理员 2填报人(学生) 3社会人员」,与契约是两套错位的枚举 ——
* 旧注释的 2=学生,契约的 2=学校管理员;旧注释的 3=社会人员,契约的 3=班主任。
* 已经因此误判过一次:把 `3` 当成"社会人员(一期不做)"拒掉,而它其实是**班主任**。
* 库注释已由 `V3__fix_user_type_comment.sql` 更正。
*/
export const USER_TYPE_NAMES: Record<number, string> = {
1: '平台运营',
2: '学校管理员',
3: '班主任',
4: '学生'
}
/**
* 角色 → 用户类型。
*
* 二者本来就是一对一绑定的,所以:
* · 表单里**不让用户选**用户类型,而是从角色推导出来(避免选出矛盾组合)
* · 服务端也会用同一套映射反推校验(`UserService.assertRoleMatchesUserType`)
*/
export const ROLE_TO_USER_TYPE: Record<string, number> = {
PLATFORM_ADMIN: 1,
SCHOOL_ADMIN: 2,
AUDITOR: 2,
HEAD_TEACHER: 3,
DEPUTY_TEACHER: 3,
STUDENT: 4
}
/** 从所选角色推导用户类型;角色为空或跨类时返回 `null`(调用方据此拦提交) */
export function deriveUserType(roleCodes: string[]): number | null {
const types = new Set<number>()
for (const c of roleCodes) {
const t = ROLE_TO_USER_TYPE[c]
if (t) types.add(t)
}
return types.size === 1 ? [...types][0] : null
}
/** 账号状态(对应 sys_user.status) */
export const USER_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '已停用', color: 'gray' },
1: { text: '正常', color: 'green' },
2: { text: '待激活', color: 'orange' }
}
/**
* 可分配的角色,**必须与后端 `BuiltinRole` 逐字一致**。
*
* 为什么在前端也写一份:一期没有任何"自定义角色"的界面,角色集合由代码决定、
* 不由数据库决定(这是项目已定的决策 —— 权限码是前后端契约,放 DB 必然漂移)。
* 所以这里是「与后端枚举对称的镜像」,不是"业务配置"。
*
* 真实的可分配范围仍由后端 `UserService.resolveAssignableRoles` 判定:
* 传了这里的列表之外的角色会拿到 40301,前端只负责不显示没得选的项。
*
* 平台运营角色只在平台租户可分配 —— 学校管理员拿到它会直接越权到平台级。
*/
export const SCHOOL_ROLE_OPTIONS: Array<{ value: string, label: string }> = [
{ value: 'SCHOOL_ADMIN', label: '学校管理员' },
{ value: 'HEAD_TEACHER', label: '班主任' },
{ value: 'DEPUTY_TEACHER', label: '副班主任' },
{ value: 'AUDITOR', label: '审核员' },
{ value: 'STUDENT', label: '学生' }
]
/** 平台租户的可分配角色 */
export const PLATFORM_ROLE_OPTIONS: Array<{ value: string, label: string }> = [
{ value: 'PLATFORM_ADMIN', label: '平台运营' }
]
/** 导入时「角色」列允许写的角色中文名 → 角色码(与后端 BuiltinRole 对齐) */
export const ROLE_NAME_TO_CODE: Record<string, string> = {
平台运营: 'PLATFORM_ADMIN',
学校管理员: 'SCHOOL_ADMIN',
班主任: 'HEAD_TEACHER',
副班主任: 'DEPUTY_TEACHER',
审核员: 'AUDITOR',
学生: 'STUDENT'
}
/** 导入文件里被识别的列名(与后端 HEADER_ALIASES 对齐,供界面提示用) */
export const IMPORT_FIELD_NAMES: Record<string, string> = {
realName: '姓名',
username: '账号',
phone: '手机号',
org: '组织',
role: '角色'
}
+6
View File
@@ -19,6 +19,8 @@ declare module 'vue' {
ABreadcrumbItem: typeof import('@arco-design/web-vue')['BreadcrumbItem']
AButton: typeof import('@arco-design/web-vue')['Button']
ACard: typeof import('@arco-design/web-vue')['Card']
ACheckbox: typeof import('@arco-design/web-vue')['Checkbox']
ACheckboxGroup: typeof import('@arco-design/web-vue')['CheckboxGroup']
ACol: typeof import('@arco-design/web-vue')['Col']
AComment: typeof import('@arco-design/web-vue')['Comment']
AConfigProvider: typeof import('@arco-design/web-vue')['ConfigProvider']
@@ -61,6 +63,7 @@ declare module 'vue' {
ATag: typeof import('@arco-design/web-vue')['Tag']
ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
ATree: typeof import('@arco-design/web-vue')['Tree']
ATreeSelect: typeof import('@arco-design/web-vue')['TreeSelect']
GiButton: typeof import('./components/GiButton/GiButton.vue')['default']
GiCellAvatar: typeof import('./components/GiCell/GiCellAvatar.vue')['default']
GiCellGender: typeof import('./components/GiCell/GiCellGender.vue')['default']
@@ -94,6 +97,8 @@ declare global {
const ABreadcrumbItem: typeof import('@arco-design/web-vue')['BreadcrumbItem']
const AButton: typeof import('@arco-design/web-vue')['Button']
const ACard: typeof import('@arco-design/web-vue')['Card']
const ACheckbox: typeof import('@arco-design/web-vue')['Checkbox']
const ACheckboxGroup: typeof import('@arco-design/web-vue')['CheckboxGroup']
const ACol: typeof import('@arco-design/web-vue')['Col']
const AComment: typeof import('@arco-design/web-vue')['Comment']
const AConfigProvider: typeof import('@arco-design/web-vue')['ConfigProvider']
@@ -136,6 +141,7 @@ declare global {
const ATag: typeof import('@arco-design/web-vue')['Tag']
const ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
const ATree: typeof import('@arco-design/web-vue')['Tree']
const ATreeSelect: typeof import('@arco-design/web-vue')['TreeSelect']
const GiButton: typeof import('./components/GiButton/GiButton.vue')['default']
const GiCellAvatar: typeof import('./components/GiCell/GiCellAvatar.vue')['default']
const GiCellGender: typeof import('./components/GiCell/GiCellGender.vue')['default']
@@ -1,5 +1,18 @@
import type { FormInstance } from '@arco-design/web-vue'
import EditTable from './GiEditTable.vue'
export type * from './type'
export type EditTableInstance = InstanceType<typeof EditTable>
/**
* 组件实例类型。
*
* 与 `GiTable/index.ts` 同一个原因:泛型 SFC 不能用 `InstanceType<>` 推导
* (`<script setup generic="T extends TableData">` 让组件类型成为泛型函数签名),
* 所以按 `defineExpose({ formRef })` 的真实形状手工声明。
*/
export interface EditTableInstance {
/** 内层 a-form 的实例(校验用) */
formRef: FormInstance | null
}
export { EditTable as GiEditTable }
+15 -15
View File
@@ -4,23 +4,23 @@ export interface FlexProps {
column?: boolean
/** 水平对齐方式(与 CSS justify-content 一致) */
justify?:
| 'start'
| 'center'
| 'end'
| 'flex-start'
| 'flex-end'
| 'space-between'
| 'space-around'
| 'space-evenly'
| 'start'
| 'center'
| 'end'
| 'flex-start'
| 'flex-end'
| 'space-between'
| 'space-around'
| 'space-evenly'
/** 垂直对齐方式(与 CSS align-items 一致) */
align?:
| 'start'
| 'center'
| 'end'
| 'flex-start'
| 'flex-end'
| 'baseline'
| 'stretch'
| 'start'
| 'center'
| 'end'
| 'flex-start'
| 'flex-end'
| 'baseline'
| 'stretch'
/** 是否自动换行 */
wrap?: boolean
/** `boolean` 须写在联合类型最前,否则模板里单独写 `gap` 会被解析成 `""` 而不是 `true` */
+20 -1
View File
@@ -1,5 +1,24 @@
import type { TableInstance as ArcoTableInstance } from '@arco-design/web-vue'
import Table from './GiTable.vue'
export type * from './type'
export type TableInstance = InstanceType<typeof Table>
/**
* 组件实例类型。
*
* ⚠️ 这里**不能**写 `InstanceType<typeof Table>`。
* `GiTable.vue` 是泛型 SFC(`<script setup generic="T extends TableData">`),
* 它的类型是一个泛型函数签名而不是构造函数,`InstanceType<>` 对它不成立,
* vue-tsc 会报 `TS2344: ... does not satisfy the constraint
* 'abstract new (...args: any) => any'`。
*
* 所以按 `defineExpose({ tableRef })` 的**真实形状**手工声明:
* 暴露出来的就是那一个内层 a-table 的实例(模板 ref 在暴露时会被解包)。
* 这比 `InstanceType` 推导出来的"全部 props + slots"更准确 —— 后者本来也是假的。
*/
export interface TableInstance {
/** 内层 a-table 的实例 */
tableRef: ArcoTableInstance | null
}
export { Table as GiTable }
+10 -6
View File
@@ -1,15 +1,20 @@
/**
* 组件库出口。
*
* ⚠️ 这里的每一行都必须对应 `src/components/` 下的真实目录。
* 剥离 gi-demo 演示页时删掉过 6 个演示专用组件
* (GiCodeButton / GiCodeView / GiFooter / GiIconSelector / GiIframe / GiTrend),
* 但出口文件没同步 —— `export * from './已删除的组件'` 在构建时**不会报错**
* (Vite 只在使用到该导出时才解析),直到某天有人 import 它才炸。
* 所以增删组件时务必回来同步这一份清单。
*/
export * from './GiButton'
export * from './GiCell'
export * from './GiCodeButton'
export * from './GiCodeView'
export * from './GiDot'
export * from './GiEditTable'
export * from './GiFlex'
export * from './GiFooter'
export * from './GiForm'
export * from './GiIconBox'
export * from './GiIconSelector'
export * from './GiIframe'
export * from './GiInputSearch'
export * from './GiOption'
export * from './GiPageLayout'
@@ -17,4 +22,3 @@ export * from './GiTable'
export * from './GiTableSetting'
export * from './GiTag'
export * from './GiThemeBtn'
export * from './GiTrend'
+104 -27
View File
@@ -44,72 +44,149 @@ const GROUPS: Array<{ id: string, title: string, icon: string, sort: number }> =
export const XT_MENUS: XtMenu[] = [
// ── 收集管理 ──────────────────────────────────────
{
key: 'task', title: '收集任务', icon: 'icon-park-outline:list-two',
path: '/collect/task', component: 'task/index', group: 'collect', sort: 11, ready: false,
key: 'task',
title: '收集任务',
icon: 'icon-park-outline:list-two',
path: '/collect/task',
component: 'task/index',
group: 'collect',
sort: 11,
ready: false,
note: '建任务、配表单与材料规则、发布与暂停。对应里程碑 M1。'
},
{
key: 'roster', title: '名单管理', icon: 'icon-park-outline:user-business',
path: '/collect/roster', component: 'roster/index', group: 'collect', sort: 12, ready: false,
key: 'roster',
title: '名单管理',
icon: 'icon-park-outline:user-business',
path: '/collect/roster',
component: 'roster/index',
group: 'collect',
sort: 12,
ready: false,
note: '导入名册、报考范围确认、门禁名单维护。对应里程碑 M2。'
},
{
key: 'join', title: '加入申请', icon: 'icon-park-outline:user-to-user-transmission',
path: '/collect/join', component: 'join/index', group: 'collect', sort: 13, ready: false,
key: 'join',
title: '加入申请',
icon: 'icon-park-outline:user-to-user-transmission',
path: '/collect/join',
component: 'join/index',
group: 'collect',
sort: 13,
ready: false,
note: '门禁未匹配的人提交加入申请,管理员在此审批。对应里程碑 M2。'
},
{
key: 'audit', title: '审核', icon: 'icon-park-outline:check-one',
path: '/collect/audit', component: 'audit/index', group: 'collect', sort: 14, ready: false,
key: 'audit',
title: '审核',
icon: 'icon-park-outline:check-one',
path: '/collect/audit',
component: 'audit/index',
group: 'collect',
sort: 14,
ready: false,
note: '班主任初审(右滑通过)、管理员终审、批量操作。对应里程碑 M5。'
},
{
key: 'export', title: '导出中心', icon: 'icon-park-outline:download-two',
path: '/collect/export', component: 'export/index', group: 'collect', sort: 15, ready: false,
key: 'export',
title: '导出中心',
icon: 'icon-park-outline:download-two',
path: '/collect/export',
component: 'export/index',
group: 'collect',
sort: 15,
ready: false,
note: '按目录规范打包汇总,不合格文件禁止进包。对应里程碑 M6。'
},
// ── 配置 ─────────────────────────────────────────
{
key: 'form', title: '表单设计器', icon: 'icon-park-outline:form-one',
path: '/setting/form', component: 'form/index', group: 'setting', sort: 21, ready: false,
key: 'form',
title: '表单设计器',
icon: 'icon-park-outline:form-one',
path: '/setting/form',
component: 'form/index',
group: 'setting',
sort: 21,
ready: false,
note: '内置字段锁定、扩展字段自由。对应里程碑 M1。'
},
{
key: 'material', title: '材料规则', icon: 'icon-park-outline:file-editing',
path: '/setting/material', component: 'material/index', group: 'setting', sort: 22, ready: false,
key: 'material',
title: '材料规则',
icon: 'icon-park-outline:file-editing',
path: '/setting/material',
component: 'material/index',
group: 'setting',
sort: 22,
ready: false,
note: '命名模板、别名、格式白名单、体积上限、压缩档位。对应里程碑 M1。'
},
{
key: 'cleanup', title: '历史整理器', icon: 'icon-park-outline:magic-wand',
path: '/setting/cleanup', component: 'cleanup/index', group: 'setting', sort: 23, ready: false,
key: 'cleanup',
title: '历史整理器',
icon: 'icon-park-outline:magic-wand',
path: '/setting/cleanup',
component: 'cleanup/index',
group: 'setting',
sort: 23,
ready: false,
note: '把已有的乱文件夹/压缩包批量规范化并出体检报告。对应里程碑 M3。'
},
// ── 组织与账号 ────────────────────────────────────
{
key: 'org', title: '组织架构', icon: 'icon-park-outline:tree-list',
path: '/org/manage', component: 'org/index', group: 'org', sort: 31, ready: true
key: 'org',
title: '组织架构',
icon: 'icon-park-outline:tree-list',
path: '/org/manage',
component: 'org/index',
group: 'org',
sort: 31,
ready: true
},
{
key: 'user', title: '账号管理', icon: 'icon-park-outline:peoples',
path: '/org/user', component: 'user/index', group: 'org', sort: 32, ready: false,
note: '账号增删改查与 Excel 批量导入(含体检报告)。属 M0 剩余任务。'
key: 'user',
title: '账号管理',
icon: 'icon-park-outline:peoples',
path: '/org/user',
component: 'user/index',
group: 'org',
sort: 32,
ready: true
},
{
key: 'role', title: '角色与授权', icon: 'icon-park-outline:permissions',
path: '/org/role', component: 'role/index', group: 'org', sort: 33, ready: false,
key: 'role',
title: '角色与授权',
icon: 'icon-park-outline:permissions',
path: '/org/role',
component: 'role/index',
group: 'org',
sort: 33,
ready: false,
note: '内置角色的权限由代码决定、不可改;自定义角色属二期。'
},
// ── 平台运维 ──────────────────────────────────────
{
key: 'tenant', title: '租户管理', icon: 'icon-park-outline:school',
path: '/platform/tenant', component: 'tenant/index', group: 'platform', sort: 41, ready: true
key: 'tenant',
title: '租户管理',
icon: 'icon-park-outline:school',
path: '/platform/tenant',
component: 'tenant/index',
group: 'platform',
sort: 41,
ready: true
},
{
key: 'log', title: '操作日志', icon: 'icon-park-outline:log',
path: '/platform/log', component: 'log/index', group: 'platform', sort: 42, ready: false,
key: 'log',
title: '操作日志',
icon: 'icon-park-outline:log',
path: '/platform/log',
component: 'log/index',
group: 'platform',
sort: 42,
ready: false,
note: '登录、查看明文、下载、导出、代传、删除的审计记录。'
}
]
+4 -1
View File
@@ -27,7 +27,10 @@ export function useBreakpoint() {
const breakpoint = computed<Breakpoint>(() => {
const arr = currentBreakpoints.value
return arr.length ? arr[arr.length - 1] : 'xs'
// arr.at(-1) 的类型是 Breakpoint | undefined —— 空数组时就是 undefined。
// 原来写成 `arr.length ? arr.at(-1) : 'xs'` 是个类型谎:
// 长度判断并不能让 TS 窄化 at() 的结果,严格模式下会报 TS2769。
return arr.length ? (arr.at(-1) ?? 'xs') : 'xs'
})
return { breakpoint }
@@ -0,0 +1,120 @@
<template>
<a-modal v-model:visible="visible" title="修改密码" :width="440" :mask-closable="false"
@ok="confirm" @cancel="close">
<a-alert type="info" :show-icon="false" class="change-pwd__tip">
<template #title>
密码用 RSA 加密后传输,不会以明文进入网关或日志。修改成功后请用新密码重新登录。
</template>
</a-alert>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-form-item field="oldPassword" label="原密码">
<a-input-password v-model="form.oldPassword" placeholder="请输入当前密码" allow-clear />
</a-form-item>
<a-form-item field="newPassword" label="新密码">
<a-input-password v-model="form.newPassword" placeholder="8~32 位,需含字母与数字" allow-clear />
</a-form-item>
<a-form-item field="confirmPassword" label="确认新密码">
<a-input-password v-model="form.confirmPassword" placeholder="再输入一次" allow-clear />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import { Message } from '@arco-design/web-vue'
import { changePassword } from '@/apis/xt'
import { encryptPassword } from '@/utils/rsa'
/**
* 修改自己的密码。
*
* 三点与后端一致的口径,界面上要提前说清楚,否则用户会以为是系统坏了:
* 1. **原密码必填** —— 文档里「强制改密场景可空」需要一个
* 「必须改密」标记来界定,而库里目前没有这个列。
* 在没有该标记时放行"不填原密码",等于任何拿到有效 token 的人
* 都能改掉密码、把原主永久挤出去,所以现在一律校验。
* 2. **新旧密码不能相同** —— 服务端会拒。
* 3. **改密后此前签发的 token 仍然有效**(已知缺口),所以这里提示的是
* "请用新密码重新登录",而不是"已踢出其他设备"——后者现在做不到。
*/
defineOptions({ name: 'ChangePasswordModal' })
const visible = ref(false)
const submitting = ref(false)
const formRef = useTemplateRef('formRef')
const form = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const rules: FormInstance['rules'] = {
oldPassword: [{ required: true, message: '请输入原密码' }],
newPassword: [
{ required: true, message: '请输入新密码' },
{ minLength: 8, message: '密码至少 8 位' },
{ maxLength: 32, message: '密码最多 32 位' }
],
confirmPassword: [
{ required: true, message: '请再输入一次新密码' },
{
validator: (value: string, callback: (error?: string) => void) => {
if (value !== form.newPassword) {
callback('两次输入的新密码不一致')
return
}
callback()
}
}
]
}
function open() {
form.oldPassword = ''
form.newPassword = ''
form.confirmPassword = ''
visible.value = true
}
function close() {
visible.value = false
}
async function confirm() {
const valid = await formRef.value?.validate()
if (valid) return false
submitting.value = true
try {
// 两个字段都要加密:后端把两个都当密文解,混着传会在解密处失败
const [oldCipher, newCipher] = await Promise.all([
encryptPassword(form.oldPassword),
encryptPassword(form.newPassword)
])
await changePassword({ oldPassword: oldCipher, newPassword: newCipher })
Message.success('密码已修改,请用新密码重新登录')
visible.value = false
return true
} catch {
return false
} finally {
submitting.value = false
}
}
defineExpose({ open })
</script>
<style lang="scss" scoped>
.change-pwd {
&__tip {
margin-bottom: 16px;
line-height: 1.7;
}
}
</style>
@@ -128,10 +128,10 @@ const appStore = useAppStore()
.layout-mode-item {
box-sizing: border-box;
display: flex;
gap: 4px;
width: 100%;
height: 58px;
padding: 6px;
gap: 4px;
overflow: hidden;
cursor: pointer;
background-color: var(--color-bg-1);
@@ -156,20 +156,20 @@ const appStore = useAppStore()
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
gap: 4px;
min-width: 0;
}
.preview-side {
box-sizing: border-box;
display: flex;
flex-direction: column;
flex-shrink: 0;
flex-direction: column;
gap: 3px;
width: 12px;
padding: 3px 2px;
gap: 3px;
background-color: var(--color-fill-3);
border-radius: 2px;
box-sizing: border-box;
}
.preview-side--main {
@@ -189,14 +189,14 @@ const appStore = useAppStore()
}
.preview-topbar {
box-sizing: border-box;
display: flex;
gap: 3px;
width: 100%;
height: 10px;
padding: 2px 3px;
gap: 3px;
background-color: var(--color-fill-3);
border-radius: 2px;
box-sizing: border-box;
}
.preview-pill {
@@ -206,14 +206,14 @@ const appStore = useAppStore()
}
.preview-content {
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
justify-content: flex-start;
gap: 4px;
justify-content: flex-start;
min-width: 0;
padding-right: 1px;
box-sizing: border-box;
.preview-line {
height: 6px;
@@ -222,8 +222,8 @@ const appStore = useAppStore()
.preview-line {
display: block;
height: 5px;
width: 100%;
height: 5px;
background-color: var(--color-fill-2);
border-radius: 1px;
@@ -77,6 +77,8 @@
</a-dropdown>
</a-space>
</a-row>
<ChangePasswordModal ref="changePwdRef" />
</template>
<script setup lang="ts">
@@ -84,6 +86,7 @@ import { Button, Drawer, Message, Modal } from '@arco-design/web-vue'
import { useFullscreen } from '@vueuse/core'
import { useBreakpoint, useTheme } from '@/hooks'
import { useAppStore, useUserStore } from '@/stores'
import ChangePasswordModal from './ChangePasswordModal.vue'
import Notice from './Notice.vue'
import SettingDrawerPanel from './SettingDrawerPanel.vue'
@@ -107,6 +110,9 @@ const { isFullscreen, toggle } = useFullscreen()
/** 头像兜底文案:取姓名首字 */
const avatarText = computed(() => (userStore.name || '—').slice(0, 1))
/** 修改密码弹窗 */
const changePwdRef = useTemplateRef('changePwdRef')
/** 用户菜单配置 */
const USER_MENUS = [
{
@@ -114,11 +120,7 @@ const USER_MENUS = [
label: '修改密码',
icon: 'icon-unlock',
iconColor: 'primary',
onClick: () => {
// 后端 PUT /auth/password 尚未实现(属 M0 剩余任务),
// 这里给明确提示,而不是像模板那样假装成功
Message.info('修改密码功能待接入后端 PUT /auth/password')
}
onClick: () => changePwdRef.value?.open()
}
]
+14 -1
View File
@@ -5,7 +5,20 @@
<template>
<a-layout class="main">
<router-view v-slot="{ Component, route }">
<transition :name="appStore.pageTransitionName" mode="out-in" appear>
<!--
⚠️ 这里**不能加 `appear`**。
加了的后果是:首次渲染也被当成一次过渡,元素被加上
`zoom-fade-enter-from` + `zoom-fade-enter-active`(即 opacity:0 / scale(0.92))
之后**再也不会被清理** —— 因为进入动画的收尾依赖 transitionend 与
下一帧的补帧,而 `mode="out-in"` + `appear` + `keep-alive` 的组合下
没有"离场"元素时这条链路不成立。
表现极具迷惑性:**DOM 里内容齐全、接口数据也对、文字能选中,
就是人眼看不到**(计算样式 opacity 恒为 0),
看起来像"页面没渲染",实际是"渲染了但透明"。
2026-09-28 实测复现:整块内容区 opacity=0、transform=scale(0.92) 长时间不变。
首次加载本来也不需要入场动画,去掉 `appear` 即可。
-->
<transition :name="appStore.pageTransitionName" mode="out-in">
<keep-alive :include="(tabsStore.cacheList as string[])">
<component :is="Component" v-if="tabsStore.reloadFlag" :key="route.path" />
</keep-alive>
@@ -83,7 +83,6 @@ defineOptions({ name: 'MagicIcon' })
transform: rotate(90deg);
.magic-dot {
&::before,
&::after {
background: rgb(var(--primary-6));
+5 -5
View File
@@ -1,11 +1,6 @@
import { Card, Drawer, Modal } from '@arco-design/web-vue'
// 额外引入 Arco Design Icon图标库
import ArcoVueIcon from '@arco-design/web-vue/es/icon'
// ⚠️ Arco 组件样式必须显式引入:
// vite.config.ts 里 ArcoResolver 配了 sideEffect:false(样式不随组件自动引入),
// 原本靠 @arco-themes/vue-gi-demo 主题包带入;我们换成自己的主题后就没人带了。
// 不引的后果是「组件能用但完全没样式」,页面直接散架,且构建不报错。
import '@arco-design/web-vue/dist/arco.css'
import { createApp } from 'vue'
// 状态管理
import pinia from '@/stores'
@@ -13,6 +8,11 @@ import App from './App.vue'
// 自定义指令
import directives from './directives'
import router from './router'
// ⚠️ Arco 组件样式必须显式引入:
// vite.config.ts 里 ArcoResolver 配了 sideEffect:false(样式不随组件自动引入),
// 原本靠 @arco-themes/vue-gi-demo 主题包带入;我们换成自己的主题后就没人带了。
// 不引的后果是「组件能用但完全没样式」,页面直接散架,且构建不报错。
import '@arco-design/web-vue/dist/arco.css'
// 导入本项目主题(内部会覆盖 Arco 默认变量)
import '@/styles/arco-ui/index.less'
// 使用动画库
+5 -2
View File
@@ -148,8 +148,11 @@ const storeSetup = () => {
if (type === 'current') {
if (targetIndex < 0 || tabList.value[targetIndex].meta?.affix) return
removeTabByIndex(targetIndex)
if (currentPath === targetPath && tabList.value.length) {
jumpToTab(tabList.value[tabList.value.length - 1].path)
// at(-1) 可能是 undefined —— 上面的 length 判断在类型层面窄化不了它,
// 所以这里要真的接住 undefined 再取 path(严格模式下是 TS2532)
const last = tabList.value.at(-1)
if (currentPath === targetPath && last) {
jumpToTab(last.path)
}
return
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type { LoginVO } from '@/apis/xt'
import { defineStore } from 'pinia'
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { login as loginApi, logout as logoutApi, getMe } from '@/apis/xt'
import { getMe, login as loginApi, logout as logoutApi } from '@/apis/xt'
import { buildXtRoutes } from '@/config/xt-routes'
import { resetRouter } from '@/router'
import { clearToken, getToken, setToken } from '@/utils/auth'
+2 -2
View File
@@ -4,8 +4,8 @@ type AllKeys<T> = T extends any ? keyof T : never
// 辅助类型:获取属性在所有类型中的联合类型
type UnionType<T, K extends PropertyKey> = T extends any
? K extends keyof T
? T[K]
: never
? T[K]
: never
: never
// 合并多个类型,同名属性转为联合类型
+9 -5
View File
@@ -28,12 +28,16 @@ NProgress.configure({ showSpinner: false })
/** 本项目后端的成功码 */
const CODE_SUCCESS = 0
/** 未登录 / token 失效 */
/**
* 未登录 / token 失效。
*
* 只有这一个业务码需要在这里特殊处理(清 token + 跳登录)。
* 40301 无权限与 42901 限流<b>不在这里判</b> —— 后端对这两个用的是**真实 HTTP 状态码**
* (403 / 429,见《03-接口设计》§1.3),由下面的传输层错误分支统一处理。
* 所以这里不要再补 40301 / 42901 的常量:加了也不会被走到,
* 反而会让人以为"业务码里也会出现 40301",从而在错的地方排查问题。
*/
const CODE_UNAUTHORIZED = 40101
/** 无权限(含跨租户) */
const CODE_FORBIDDEN = 40301
/** 限流 */
const CODE_RATE_LIMITED = 42901
/** 传输层 HTTP 状态码文案(业务错误不走这里,见下方响应拦截器) */
const StatusCodeMessage: Record<number, string> = {
+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>