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:
@@ -9,7 +9,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/hooks'
|
||||
import { useDictStore } from '@/stores'
|
||||
|
||||
defineOptions({ name: 'App' })
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface TenantCreateReq {
|
||||
adminPhone: string
|
||||
}
|
||||
|
||||
/** 开通结果:初始密码**只返回这一次** */
|
||||
/** 开通结果:初始密码**只返回这一次 */
|
||||
export interface TenantCreateResult {
|
||||
id: number
|
||||
adminInitialPassword: string
|
||||
|
||||
@@ -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: '角色'
|
||||
}
|
||||
Vendored
+6
@@ -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 }
|
||||
|
||||
@@ -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` */
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: '登录、查看明文、下载、导出、代传、删除的审计记录。'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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'
|
||||
// 使用动画库
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Vendored
+2
-2
@@ -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
|
||||
|
||||
// 合并多个类型,同名属性转为联合类型
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
@@ -3,12 +3,14 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2020", // 指定 ECMAScript 目标版本: 'ES3' (default), 'ES5', 'ES6'/'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'
|
||||
"jsx": "preserve", // 指定 jsx 代码的生成: 'preserve', 'react-native', or 'react'
|
||||
"jsxFactory": "h", // 当使用经典的JSX运行时编译JSX元素时,更改.js文件中调用的函数,默认:React.createElement
|
||||
"jsxFragmentFactory": "Fragment", // 指定 JSX 片段工厂函数在指定了 jsxFactory 编译器选项的情况下针对 react JSX 发出时使用 指定使用模块: 'commonjs', 'amd', 'system', 'umd' or 'es2015'
|
||||
"jsxFactory": "h", // 当使用经典的JSX运行时,更改.js文件中调用的函数,默认:React.createElement
|
||||
"jsxFragmentFactory": "Fragment", // 指定 JSX 片段工厂函数
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"ES2022",
|
||||
"DOM"
|
||||
], // 指定要包含在编译中的库文件
|
||||
], // 指定要包含在编译中的库文件。必须含 ES2022 ——
|
||||
// gi-demo 的 useBreakpoint / useTabsStore 用了 Array.prototype.at(),
|
||||
// 停在 ES2020 会让 `pnpm typecheck` 报 TS2550(运行期是好的:Vite 按浏览器实际能力打包)
|
||||
"baseUrl": ".", // 用于解析非相对模块名称的基目录
|
||||
"module": "ES2020", //
|
||||
"moduleResolution": "bundler",
|
||||
@@ -17,15 +19,15 @@
|
||||
"src/*"
|
||||
] // 模块名到基于 baseUrl 的路径映射的列表
|
||||
},
|
||||
"resolveJsonModule": true, // 是否解析 JSON 模块
|
||||
"resolveJsonModule": true, // 是否解析 JSON 文件
|
||||
"allowJs": true, // 允许编译 javascript 文件
|
||||
"checkJs": true, // 报告 javascript 文件中的错误
|
||||
"strict": true, // 开启所有严格的类型检查
|
||||
"noImplicitAny": false,
|
||||
"sourceMap": true, // 是否生成相应的Map映射的文件,默认:false
|
||||
"esModuleInterop": true, // 是否通过为所有导入模块创建命名空间对象,允许CommonJS和ES模块之间的互操作性,开启改选项时,也自动开启allowSyntheticDefaultImports选项,默认:false
|
||||
"skipLibCheck": true, // 是否跳过声明文件的类型检查,这可以在编译期间以牺牲类型系统准确性为代价来节省时间,默认:false
|
||||
"ignoreDeprecations": "5.0" // 选项 importsNotUsedAsValues 已弃用,并将在 TypeScript 5.5 中停止工作。指定 compilerOption 的 "ignoreprecations ": "5.0" 来消除这个错误,使用 verbatimModuleSyntax 代替
|
||||
"esModuleInterop": true, // 是否通过为所有导入模块创建命名空间对象,允许CommonJS和ES模块之间的互操作性
|
||||
"skipLibCheck": true, // 是否跳过声明文件的类型检查
|
||||
"ignoreDeprecations": "5.0" // 消除 importsNotUsedAsValues 的弃用报错
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
|
||||
Reference in New Issue
Block a user