diff --git a/frontend/src/views/Users.test.js b/frontend/src/views/Users.test.js
index 87a07095..1cfd8ac1 100644
--- a/frontend/src/views/Users.test.js
+++ b/frontend/src/views/Users.test.js
@@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessage } from 'element-plus'
+import { defineComponent, ref } from 'vue'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import Users from './Users.vue'
import { organizationsApi } from '../api/organizations'
@@ -99,6 +100,25 @@ describe('用户管理角色', () => {
expect(wrapper.text()).toContain('采购人员')
wrapper.unmount()
})
+
+ // 列表里同样要标出来:停用角色的 tag 和启用的一模一样,管理员会以为这个用户还在
+ // 通过它拿权限,实际上 GenerateToken 已经把它过滤掉了。
+ test('列表里已停用的角色也带后缀,启用的不带', async () => {
+ usersStore.users = [{
+ ...usersStore.users[0],
+ roles: [
+ { id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] },
+ { id: 3, code: 'purchaser', name: '采购人员', enabled: false, permissions: [] }
+ ]
+ }]
+ const wrapper = mountUsers()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('采购人员(已停用)')
+ expect(wrapper.text()).toContain('仓库管理员')
+ expect(wrapper.text()).not.toContain('仓库管理员(已停用)')
+ wrapper.unmount()
+ })
})
describe('新增用户后列表与搜索框保持一致', () => {
@@ -155,3 +175,113 @@ describe('新增用户后列表与搜索框保持一致', () => {
wrapper.unmount()
})
})
+
+// 已停用角色在下拉里怎么呈现,直接决定"编辑用户"这条路走不走得通:保存是全量替换
+// 角色,前端若摘不掉那个停用角色、后端又要求角色全部启用,这个用户就永远存不回去。
+// 现在后端已放行"本来就持有的停用角色",前端这两条是配套:既让人摘得掉,也不让人
+// 新分配一个停用角色。
+describe('已停用角色的选择状态', () => {
+ const disabledRole = { id: 3, code: 'purchaser', name: '采购人员', enabled: false, permissions: [] }
+ const unassignedDisabledRole = { id: 4, code: 'obsolete_role', name: '作废角色', enabled: false, permissions: [] }
+
+ beforeEach(() => {
+ // 用户 7 正持有已停用的角色 3;角色 4 同样停用但该用户没有
+ usersStore.users = [{
+ id: 7,
+ username: 'zhangsan',
+ description: '测试用户',
+ created_at: '2026-09-16T09:00:00+08:00',
+ roles: [disabledRole]
+ }]
+ usersStore.updateUser.mockReset()
+ usersStore.updateUser.mockResolvedValue({ data: {} })
+ organizationsApi.listRoles.mockResolvedValue({
+ data: [
+ { id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] },
+ disabledRole,
+ unassignedDisabledRole
+ ]
+ })
+ })
+
+ async function openEdit(wrapper) {
+ const state = wrapper.vm.$.setupState
+ state.showEditModal(usersStore.users[0])
+ await flushPromises()
+ return state
+ }
+
+ function findOption(wrapper, value) {
+ return wrapper.findAllComponents(RoleOptionStub).find(option => option.props('value') === value)
+ }
+
+ test('用户已持有的停用角色不禁用,否则它摘不掉', async () => {
+ const wrapper = mountUsers()
+ await flushPromises()
+ const state = await openEdit(wrapper)
+
+ expect(state.form.role_ids).toEqual([3])
+ // disabled 跟着 form.role_ids 走:已选中就不禁用,tag 上才有关闭按钮
+ expect(findOption(wrapper, 3).props('disabled')).toBe(false)
+ wrapper.unmount()
+ })
+
+ test('未持有的停用角色仍然禁用,不能通过编辑新分配', async () => {
+ const wrapper = mountUsers()
+ await flushPromises()
+ await openEdit(wrapper)
+
+ expect(findOption(wrapper, 4).props('disabled')).toBe(true)
+ wrapper.unmount()
+ })
+
+ test('停用的角色在下拉里标注出来', async () => {
+ const wrapper = mountUsers()
+ await flushPromises()
+ await openEdit(wrapper)
+
+ expect(findOption(wrapper, 3).props('label')).toBe('采购人员(已停用)')
+ expect(findOption(wrapper, 2).props('label')).toBe('仓库管理员')
+ wrapper.unmount()
+ })
+})
+
+// 上面几条用的是 stub,只能证明 :disabled 这个 prop 算对了;而"管理员究竟能不能把那个
+// 停用角色摘掉"取决于 element-plus 内部——multiple 模式下 tag 的关闭按钮绑在 option 的
+// isDisabled 上(select2.mjs 的 closable: !selectDisabled && !item.isDisabled)。这里是
+// 用真实组件把这条依赖验一遍,否则 Users.vue 的修复只是纸面上成立。
+describe('element-plus 多选下停用角色 tag 的可关闭性', () => {
+ const roles = [
+ { id: 2, name: 'warehouse_admin', enabled: true },
+ { id: 3, name: 'purchaser', enabled: false }
+ ]
+
+ // 只铺一个最小场景:已选中那个停用的角色 3
+ function buildSelect(disabledExpression) {
+ return defineComponent({
+ template: `
+
+
+
+ `,
+ setup() {
+ return { ids: ref([3]), roles }
+ }
+ })
+ }
+
+ async function closeIconCount(disabledExpression) {
+ const wrapper = mount(buildSelect(disabledExpression), { global: { plugins: [ElementPlus] } })
+ await flushPromises()
+ return wrapper.findAll('.el-tag__close').length
+ }
+
+ test('Users.vue 的写法:已选中的停用角色 tag 保留关闭按钮', async () => {
+ expect(await closeIconCount('!role.enabled && !ids.includes(role.id)')).toBe(1)
+ })
+
+ test('静态 disabled 的写法:同一个 tag 没有关闭按钮(原死锁现场)', async () => {
+ expect(await closeIconCount('!role.enabled')).toBe(0)
+ })
+})
diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue
index 7b337018..1704f655 100644
--- a/frontend/src/views/Users.vue
+++ b/frontend/src/views/Users.vue
@@ -32,7 +32,7 @@
:type="role.code === 'super_admin' ? 'danger' : 'info'"
class="role-tag"
>
- {{ role.name }}
+ {{ roleLabel(role) }}
-
@@ -60,13 +60,18 @@
+
@@ -126,6 +131,13 @@ async function loadRoles() {
roles.value = response.data || []
}
+// 列表的 tag 和编辑下拉都用它。已停用的角色照旧显示(用户可能正持有它,编辑时得
+// 看得见、摘得掉),但必须把"已停用"标出来:停用意味着不再授予权限,只显示角色名
+// 会让管理员以为这个角色还在生效。
+function roleLabel(role) {
+ return role.enabled ? role.name : `${role.name}(已停用)`
+}
+
function handleSearch() {
usersStore.fetchUsers(1, searchKeyword.value)
}
diff --git a/internal/handler/api.go b/internal/handler/api.go
index f7f8c8b4..5c7352ad 100644
--- a/internal/handler/api.go
+++ b/internal/handler/api.go
@@ -1076,8 +1076,8 @@ func (h *Handler) CreateUser(c *gin.Context) {
user, err := h.authSvc.CreateUser(req.Username, req.Password, req.Description, req.RoleIDs)
if err != nil {
- if errors.Is(err, repository.ErrInvalidRoles) {
- response.BadRequest(c, err.Error())
+ if message := roleErrorText(err); message != "" {
+ response.BadRequest(c, message)
return
}
response.InternalError(c, err.Error())
@@ -1107,8 +1107,8 @@ func (h *Handler) UpdateUser(c *gin.Context) {
err = h.authSvc.UpdateUser(id, req.Description, req.RoleIDs, req.Password)
if err != nil {
- if errors.Is(err, repository.ErrInvalidRoles) || errors.Is(err, repository.ErrLastSuperAdmin) {
- response.BadRequest(c, err.Error())
+ if message := roleErrorText(err); message != "" {
+ response.BadRequest(c, message)
return
}
response.InternalError(c, err.Error())
@@ -1133,8 +1133,8 @@ func (h *Handler) DeleteUser(c *gin.Context) {
}
if err := h.authSvc.DeleteUser(id); err != nil {
- if errors.Is(err, repository.ErrLastSuperAdmin) {
- response.BadRequest(c, err.Error())
+ if message := roleErrorText(err); message != "" {
+ response.BadRequest(c, message)
return
}
response.InternalError(c, err.Error())
@@ -1144,6 +1144,21 @@ func (h *Handler) DeleteUser(c *gin.Context) {
response.SuccessWithMessage(c, "user deleted successfully", nil)
}
+// roleErrorText 把角色相关的仓储层错误翻成面向用户的中文,返回空字符串表示这个错误
+// 与角色无关。翻译放在 handler 而非仓储层:仓储层的错误常量是给开发者看的标识,不该
+// 承担界面文案。前端的 utils/errorText.js 只放行带中文的消息,英文会被换成笼统的
+// "请求参数有误",用户看不出问题出在角色上。
+func roleErrorText(err error) string {
+ switch {
+ case errors.Is(err, repository.ErrInvalidRoles):
+ return "所选角色无效或已被停用,请重新选择"
+ case errors.Is(err, repository.ErrLastSuperAdmin):
+ return "不能移除最后一个超级管理员"
+ default:
+ return ""
+ }
+}
+
// Application handlers
func (h *Handler) CreateApplication(c *gin.Context) {
diff --git a/internal/repository/user.go b/internal/repository/user.go
index 5fc78c94..dad0fcf3 100644
--- a/internal/repository/user.go
+++ b/internal/repository/user.go
@@ -132,7 +132,7 @@ func (r *UserRepository) update(user *model.User, roleIDs []int64, updatePasswor
defer func() { _ = tx.Rollback() }()
roleIDs = uniqueRoleIDs(roleIDs)
- if err := validateRolesTx(tx, roleIDs); err != nil {
+ if err := validateRolesTx(tx, user.ID, roleIDs); err != nil {
return err
}
if err := protectLastSuperAdminTx(tx, user.ID, roleIDs); err != nil {
@@ -208,23 +208,37 @@ func loadRolesForUsers(queryer interface {
func assignRolesTx(tx *sql.Tx, userID int64, roleIDs []int64) error {
roleIDs = uniqueRoleIDs(roleIDs)
- if err := validateRolesTx(tx, roleIDs); err != nil {
+ if err := validateRolesTx(tx, userID, roleIDs); err != nil {
return err
}
return insertUserRolesTx(tx, userID, roleIDs)
}
-func validateRolesTx(tx *sql.Tx, roleIDs []int64) error {
+// validateRolesTx 校验 roleIDs 是否都是可分配给该用户的角色。
+// 可分配 = 角色处于启用状态,或它本来就是该用户当前持有的角色。
+// - 新建用户时 user_roles 里还没有该用户的记录,EXISTS 恒为假,等价于原来的
+// "只允许启用中的角色"。
+// - 编辑用户时前端会把现有角色原样回传,其中可能含已被停用的。停用只是不再授予
+// 权限(GenerateToken 会过滤掉停用角色),没必要把已有分配一并抹掉;若在这里
+// 拒绝,管理员就再也保存不了这个用户:下拉里的停用角色是禁用项,已选中的 tag
+// 也没有关闭按钮。
+//
+// 必须在 DELETE FROM user_roles 之前调用,否则 EXISTS 取不到旧分配。
+func validateRolesTx(tx *sql.Tx, userID int64, roleIDs []int64) error {
if len(roleIDs) == 0 {
return ErrInvalidRoles
}
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(roleIDs)), ", ")
- args := make([]interface{}, len(roleIDs))
- for index, id := range roleIDs {
- args[index] = id
+ args := make([]interface{}, 0, len(roleIDs)+1)
+ for _, id := range roleIDs {
+ args = append(args, id)
}
+ args = append(args, userID)
var count int
- query := fmt.Sprintf(`SELECT COUNT(*) FROM roles WHERE enabled = TRUE AND id IN (%s)`, placeholders)
+ query := fmt.Sprintf(`SELECT COUNT(*) FROM roles r
+ WHERE r.id IN (%s)
+ AND (r.enabled = TRUE OR EXISTS (
+ SELECT 1 FROM user_roles ur WHERE ur.role_id = r.id AND ur.user_id = ?))`, placeholders)
if err := tx.QueryRow(query, args...).Scan(&count); err != nil {
return err
}
diff --git a/internal/repository/user_test.go b/internal/repository/user_test.go
index 03baa133..d568c273 100644
--- a/internal/repository/user_test.go
+++ b/internal/repository/user_test.go
@@ -87,8 +87,11 @@ func TestUpdateRejectsRemovingRoleFromLastSuperAdmin(t *testing.T) {
database.DB = db
mock.ExpectBegin()
- mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles WHERE enabled = TRUE AND id IN (?)`)).
- WithArgs(int64(2)).
+ mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles r
+ WHERE r.id IN (?)
+ AND (r.enabled = TRUE OR EXISTS (
+ SELECT 1 FROM user_roles ur WHERE ur.role_id = r.id AND ur.user_id = ?))`)).
+ WithArgs(int64(2), int64(7)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM roles WHERE code = 'super_admin' FOR UPDATE`)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
@@ -110,6 +113,80 @@ func TestUpdateRejectsRemovingRoleFromLastSuperAdmin(t *testing.T) {
}
}
+// 编辑用户时把已被停用的角色原样回传必须能保存成功。停用只是不再授予权限,不要求
+// 管理员顺手把它从用户身上摘掉;旧实现在这里返回 ErrInvalidRoles,而前端下拉里的
+// 停用角色是禁用项、已选中的 tag 也关不掉,结果这个用户再也编辑不了。
+func TestUpdateKeepsDisabledRoleAlreadyAssignedToUser(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ if err != nil {
+ t.Fatalf("open sql mock: %v", err)
+ }
+ t.Cleanup(func() { database.DB = nil; db.Close() })
+ database.DB = db
+
+ mock.ExpectBegin()
+ // count 命中 1:角色 3 虽然已停用,但该用户当前正持有它,EXISTS 放行。
+ mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles r
+ WHERE r.id IN (?)
+ AND (r.enabled = TRUE OR EXISTS (
+ SELECT 1 FROM user_roles ur WHERE ur.role_id = r.id AND ur.user_id = ?))`)).
+ WithArgs(int64(3), int64(7)).
+ WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
+ mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM roles WHERE code = 'super_admin' FOR UPDATE`)).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
+ mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role_id = ?`)).
+ WithArgs(int64(7), int64(1)).
+ WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
+ mock.ExpectExec(regexp.QuoteMeta(`UPDATE users SET description = ? WHERE id = ?`)).
+ WithArgs("改个描述", int64(7)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM user_roles WHERE user_id = ?`)).
+ WithArgs(int64(7)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)`)).
+ WithArgs(int64(7), int64(3)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectCommit()
+
+ user := &model.User{ID: 7, Description: "改个描述"}
+ if err := NewUserRepository().Update(user, []int64{3}); err != nil {
+ t.Fatalf("update user: %v", err)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// 反面:把一个该用户还没持有的停用角色塞进来仍要拒绝。EXISTS 为假,count 对不上,
+// 这样"不能通过接口新分配停用角色"这条约束就没有被上面那个放行口子带松。
+func TestUpdateRejectsNewlyAssignedDisabledRole(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ if err != nil {
+ t.Fatalf("open sql mock: %v", err)
+ }
+ t.Cleanup(func() { database.DB = nil; db.Close() })
+ database.DB = db
+
+ mock.ExpectBegin()
+ // count 为 0:角色 4 已停用,且不在该用户当前持有的角色里。
+ mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles r
+ WHERE r.id IN (?)
+ AND (r.enabled = TRUE OR EXISTS (
+ SELECT 1 FROM user_roles ur WHERE ur.role_id = r.id AND ur.user_id = ?))`)).
+ WithArgs(int64(4), int64(7)).
+ WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
+ mock.ExpectRollback()
+
+ user := &model.User{ID: 7, Description: "改个描述"}
+ err = NewUserRepository().Update(user, []int64{4})
+ if !errors.Is(err, ErrInvalidRoles) {
+ t.Fatalf("update error = %v, want ErrInvalidRoles", err)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatal(err)
+ }
+}
+
func TestCreateUserAssignsMultipleEnabledRolesInTransaction(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
@@ -122,8 +199,13 @@ func TestCreateUserAssignsMultipleEnabledRolesInTransaction(t *testing.T) {
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO users (username, password, description) VALUES (?, ?, ?)`)).
WithArgs("zhangsan", "hash", "测试用户").
WillReturnResult(sqlmock.NewResult(7, 1))
- mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles WHERE enabled = TRUE AND id IN (?, ?)`)).
- WithArgs(int64(2), int64(3)).
+ // 新建的用户在 user_roles 里还没有记录,EXISTS 恒为假,所以这里等价于
+ // "只允许启用中的角色"。
+ mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles r
+ WHERE r.id IN (?, ?)
+ AND (r.enabled = TRUE OR EXISTS (
+ SELECT 1 FROM user_roles ur WHERE ur.role_id = r.id AND ur.user_id = ?))`)).
+ WithArgs(int64(2), int64(3), int64(7)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO user_roles (user_id, role_id) VALUES (?, ?), (?, ?)`)).
WithArgs(int64(7), int64(2), int64(7), int64(3)).