diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java
index 4e15a543..81cc2438 100644
--- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java
+++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java
@@ -5,7 +5,10 @@ import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.paginate.Page;
+import com.mybatisflex.core.query.QueryCondition;
+import com.mybatisflex.core.query.QueryMethods;
import com.mybatisflex.core.query.QueryWrapper;
+import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.annotation.Transactional;
@@ -49,6 +52,10 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
+import static tech.easyflow.system.entity.table.SysAccountRoleTableDef.SYS_ACCOUNT_ROLE;
+import static tech.easyflow.system.entity.table.SysAccountTableDef.SYS_ACCOUNT;
+import static tech.easyflow.system.entity.table.SysRoleTableDef.SYS_ROLE;
+
/**
* 用户表 控制层。
*
@@ -58,6 +65,7 @@ import java.util.stream.Collectors;
@RestController("sysAccountController")
@RequestMapping("/api/v1/sysAccount")
public class SysAccountController extends BaseCurdController {
+ private static final String ACCOUNT_SEARCH_KEYWORD_PARAM = "keyword";
private static final String SUPER_ADMIN_HOME_PATH = "/dashboard/workspace";
private static final String USER_HOME_PATH = "/ai/agent-chat";
@@ -81,6 +89,53 @@ public class SysAccountController extends BaseCurdController统一关键字同时匹配账号、昵称、电话、邮件和已关联角色名称。角色条件使用相关
+ * {@code EXISTS} 子查询,避免多角色关联导致分页记录重复。
+ *
+ * @param request 当前 HTTP 请求
+ * @return 账号列表查询条件
+ */
+ @Override
+ protected QueryWrapper buildQueryWrapper(HttpServletRequest request) {
+ String keyword = request.getParameter(ACCOUNT_SEARCH_KEYWORD_PARAM);
+ if (!StringUtil.hasText(keyword)) {
+ return super.buildQueryWrapper(request);
+ }
+
+ String likePattern = "%" + escapeLikeKeyword(keyword.trim()) + "%";
+ QueryWrapper roleExistsQuery = QueryMethods.selectOne()
+ .from(SYS_ACCOUNT_ROLE)
+ .innerJoin(SYS_ROLE)
+ .on(SYS_ACCOUNT_ROLE.ROLE_ID.eq(SYS_ROLE.ID))
+ .where(SYS_ACCOUNT_ROLE.ACCOUNT_ID.eq(SYS_ACCOUNT.ID))
+ // 关联角色必须与账号属于同一租户,避免异常关系数据跨租户命中。
+ .and(SYS_ROLE.TENANT_ID.eq(SYS_ACCOUNT.TENANT_ID))
+ .and(SYS_ROLE.ROLE_NAME.likeRaw(likePattern));
+ QueryCondition keywordCondition = SYS_ACCOUNT.LOGIN_NAME.likeRaw(likePattern)
+ .or(SYS_ACCOUNT.NICKNAME.likeRaw(likePattern))
+ .or(SYS_ACCOUNT.MOBILE.likeRaw(likePattern))
+ .or(SYS_ACCOUNT.EMAIL.likeRaw(likePattern))
+ .or(QueryMethods.exists(roleExistsQuery));
+
+ return QueryWrapper.create().and(keywordCondition);
+ }
+
+ /**
+ * 转义 MySQL LIKE 模式中的特殊字符,使用户输入按普通文本匹配。
+ *
+ * @param keyword 已去除首尾空格的关键字
+ * @return 可安全放入 LIKE 模式的文本
+ */
+ private String escapeLikeKeyword(String keyword) {
+ return keyword
+ .replace("\\", "\\\\")
+ .replace("%", "\\%")
+ .replace("_", "\\_");
+ }
+
@Override
@LogRecord("分页查询")
protected Page queryPage(Page page, QueryWrapper queryWrapper) {
diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java
index 470c4dfe..6d61a201 100644
--- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java
+++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java
@@ -2,6 +2,7 @@ package tech.easyflow.admin.controller.system;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.query.QueryWrapper;
+import jakarta.servlet.http.HttpServletRequest;
import org.mockito.MockedStatic;
import org.testng.annotations.Test;
import tech.easyflow.auth.service.AuthCredentialKeyService;
@@ -15,7 +16,9 @@ import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysRoleService;
import java.math.BigInteger;
+import java.util.Collections;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
@@ -27,12 +30,79 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
/**
* {@link SysAccountController} 创建账号测试。
*/
public class SysAccountControllerTest {
+ /**
+ * 验证统一关键字覆盖账号字段和关联角色名称,并保留租户关联条件。
+ */
+ @Test
+ public void buildQueryWrapperShouldSearchAccountFieldsAndRoleName() {
+ SysAccountController controller = new SysAccountController(
+ mock(SysAccountService.class),
+ mock(AuthCredentialKeyService.class),
+ mock(SysRoleService.class)
+ );
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getParameter("keyword")).thenReturn(" search-user ");
+
+ QueryWrapper queryWrapper = controller.buildQueryWrapper(request);
+
+ String sql = queryWrapper.toSQL().toLowerCase(Locale.ROOT);
+ assertTrue(sql.contains("login_name"), sql);
+ assertTrue(sql.contains("nickname"), sql);
+ assertTrue(sql.contains("mobile"), sql);
+ assertTrue(sql.contains("email"), sql);
+ assertTrue(sql.contains("exists"), sql);
+ assertTrue(sql.contains("tb_sys_account_role"), sql);
+ assertTrue(sql.contains("tb_sys_role"), sql);
+ assertTrue(sql.contains("role_name"), sql);
+ assertTrue(sql.contains("tenant_id"), sql);
+ assertTrue(sql.contains("search-user"), sql);
+ }
+
+ /**
+ * 验证空关键字不会产生额外筛选条件。
+ */
+ @Test
+ public void buildQueryWrapperShouldIgnoreBlankKeyword() {
+ SysAccountController controller = new SysAccountController(
+ mock(SysAccountService.class),
+ mock(AuthCredentialKeyService.class),
+ mock(SysRoleService.class)
+ );
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getParameter("keyword")).thenReturn(" ");
+ when(request.getParameterMap()).thenReturn(Collections.emptyMap());
+
+ QueryWrapper queryWrapper = controller.buildQueryWrapper(request);
+
+ assertFalse(queryWrapper.hasCondition());
+ }
+
+ /**
+ * 验证 LIKE 通配符和反斜杠按普通字符参与搜索。
+ */
+ @Test
+ public void buildQueryWrapperShouldEscapeLikePatternCharacters() {
+ SysAccountController controller = new SysAccountController(
+ mock(SysAccountService.class),
+ mock(AuthCredentialKeyService.class),
+ mock(SysRoleService.class)
+ );
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getParameter("keyword")).thenReturn("a_b%c\\d");
+
+ String sql = controller.buildQueryWrapper(request).toSQL();
+
+ assertTrue(sql.contains("a\\\\_b\\\\%c\\\\\\\\d"), sql);
+ }
+
/**
* 验证创建用户时保留表单选择的部门,不被操作者部门覆盖。
*/
diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json b/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json
index ba03a89f..d99031ab 100644
--- a/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json
+++ b/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json
@@ -3,6 +3,7 @@
"deptId": "Dept",
"tenantId": "TenantId",
"loginName": "LoginName",
+ "searchPlaceholder": "Search account, nickname, phone, email, or role",
"password": "Password",
"accountType": "AccountType",
"nickname": "Nickname",
diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json
index d45652aa..47c2649c 100644
--- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json
+++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json
@@ -3,6 +3,7 @@
"deptId": "部门",
"tenantId": "租户ID",
"loginName": "登录账号",
+ "searchPlaceholder": "搜索账号、昵称、电话、邮件或角色",
"password": "密码",
"accountType": "账户类型",
"nickname": "昵称",
diff --git a/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.test.ts b/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.test.ts
new file mode 100644
index 00000000..9b05db50
--- /dev/null
+++ b/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.test.ts
@@ -0,0 +1,123 @@
+/* eslint-disable vue/one-component-per-file -- Inline stubs keep this search test isolated. */
+import { mount } from '@vue/test-utils';
+import { defineComponent, h } from 'vue';
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import SysAccountList from './SysAccountList.vue';
+
+const pageDataMocks = vi.hoisted(() => ({
+ setQuery: vi.fn(),
+}));
+const dictStoreMocks = vi.hoisted(() => ({
+ fetchDictionary: vi.fn(),
+}));
+
+vi.mock('#/api/request', () => ({
+ api: {
+ get: vi.fn(),
+ post: vi.fn(),
+ },
+}));
+vi.mock('#/locales', () => ({
+ $t: (key: string) => {
+ if (key === 'sysAccount.searchPlaceholder') {
+ return '搜索账号、昵称、电话、邮件或角色';
+ }
+ return key;
+ },
+}));
+vi.mock('#/store', () => ({
+ useDictStore: () => dictStoreMocks,
+}));
+vi.mock('#/components/headerSearch/HeaderSearch.vue', () => ({
+ default: defineComponent({
+ name: 'HeaderSearch',
+ props: {
+ buttons: {
+ type: Array,
+ default: () => [],
+ },
+ searchPlaceholder: {
+ type: String,
+ default: '',
+ },
+ },
+ emits: ['buttonClick', 'search'],
+ setup(props, { emit }) {
+ return () =>
+ h(
+ 'button',
+ {
+ 'data-placeholder': props.searchPlaceholder,
+ 'data-testid': 'account-search',
+ onClick: () => emit('search', ' alice@example.com '),
+ },
+ props.searchPlaceholder,
+ );
+ },
+ }),
+}));
+vi.mock('#/components/page/ListPageShell.vue', () => ({
+ default: defineComponent({
+ name: 'ListPageShell',
+ setup(_, { slots }) {
+ return () => h('div', [slots.filters?.(), slots.default?.()]);
+ },
+ }),
+}));
+vi.mock('#/components/page/PageData.vue', () => ({
+ default: defineComponent({
+ name: 'PageData',
+ setup(_, { expose }) {
+ expose(pageDataMocks);
+ return () => h('div');
+ },
+ }),
+}));
+vi.mock('./SysAccountImportModal.vue', () => ({
+ default: defineComponent({
+ name: 'SysAccountImportModal',
+ setup: () => () => h('div'),
+ }),
+}));
+vi.mock('./SysAccountModal.vue', () => ({
+ default: defineComponent({
+ name: 'SysAccountModal',
+ setup: () => () => h('div'),
+ }),
+}));
+vi.mock('./SysAccountPasswordResultModal.vue', () => ({
+ default: defineComponent({
+ name: 'SysAccountPasswordResultModal',
+ setup: () => () => h('div'),
+ }),
+}));
+
+describe('sysAccountList search', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('submits a trimmed unified keyword and describes searchable fields', async () => {
+ const wrapper = mount(SysAccountList, {
+ global: {
+ directives: {
+ access: {},
+ loading: {},
+ },
+ },
+ });
+ const search = wrapper.get('[data-testid="account-search"]');
+
+ expect(search.attributes('data-placeholder')).toBe(
+ '搜索账号、昵称、电话、邮件或角色',
+ );
+ await search.trigger('click');
+
+ expect(pageDataMocks.setQuery).toHaveBeenCalledWith({
+ keyword: 'alice@example.com',
+ });
+ wrapper.unmount();
+ });
+});
diff --git a/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.vue b/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.vue
index b2e333d4..0fe53be1 100644
--- a/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.vue
+++ b/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.vue
@@ -69,7 +69,7 @@ function initDict() {
dictStore.fetchDictionary('dataStatus');
}
const handleSearch = (params: string) => {
- pageDataRef.value.setQuery({ loginName: params, isQueryOr: true });
+ pageDataRef.value.setQuery({ keyword: params.trim() });
};
function clearSelection() {
selectedRows.value = [];
@@ -276,6 +276,7 @@ function isAdmin(data: any) {