1. 登录返回总公司,分公司,部门信息。

2. 修改角色权限管理的分配用户的数据渲染和接口。
3. 交叉评查任务的创建的组织架构组件的重构。
This commit is contained in:
2026-01-21 10:04:04 +08:00
parent 9951f16e50
commit b97d0e1a0b
12 changed files with 1348 additions and 14006 deletions
+5 -1
View File
@@ -50,7 +50,11 @@ export interface UserInfo {
area?: string; // 用户所属地区 area?: string; // 用户所属地区
id?: string | number; // 临时的用户id id?: string | number; // 临时的用户id
user_id?: string | number; user_id?: string | number;
user_role?: string user_role?: string;
// 🔑 组织信息字段(可能为null)
tenant_name?: string | null;
dep_name?: string | null;
dep_short_name?: string | null;
} }
/** /**
+4
View File
@@ -47,6 +47,10 @@ export interface LoginResponse {
user_role: string; user_role: string;
sub: string; sub: string;
area?: string; // 🔑 用户所属地区 area?: string; // 🔑 用户所属地区
// 🔑 组织信息字段(可能为null)
tenant_name?: string | null;
dep_name?: string | null;
dep_short_name?: string | null;
}; };
}; };
error?: string; error?: string;
+89 -1
View File
@@ -111,7 +111,16 @@ export interface RoleRoutePermission {
} }
/** /**
* 用户信息 * 用户角色信息(用于用户列表中的角色显示)
*/
export interface UserRoleInfo {
role_id: number;
role_key: string;
role_name: string;
}
/**
* 用户信息(v3.4: 增加角色信息)
*/ */
export interface UserInfo { export interface UserInfo {
id: number; id: number;
@@ -123,6 +132,10 @@ export interface UserInfo {
ou_name: string; // 部门名称,用于组织显示(部门级别) ou_name: string; // 部门名称,用于组织显示(部门级别)
status: number; status: number;
is_leader: boolean; is_leader: boolean;
// v3.4: 用户角色信息(从 /api/v3/rbac/users 返回)
roles?: UserRoleInfo[];
tenant_name?: string; // 租户名称(多租户场景)
dep_name?: string; // 部门名称(多租户场景)
} }
/** /**
@@ -664,6 +677,7 @@ export async function getRoleUsers(
/** /**
* 获取所有用户列表 * 获取所有用户列表
* @param params 查询参数 * @param params 查询参数
* @deprecated 请使用 getUsersWithRoles 替代
*/ */
export async function getAllUsers(params?: { export async function getAllUsers(params?: {
page?: number; page?: number;
@@ -726,6 +740,80 @@ export async function getAllUsers(params?: {
} }
} }
/**
* v3.4: 获取用户列表(含角色信息)
* 使用统一的 /api/v3/rbac/users 接口,一次性获取用户和角色信息
* @param params 查询参数
*/
export async function getUsersWithRoles(params?: {
page?: number;
page_size?: number;
area?: string;
nick_name?: string;
}): Promise<{
total: number;
page: number;
page_size: number;
items: UserInfo[];
}> {
try {
const queryParams: Record<string, any> = {};
if (params?.page) queryParams.page = params.page;
if (params?.page_size) queryParams.page_size = params.page_size;
if (params?.area) queryParams.area = params.area;
if (params?.nick_name) queryParams.nick_name = params.nick_name;
const response = await get<any>('/api/v3/rbac/users', queryParams);
if (response.error) {
throw new Error(response.error);
}
// 后端响应格式: { code: 200, message: "success", data: { total, page, page_size, items: [...] } }
let data: any;
if (response.data?.data) {
data = response.data.data;
} else if (response.data?.code === 200 && response.data) {
data = response.data;
} else {
data = response.data;
}
// console.log('获取的用户列表', data )
const items: UserInfo[] = (data.items || []).map((user: any) => ({
id: user.id,
username: user.username,
nick_name: user.nick_name,
phone_number: user.phone_number || '',
email: user.email || '',
area: user.area || '',
ou_name: user.ou_name,
ou_id: user.ou_id,
status: user.status || 0,
is_leader: user.is_leader || false,
roles: user.roles || [],
dep_name: user.dep_name,
tenant_name: user.tenant_name
}));
return {
total: data.total || 0,
page: data.page || 1,
page_size: data.page_size || 50,
items
};
} catch (error) {
console.error('❌ [getUsersWithRoles] 获取用户列表失败:', error);
return {
total: 0,
page: 1,
page_size: 50,
items: []
};
}
}
/** /**
* 为用户分配角色 * 为用户分配角色
* @param userId 用户ID * @param userId 用户ID
+215 -56
View File
@@ -2,18 +2,35 @@ import { get } from '../axios-client';
import { API_BASE_URL } from '../../config/api-config'; import { API_BASE_URL } from '../../config/api-config';
import axios from 'axios'; import axios from 'axios';
// 用户信息接口 // 组织路径信息(懒加载接口返回)
export interface OrganizationPath {
tenant_name: string;
dep_name: string;
dep_short_name: string;
ou_name: string;
}
// 用户信息接口(新版,匹配 /api/v2/users 响应)
export interface UserInfo { export interface UserInfo {
id: number; id: number;
username: string; username: string;
nick_name: string; nick_name: string;
area: string;
ou_id: string; ou_id: string;
ou_name: string; ou_name: string;
is_leader: boolean; is_leader: boolean;
status: number; status: number;
// 以下字段在搜索接口中有值,在懒加载接口中为 null
tenant_name: string | null;
dep_name: string | null;
dep_short_name: string | null;
email?: string;
phone_number?: string;
// 懒加载接口返回的组织路径信息
organization_path?: OrganizationPath | null;
} }
// 组织节点接口 // 组织节点接口(新版,匹配 /api/v2/users/organizations/tree 响应)
export interface OrganizationNode { export interface OrganizationNode {
ou_id: string; ou_id: string;
ou_name: string; ou_name: string;
@@ -46,55 +63,125 @@ export interface ApiResponse<T> {
} }
/** /**
* 获取组织架构树 * 获取组织架构树(新版接口,支持按需加载)
* @param includeUsers 是否包含用户信息 * @param includeUsers 是否包含用户信息
* @param rootUuid 指定根节点UUID,不传则查询所有根节点
* @param jwtToken JWT Token
* @returns 组织架构树 * @returns 组织架构树
*/ */
export async function getOrganizationTree(includeUsers: boolean = true, jwtToken?: string): Promise<ApiResponse<OrganizationResponse>> { export async function getOrganizationTree(
includeUsers: boolean = false,
jwtToken?: string,
rootUuid?: string
): Promise<ApiResponse<OrganizationResponse>> {
try { try {
console.log('开始调用获取组织架构API'); // console.log('[getOrganizationTree] 开始调用获取组织架构API:', { includeUsers, rootUuid });
let responseData: OrganizationResponse; const params: string[] = [];
params.push(`include_users=${includeUsers}`);
if (jwtToken) { if (rootUuid) {
// 如果提供了JWT Token,则使用axios并携带Authorization头 params.push(`root_uuid=${rootUuid}`);
const url = `${API_BASE_URL}/api/v2/users/organizations?include_users=${includeUsers}`;
const response = await axios.get(url, {
headers: {
'Authorization': `Bearer ${jwtToken}`,
'Content-Type': 'application/json'
}
});
responseData = response.data;
}
else {
// 否则,使用原有的get方法
const response = await get<OrganizationResponse>(
`/admin/users/organizations?include_users=${includeUsers}`
);
if (response.error || !response.data) {
console.error('获取组织架构失败 (get):', response.error);
return {
success: false,
error: response.error || '获取组织架构数据失败'
};
}
responseData = response.data;
} }
console.log('组织架构API响应:', responseData); const url = `${API_BASE_URL}/api/v2/users/organizations/tree?${params.join('&')}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (jwtToken) {
headers['Authorization'] = `Bearer ${jwtToken}`;
}
const response = await axios.get<OrganizationResponse>(url, { headers });
const responseData = response.data;
// console.log('[getOrganizationTree] API响应:', responseData);
return { return {
success: true, success: true,
data: responseData data: responseData
}; };
} catch (error) { } catch (error) {
console.error('获取组织架构失败:', error); console.error('[getOrganizationTree] 获取组织架构失败:', error);
let errorMessage = '获取组织架构失败';
if (axios.isAxiosError(error)) {
if (error.response?.data?.detail) {
errorMessage = error.response.data.detail;
} else if (error.response?.data?.message) {
errorMessage = error.response.data.message;
}
} else if (error instanceof Error) {
errorMessage = error.message;
}
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : '获取组织架构失败' error: errorMessage
};
}
}
/**
* 搜索用户(新版接口)
* @param search 搜索关键词(模糊匹配姓名和工号)
* @param page 页码
* @param pageSize 每页数量
* @param jwtToken JWT Token
* @returns 用户列表
*/
export async function searchUsers(
search: string,
page: number = 1,
pageSize: number = 20,
jwtToken?: string
): Promise<ApiResponse<{
users: UserInfo[];
total: number;
}>> {
try {
// console.log('[searchUsers] 搜索用户:', { search, page, pageSize });
const params: string[] = [];
if (search) params.push(`search=${encodeURIComponent(search)}`);
params.push(`page=${page}`);
params.push(`page_size=${pageSize}`);
const url = `${API_BASE_URL}/api/v2/users?${params.join('&')}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (jwtToken) {
headers['Authorization'] = `Bearer ${jwtToken}`;
}
const response = await axios.get<{ users: UserInfo[]; total: number }>(url, { headers });
const responseData = response.data;
// console.log('[searchUsers] 搜索结果:', { total: responseData.total, count: responseData.users?.length });
return {
success: true,
data: responseData
};
} catch (error) {
console.error('[searchUsers] 搜索用户失败:', error);
let errorMessage = '搜索用户失败';
if (axios.isAxiosError(error)) {
if (error.response?.data?.detail) {
errorMessage = error.response.data.detail;
} else if (error.response?.data?.message) {
errorMessage = error.response.data.message;
}
} else if (error instanceof Error) {
errorMessage = error.message;
}
return {
success: false,
error: errorMessage
}; };
} }
} }
@@ -113,7 +200,7 @@ export async function getUserList(params: {
search?: string; search?: string;
} = {}): Promise<ApiResponse<UserListResponse>> { } = {}): Promise<ApiResponse<UserListResponse>> {
try { try {
console.log('开始调用获取用户列表API,参数:', params); // console.log('开始调用获取用户列表API,参数:', params);
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params.page) queryParams.append('page', params.page.toString()); if (params.page) queryParams.append('page', params.page.toString());
@@ -127,7 +214,7 @@ export async function getUserList(params: {
`/admin/users/users?${queryParams.toString()}` `/admin/users/users?${queryParams.toString()}`
); );
console.log('用户列表API响应:', response); // console.log('用户列表API响应:', response);
if (response.error) { if (response.error) {
console.error('获取用户列表失败:', response.error); console.error('获取用户列表失败:', response.error);
@@ -150,43 +237,115 @@ export async function getUserList(params: {
} }
} }
/**
* 在组织架构树中查找指定节点并提取其用户
* @param organizations 组织架构数据
* @param targetOuId 目标节点的 ou_id
* @returns 目标节点的用户列表转换为树节点
*/
export function extractUsersFromNode(organizations: OrganizationNode[], targetOuId: string): TreeNodeItem[] {
// 递归查找目标节点
function findNode(nodes: OrganizationNode[]): OrganizationNode | null {
for (const node of nodes) {
if (node.ou_id === targetOuId) {
return node;
}
if (node.children && node.children.length > 0) {
const found = findNode(node.children);
if (found) return found;
}
}
return null;
}
const targetNode = findNode(organizations);
if (!targetNode) {
console.warn('[extractUsersFromNode] 未找到目标节点:', targetOuId);
return [];
}
// 将该节点的用户转换为树节点
if (targetNode.users && targetNode.users.length > 0) {
return targetNode.users.map(user => convertUserToTreeNode(user));
}
return [];
}
/** /**
* 将组织架构数据转换为前端树形选择器格式 * 将组织架构数据转换为前端树形选择器格式
* @param organizations 组织架构数据 * @param organizations 组织架构数据
* @returns 前端树形选择器格式的数据 * @returns 前端树形选择器格式的数据
*/ */
export function convertToTreeData(organizations: OrganizationNode[]): Array<{ export function convertToTreeData(organizations: OrganizationNode[]): TreeNodeItem[] {
label: string;
value: string;
children?: Array<{
label: string;
value: string;
isUser?: boolean;
userInfo?: UserInfo;
}>;
}> {
return organizations.map(org => { return organizations.map(org => {
// 递归处理子组织 // 递归处理子组织
const subOrganizations = org.children && org.children.length > 0 ? convertToTreeData(org.children) : []; const subOrganizations = org.children && org.children.length > 0 ? convertToTreeData(org.children) : [];
// 添加该组织下的用户 // 添加该组织下的用户
const userChildren = (org.users && org.users.length > 0) const userChildren = (org.users && org.users.length > 0)
? org.users.map(user => ({ ? org.users.map(user => convertUserToTreeNode(user))
label: user.nick_name,
value: `user_${user.id}`,
isUser: true,
userInfo: user
}))
: []; : [];
// 合并子组织和用户 // 合并子组织和用户
const children = [...subOrganizations, ...userChildren]; const children = [...subOrganizations, ...userChildren];
// 判断是否已加载:
// 1. 如果有子组织,说明数据已经完整(初始加载会返回完整的3层结构)
// 2. 如果没有子组织但有用户,说明是叶子节点且用户已加载
// 3. 如果既没有子组织也没有用户,说明是叶子节点但用户未加载(需要懒加载)
const hasSubOrgs = org.children && org.children.length > 0;
const hasUsers = org.users && org.users.length > 0;
// hasChildren 为 true 的情况:
// - 有子组织(可以展开查看下级)
// - 没有用户且没有子组织(可能需要懒加载用户)
const canExpand = hasSubOrgs || (!hasSubOrgs && !hasUsers);
return { return {
label: org.ou_name, label: org.ou_name,
value: org.ou_id, value: org.ou_id,
isUser: false,
hasChildren: canExpand,
isLoaded: hasSubOrgs || hasUsers, // 有子组织或用户的节点视为已加载
children: children.length > 0 ? children : undefined children: children.length > 0 ? children : undefined
}; };
}); });
} }
/**
* 将用户信息转换为树节点格式
* @param user 用户信息
* @returns 树节点
*/
export function convertUserToTreeNode(user: UserInfo): TreeNodeItem {
return {
label: user.nick_name,
value: `user_${user.id}`,
isUser: true,
hasChildren: false,
userInfo: user
};
}
/**
* 将搜索结果转换为树节点列表
* @param users 用户列表
* @returns 树节点列表
*/
export function convertSearchResultsToTreeNodes(users: UserInfo[]): TreeNodeItem[] {
return users.map(user => convertUserToTreeNode(user));
}
// 树节点类型(用于 MultiCascader 组件)
export interface TreeNodeItem {
label: string;
value: string;
isUser: boolean;
hasChildren: boolean;
userInfo?: UserInfo;
children?: TreeNodeItem[];
}
/** /**
* 获取扁平化组织列表 * 获取扁平化组织列表
* @param includeUsers 是否包含用户信息 * @param includeUsers 是否包含用户信息
@@ -194,13 +353,13 @@ export function convertToTreeData(organizations: OrganizationNode[]): Array<{
*/ */
export async function getFlatOrganizations(includeUsers: boolean = true): Promise<ApiResponse<OrganizationResponse>> { export async function getFlatOrganizations(includeUsers: boolean = true): Promise<ApiResponse<OrganizationResponse>> {
try { try {
console.log('开始调用获取扁平化组织列表API'); // console.log('开始调用获取扁平化组织列表API');
const response = await get<OrganizationResponse>( const response = await get<OrganizationResponse>(
`/admin/users/organizations/flat?include_users=${includeUsers}` `/admin/users/organizations/flat?include_users=${includeUsers}`
); );
console.log('扁平化组织列表API响应:', response); // console.log('扁平化组织列表API响应:', response);
if (response.error) { if (response.error) {
console.error('获取扁平化组织列表失败:', response.error); console.error('获取扁平化组织列表失败:', response.error);
+360 -202
View File
@@ -1,254 +1,407 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
type Option = { // 组织路径信息(懒加载接口返回)
value: string; export interface OrganizationPath {
tenant_name: string;
dep_name: string;
dep_short_name: string;
ou_name: string;
}
// 导入类型
export interface UserInfo {
id: number;
username: string;
nick_name: string;
area: string;
ou_id: string;
ou_name: string;
is_leader: boolean;
status: number;
// 以下字段在搜索接口中有值,在懒加载接口中为 null
tenant_name: string | null;
dep_name: string | null;
dep_short_name: string | null;
email?: string;
phone_number?: string;
// 懒加载接口返回的组织路径信息
organization_path?: OrganizationPath | null;
}
export interface TreeNodeItem {
label: string; label: string;
children?: Option[]; value: string;
}; isUser: boolean;
hasChildren: boolean;
userInfo?: UserInfo;
children?: TreeNodeItem[];
// 懒加载状态
isLoading?: boolean;
isLoaded?: boolean;
}
type MultiCascaderProps = { type MultiCascaderProps = {
options: Option[]; options: TreeNodeItem[];
defaultValue?: string[]; // 已选择的用户ID列表(用于判断按钮状态)
value?: string[]; selectedUsers?: string[];
onChange?: (value: string[]) => void;
placeholder?: string; placeholder?: string;
maxHeight?: number; // 下拉框最大高度,默认300px maxHeight?: number;
searchable?: boolean; // 是否显示搜索框,默认false searchable?: boolean;
searchPlaceholder?: string; // 搜索框占位符 searchPlaceholder?: string;
}; // 懒加载回调
onLoadChildren?: (node: TreeNodeItem) => Promise<TreeNodeItem[]>;
// 获取所有叶子节点的值 // 搜索用户回调
const getAllLeafValues = (option: Option): string[] => { onSearchUsers?: (keyword: string) => Promise<TreeNodeItem[]>;
if (!option.children || option.children.length === 0) { // 树数据变化回调(懒加载后同步更新父组件的树数据)
return [option.value]; onTreeDataChange?: (treeData: TreeNodeItem[]) => void;
} // 用户从树中添加时的回调
return option.children.flatMap(getAllLeafValues); onAddUser?: (userNode: TreeNodeItem) => void;
}; // 用户从搜索结果添加时的回调
onSearchUserAdded?: (userNode: TreeNodeItem) => void;
// 检查节点是否所有子节点都被选中
const isAllChildrenChecked = (option: Option, selected: string[]): boolean => {
if (!option.children || option.children.length === 0) {
return selected.includes(option.value);
}
// 对于有子节点的节点,检查其所有叶子节点是否都被选中
const leafValues = getAllLeafValues(option);
return leafValues.every(value => selected.includes(value));
};
// 检查节点是否有部分子节点被选中
const isSomeChildrenChecked = (option: Option, selected: string[]): boolean => {
if (!option.children || option.children.length === 0) {
return selected.includes(option.value);
}
// 对于有子节点的节点,检查其叶子节点是否有部分被选中
const leafValues = getAllLeafValues(option);
return leafValues.some(value => selected.includes(value));
};
// 递归过滤选项,保留匹配项及其父级
const filterOptions = (options: Option[], keyword: string): Option[] => {
if (!keyword.trim()) return options;
const lowerKeyword = keyword.toLowerCase();
return options.reduce<Option[]>((acc, option) => {
// 检查当前节点是否匹配
const isCurrentMatch = option.label.toLowerCase().includes(lowerKeyword);
// 递归检查子节点
const filteredChildren = option.children ? filterOptions(option.children, keyword) : [];
// 如果当前节点匹配,或者有匹配的子节点,则保留
if (isCurrentMatch || filteredChildren.length > 0) {
acc.push({
...option,
children: isCurrentMatch
? option.children // 如果父节点匹配,保留所有子节点
: filteredChildren.length > 0
? filteredChildren // 如果只是子节点匹配,只保留匹配的子节点
: option.children
});
}
return acc;
}, []);
};
// 获取过滤后需要展开的所有父节点
const getExpandedKeysForFilter = (options: Option[], keyword: string): Set<string> => {
const keys = new Set<string>();
if (!keyword.trim()) return keys;
const lowerKeyword = keyword.toLowerCase();
const traverse = (opts: Option[], parentKeys: string[]) => {
for (const opt of opts) {
const isMatch = opt.label.toLowerCase().includes(lowerKeyword);
const hasChildren = opt.children && opt.children.length > 0;
if (hasChildren) {
// 递归检查子节点
const childMatches = traverse(opt.children!, [...parentKeys, opt.value]);
// 如果子节点有匹配,展开当前节点
if (childMatches) {
keys.add(opt.value);
parentKeys.forEach(k => keys.add(k));
}
}
// 如果当前节点匹配且有父节点,展开父节点
if (isMatch && parentKeys.length > 0) {
parentKeys.forEach(k => keys.add(k));
return true;
}
}
return false;
};
traverse(options, []);
return keys;
}; };
const MultiCascader: React.FC<MultiCascaderProps> = ({ const MultiCascader: React.FC<MultiCascaderProps> = ({
options, options,
defaultValue = [], selectedUsers = [],
value,
onChange,
placeholder = '请选择', placeholder = '请选择',
maxHeight = 300, maxHeight = 300,
searchable = false, searchable = false,
searchPlaceholder = '搜索...' searchPlaceholder = '搜索...',
onLoadChildren,
onSearchUsers,
onTreeDataChange,
onAddUser,
onSearchUserAdded
}) => { }) => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [selected, setSelected] = useState<string[]>(value ?? defaultValue);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set()); const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
const [searchKeyword, setSearchKeyword] = useState(''); const [searchKeyword, setSearchKeyword] = useState('');
const [searchResults, setSearchResults] = useState<TreeNodeItem[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [localOptions, setLocalOptions] = useState<TreeNodeItem[]>(options);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const loadingNodesRef = useRef<Set<string>>(new Set());
const prevOptionsRef = useRef<TreeNodeItem[]>(options);
// 外部 value 变化时,同步内部状态 // 同步外部 options 变化
useEffect(() => { useEffect(() => {
if (value !== undefined) { setLocalOptions(options);
setSelected(value); }, [options]);
}
}, [value]);
// 同步树数据变化到父组件(懒加载后触发)
useEffect(() => {
// 只有当 localOptions 真正变化时才调用回调,避免无限循环
if (onTreeDataChange && localOptions !== prevOptionsRef.current) {
prevOptionsRef.current = localOptions;
onTreeDataChange(localOptions);
}
}, [localOptions, onTreeDataChange]);
// 点击外部关闭
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setVisible(false); setVisible(false);
setSearchKeyword('');
setSearchResults([]);
} }
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
// 搜索关键词变化时自动展开匹配项
useEffect(() => {
if (searchKeyword.trim()) {
const keysToExpand = getExpandedKeysForFilter(options, searchKeyword);
setExpandedKeys(keysToExpand);
}
}, [searchKeyword, options]);
// 下拉框打开时聚焦搜索框 // 下拉框打开时聚焦搜索框
useEffect(() => { useEffect(() => {
if (visible && searchable && searchInputRef.current) { if (visible && searchable && searchInputRef.current) {
setTimeout(() => searchInputRef.current?.focus(), 100); setTimeout(() => searchInputRef.current?.focus(), 100);
} }
if (!visible) { if (!visible) {
setSearchKeyword(''); // 关闭时清空搜索 setSearchKeyword('');
setSearchResults([]);
} }
}, [visible, searchable]); }, [visible, searchable]);
// 过滤后的选项 // 搜索用户
const filteredOptions = searchable ? filterOptions(options, searchKeyword) : options; const handleSearch = useCallback(async (keyword: string) => {
setSearchKeyword(keyword);
const handleItemCheck = (option: Option, checked: boolean) => { if (!keyword.trim() || !onSearchUsers) {
const leafValues = getAllLeafValues(option); setSearchResults([]);
let newSelected: string[]; return;
if (checked) {
newSelected = Array.from(new Set([...selected, ...leafValues]));
} else {
newSelected = selected.filter(v => !leafValues.includes(v));
} }
setSelected(newSelected);
onChange?.(newSelected);
};
const getDisplayText = () => { setSearchLoading(true);
if (selected.length === 0) return placeholder; try {
return `已选择 ${selected.length}`; const results = await onSearchUsers(keyword);
}; setSearchResults(results);
} catch (error) {
console.error('搜索用户失败:', error);
setSearchResults([]);
} finally {
setSearchLoading(false);
}
}, [onSearchUsers]);
const toggleExpand = (key: string) => { // 防抖搜索
setExpandedKeys(prev => { useEffect(() => {
const newSet = new Set(prev); const timer = setTimeout(() => {
if (newSet.has(key)) { if (searchKeyword && searchable && onSearchUsers) {
newSet.delete(key); handleSearch(searchKeyword);
} else {
newSet.add(key);
} }
return newSet; }, 300);
return () => clearTimeout(timer);
}, [searchKeyword, searchable, onSearchUsers, handleSearch]);
// 懒加载子节点
const handleExpand = async (node: TreeNodeItem) => {
// 如果已经加载过(有子组织或用户),则直接展开/折叠,不调用API
if (node.isLoaded) {
setExpandedKeys(prev => {
const newSet = new Set(prev);
if (newSet.has(node.value)) {
newSet.delete(node.value); // 折叠
} else {
newSet.add(node.value); // 展开
}
return newSet;
});
return;
}
// 正在加载中,直接展开已加载的部分
if (loadingNodesRef.current.has(node.value)) {
setExpandedKeys(prev => {
const newSet = new Set(prev);
newSet.add(node.value);
return newSet;
});
return;
}
// 标记为加载中
loadingNodesRef.current.add(node.value);
setLocalOptions(prev => updateNodeLoading(prev, node.value, true));
try {
if (onLoadChildren) {
const children = await onLoadChildren(node);
// 更新本地选项
setLocalOptions(prev => updateNodeChildren(prev, node.value, children, true));
}
// 展开节点
setExpandedKeys(prev => {
const newSet = new Set(prev);
newSet.add(node.value);
return newSet;
});
} catch (error) {
console.error('加载子节点失败:', error);
} finally {
loadingNodesRef.current.delete(node.value);
setLocalOptions(prev => updateNodeLoading(prev, node.value, false));
}
};
// 更新节点加载状态
const updateNodeLoading = (nodes: TreeNodeItem[], value: string, isLoading: boolean): TreeNodeItem[] => {
return nodes.map(node => {
if (node.value === value) {
return { ...node, isLoading };
}
if (node.children) {
return { ...node, children: updateNodeLoading(node.children, value, isLoading) };
}
return node;
}); });
}; };
const renderOption = (option: Option, level: number = 0) => { // 更新节点的子节点
const allChecked = isAllChildrenChecked(option, selected); const updateNodeChildren = (
const someChecked = isSomeChildrenChecked(option, selected); nodes: TreeNodeItem[],
const hasChildren = option.children && option.children.length > 0; value: string,
const isExpanded = expandedKeys.has(option.value); children: TreeNodeItem[],
isLoaded: boolean
): TreeNodeItem[] => {
return nodes.map(node => {
if (node.value === value) {
return {
...node,
children,
isLoaded,
hasChildren: children.length > 0
};
}
if (node.children) {
return {
...node,
children: updateNodeChildren(node.children, value, children, isLoaded)
};
}
return node;
});
};
// 检查用户是否已添加
const isUserAdded = (value: string): boolean => {
return selectedUsers.includes(value);
};
// 从树中添加用户
const handleAddUser = (userNode: TreeNodeItem) => {
if (isUserAdded(userNode.value)) {
return; // 已添加,不处理
}
onAddUser?.(userNode);
};
// 从搜索结果添加用户
const handleAddSearchUser = (userNode: TreeNodeItem) => {
if (isUserAdded(userNode.value)) {
return; // 已添加,不处理
}
onSearchUserAdded?.(userNode);
};
// 渲染组织/用户节点
const renderNode = (node: TreeNodeItem, level: number = 0, parentKey: string = ''): React.ReactNode => {
const hasChildren = node.hasChildren;
const isExpanded = expandedKeys.has(node.value);
const isLoading = node.isLoading;
const isLoaded = node.isLoaded;
const isAdded = isUserAdded(node.value);
const nodeKey = parentKey ? `${parentKey}-${node.value}` : node.value;
return ( return (
<div key={option.value} className={level > 0 ? "pl-4" : ""}> <div key={nodeKey} className={level > 0 ? "ml-4" : ""}>
<div className="flex items-center py-1"> {/* 组织节点:点击整行可展开/收缩 */}
<input {hasChildren ? (
type="checkbox" <div
className="h-4 w-4 rounded border-gray-300" className="flex items-center py-1 px-2 rounded cursor-pointer hover:bg-gray-100 transition-colors"
style={{ onClick={() => handleExpand(node)}
accentColor: '#00684a', // 固定为绿色(烟草企业绿) >
cursor: 'pointer' {/* 展开/折叠图标 */}
}} {isLoading ? (
checked={allChecked} <i className="ri-loader-4-line animate-spin text-gray-400 mr-2"></i>
ref={el => { if (el) el.indeterminate = !allChecked && someChecked; }} ) : isExpanded ? (
onChange={e => handleItemCheck(option, e.target.checked)} <i className="ri-arrow-down-s-line text-gray-400 mr-2"></i>
id={`cascader-${option.value}`} ) : (
/> <i className="ri-arrow-right-s-line text-gray-400 mr-2"></i>
<label htmlFor={`cascader-${option.value}`} className="ml-2 text-sm flex-1 cursor-pointer"> )}
{option.label} {/* 节点标签 */}
</label> <span className="text-sm flex-1" title={node.label}>
{hasChildren && ( {node.label}
<button </span>
type="button" </div>
className="ml-2" ) : (
style={{ color: 'gray' }} // 展开图标固定为绿色 /* 用户节点 */
onClick={(e) => { <div className="flex items-center py-1 px-2 rounded hover:bg-gray-100 transition-colors">
e.stopPropagation(); {/* 用户节点:显示添加按钮 */}
toggleExpand(option.value); <span className="text-sm flex-1" title={node.label}>
}} {node.label}
> </span>
<i className={isExpanded ? "ri-arrow-down-s-line" : "ri-arrow-right-s-line"}></i> {node.isUser && (
</button> <button
)} type="button"
</div> className={`ml-2 w-6 h-6 rounded flex items-center justify-center text-sm ${
{hasChildren && isExpanded && ( isAdded
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-green-800 text-white hover:bg-green-700'
}`}
onClick={(e) => {
e.stopPropagation();
handleAddUser(node);
}}
disabled={isAdded}
title={isAdded ? '已添加' : '添加成员'}
>
{isAdded ? '✓' : '+'}
</button>
)}
</div>
)}
{/* 渲染子节点 - 只有当有实际子节点时才渲染 */}
{node.children && node.children.length > 0 && isExpanded && (
<div className="ml-4 border-l border-gray-200 pl-2"> <div className="ml-4 border-l border-gray-200 pl-2">
{option.children?.map(child => ( {node.children.map(child => renderNode(child, level + 1, nodeKey))}
<React.Fragment key={child.value}>
{renderOption(child, level + 1)}
</React.Fragment>
))}
</div> </div>
)} )}
</div> </div>
); );
}; };
// 渲染搜索结果
const renderSearchResults = () => {
if (searchLoading) {
return (
<div className="p-4 text-center text-gray-400 text-sm">
<i className="ri-loader-4-line animate-spin text-lg block mb-1"></i>
...
</div>
);
}
if (searchResults.length === 0) {
return (
<div className="p-4 text-center text-gray-400 text-sm">
<i className="ri-search-line text-lg block mb-1"></i>
</div>
);
}
return (
<div className="p-2">
<div className="text-xs text-gray-500 mb-2 px-1">
{searchResults.length}
</div>
{searchResults.map(user => (
<div
key={user.value}
className="flex items-center justify-between py-2 px-2 hover:bg-gray-50 rounded"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{user.userInfo?.nick_name || user.label}
</div>
<div className="text-xs text-gray-500 truncate">
{/* 优先使用 organization_path 中的值(懒加载接口),为空则使用顶层字段(搜索接口) */}
{user.userInfo?.organization_path?.tenant_name || user.userInfo?.tenant_name} · {user.userInfo?.ou_name}
</div>
</div>
<button
type="button"
onClick={() => handleAddSearchUser(user)}
className={`ml-2 px-3 py-1 text-xs rounded ${
isUserAdded(user.value)
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
: 'bg-green-800 text-white hover:bg-green-700'
}`}
disabled={isUserAdded(user.value)}
>
{isUserAdded(user.value) ? '已添加' : '添加'}
</button>
</div>
))}
</div>
);
};
// 显示文本
const getDisplayText = () => {
if (selectedUsers.length === 0) return placeholder;
return `已选择 ${selectedUsers.length} 位成员`;
};
return ( return (
<div ref={containerRef} className="relative inline-block w-full"> <div ref={containerRef} className="relative inline-block w-full">
{/* 触发器 */}
<div <div
className="border border-gray-300 rounded px-3 py-2 cursor-pointer bg-white flex justify-between items-center focus:outline-none focus:ring-2 focus:ring-[var(--color-primary,#00684a)] focus:border-[var(--color-primary,#00684a)]" className="border border-gray-300 rounded px-3 py-2 cursor-pointer bg-white flex justify-between items-center focus:outline-none focus:ring-2 focus:ring-[var(--color-primary,#00684a)] focus:border-[var(--color-primary,#00684a)]"
role="button" role="button"
@@ -261,13 +414,15 @@ const MultiCascader: React.FC<MultiCascaderProps> = ({
}} }}
onClick={() => setVisible(!visible)} onClick={() => setVisible(!visible)}
> >
<span className={selected.length === 0 ? 'text-gray-400' : ''}> <span className={selectedUsers.length === 0 ? 'text-gray-400' : ''}>
{getDisplayText()} {getDisplayText()}
</span> </span>
<span className="text-gray-400"> <span className="text-gray-400">
<i className={visible ? "ri-arrow-up-s-line" : "ri-arrow-down-s-line"}></i> <i className={visible ? "ri-arrow-up-s-line" : "ri-arrow-down-s-line"}></i>
</span> </span>
</div> </div>
{/* 下拉面板 */}
{visible && ( {visible && (
<div className="absolute z-10 bg-white border border-gray-300 rounded shadow-lg mt-1 w-full"> <div className="absolute z-10 bg-white border border-gray-300 rounded shadow-lg mt-1 w-full">
{/* 搜索框 */} {/* 搜索框 */}
@@ -278,10 +433,10 @@ const MultiCascader: React.FC<MultiCascaderProps> = ({
<input <input
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary,#00684a)] focus:border-[var(--color-primary,#00684a)]" className="w-full pl-8 pr-8 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary,#00684a)] focus:border-[var(--color-primary,#00684a)]"
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={searchKeyword} value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
{searchKeyword && ( {searchKeyword && (
@@ -291,6 +446,7 @@ const MultiCascader: React.FC<MultiCascaderProps> = ({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setSearchKeyword(''); setSearchKeyword('');
setSearchResults([]);
}} }}
> >
<i className="ri-close-line"></i> <i className="ri-close-line"></i>
@@ -299,14 +455,16 @@ const MultiCascader: React.FC<MultiCascaderProps> = ({
</div> </div>
</div> </div>
)} )}
{/* 选项列表 */}
{/* 内容区域 */}
<div className="p-2 overflow-y-auto" style={{ maxHeight: `${maxHeight}px` }}> <div className="p-2 overflow-y-auto" style={{ maxHeight: `${maxHeight}px` }}>
{filteredOptions.length > 0 ? ( {searchKeyword && searchable ? (
filteredOptions.map(option => renderOption(option)) renderSearchResults()
) : localOptions.length > 0 ? (
localOptions.map(option => renderNode(option))
) : ( ) : (
<div className="text-center text-gray-400 py-4 text-sm"> <div className="text-center text-gray-400 py-4 text-sm">
<i className="ri-search-line text-lg block mb-1"></i>
</div> </div>
)} )}
</div> </div>
@@ -316,4 +474,4 @@ const MultiCascader: React.FC<MultiCascaderProps> = ({
); );
}; };
export default MultiCascader; export default MultiCascader;
+10 -2
View File
@@ -239,7 +239,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
user_id: savedUserInfo.user_id, user_id: savedUserInfo.user_id,
user_role: savedUserInfo.user_role, // 使用后端返回的角色 user_role: savedUserInfo.user_role, // 使用后端返回的角色
area: savedUserInfo.area, // 🔑 用户所属地区 area: savedUserInfo.area, // 🔑 用户所属地区
frontend_jwt: frontendJWT frontend_jwt: frontendJWT,
// 🔑 包含后端返回的组织信息字段(可能为null)
tenant_name: savedUserInfo.tenant_name,
dep_name: savedUserInfo.dep_name,
dep_short_name: savedUserInfo.dep_short_name,
}; };
// 🔑 重要:将 token 和用户信息作为 URL 参数传递给客户端 // 🔑 重要:将 token 和用户信息作为 URL 参数传递给客户端
@@ -257,7 +261,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
is_leader: savedUserInfo.is_leader, is_leader: savedUserInfo.is_leader,
user_role: savedUserInfo.user_role, user_role: savedUserInfo.user_role,
area: savedUserInfo.area, // 🔑 用户所属地区 area: savedUserInfo.area, // 🔑 用户所属地区
sub: userInfo.data.sub sub: userInfo.data.sub,
// 🔑 包含后端返回的组织信息字段(可能为null)
tenant_name: savedUserInfo.tenant_name,
dep_name: savedUserInfo.dep_name,
dep_short_name: savedUserInfo.dep_short_name,
}))); })));
callbackUrl.searchParams.set('redirectTo', redirectTo); callbackUrl.searchParams.set('redirectTo', redirectTo);
+301 -171
View File
@@ -21,10 +21,16 @@ import {
getCrossCheckingDocumentTypes, getCrossCheckingDocumentTypes,
type DocumentType type DocumentType
} from "~/api/cross-checking/cross-files"; } from "~/api/cross-checking/cross-files";
import { import {
getOrganizationTree, getOrganizationTree,
convertToTreeData convertToTreeData,
} from "~/api/user"; extractUsersFromNode,
convertUserToTreeNode,
convertSearchResultsToTreeNodes,
searchUsers,
type TreeNodeItem,
type UserInfo
} from "~/api/user/user-management";
import { API_BASE_URL } from '~/config/api-config'; import { API_BASE_URL } from '~/config/api-config';
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
@@ -52,82 +58,53 @@ const STEPS = [
{ id: 3, label: "选择卷宗" } { id: 3, label: "选择卷宗" }
]; ];
// 1. TreeNode类型和MOCK_TREE
export interface TreeNode {
label: string;
value: string;
children?: TreeNode[];
}
// 默认的空组织架构数据(作为备用) // 默认的空组织架构数据(作为备用)
const DEFAULT_TREE: TreeNode[] = []; const DEFAULT_TREE: TreeNodeItem[] = [];
// 用户选择状态管理 // 用户选择状态管理
interface UserSelectionState { interface UserSelectionState {
treeData: TreeNode[]; treeData: TreeNodeItem[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
} }
function isAllChildrenChecked(node: TreeNode, checked: string[]): boolean { // 获取用户完整信息
if (!node.children || node.children.length === 0) return checked.includes(node.value); const getUserFullName = (value: string, treeData: TreeNodeItem[]): string => {
return node.children.every(child => isAllChildrenChecked(child, checked)); for (const node of treeData) {
} if (node.value === value && node.isUser) {
function isSomeChildrenChecked(node: TreeNode, checked: string[]): boolean { return `${node.userInfo?.nick_name || node.label} (${node.userInfo?.tenant_name} · ${node.userInfo?.ou_name})`;
if (!node.children || node.children.length === 0) return checked.includes(node.value); }
return node.children.some(child => isSomeChildrenChecked(child, checked)); if (node.children) {
} const found = getUserFullName(value, node.children);
const TreeNodeCheckbox: React.FC<{ if (found) return found;
node: TreeNode; }
checked: string[]; }
onCheck: (node: TreeNode, checked: boolean) => void; return value;
level?: number; };
}> = ({ node, checked, onCheck, level = 0 }) => {
const [expanded, setExpanded] = React.useState(true); // 从用户ID获取用户信息(优先从搜索用户Map中查找,再从树数据中查找)
const allChecked = isAllChildrenChecked(node, checked); const getUserInfoById = (
const someChecked = isSomeChildrenChecked(node, checked); userId: string,
const isLeaf = !node.children || node.children.length === 0; treeData: TreeNodeItem[],
return ( searchedUsersMap?: Map<string, UserInfo>
<div style={{ marginLeft: level * 18 }}> ): UserInfo | null => {
<div className="flex items-center"> // 先从搜索用户Map中查找
{!isLeaf && ( if (searchedUsersMap && searchedUsersMap.has(userId)) {
<span return searchedUsersMap.get(userId)!;
className="mr-1 cursor-pointer select-none" }
onClick={() => setExpanded(e => !e)}
role="button" // 再从树数据中查找
tabIndex={0} for (const node of treeData) {
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && setExpanded(e => !e)} if (node.value === userId && node.isUser && node.userInfo) {
style={{ width: 16, display: "inline-block", textAlign: "center" }} return node.userInfo;
> }
<i className={expanded ? "ri-arrow-down-s-line" : "ri-arrow-right-s-line"}></i> if (node.children) {
</span> const found = getUserInfoById(userId, node.children, searchedUsersMap);
)} if (found) return found;
<input }
type="checkbox" }
className="form-checkbox" return null;
checked={allChecked}
ref={el => { if (el) el.indeterminate = !allChecked && someChecked; }}
onChange={e => onCheck(node, e.target.checked)}
id={node.value}
/>
<label htmlFor={node.value} className="ml-2">{node.label}</label>
</div>
{expanded && node.children && (
<div>
{node.children.map(child => (
<TreeNodeCheckbox
key={child.value}
node={child}
checked={checked}
onCheck={onCheck}
level={level + 1}
/>
))}
</div>
)}
</div>
);
}; };
/** /**
* 获取用户会话和前端JWT,以及文档类型列表 * 获取用户会话和前端JWT,以及文档类型列表
@@ -153,7 +130,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const caseType = formData.get("caseType") as string; const caseType = formData.get("caseType") as string;
const uploadType = formData.get("uploadType") as string; const uploadType = formData.get("uploadType") as string;
console.log("交叉评查上传:", { caseType, uploadType }); // console.log("交叉评查上传:", { caseType, uploadType });
// 这里可以处理上传后的业务逻辑 // 这里可以处理上传后的业务逻辑
// 例如创建任务记录等 // 例如创建任务记录等
@@ -188,6 +165,8 @@ export default function CrossCheckingUpload() {
loading: false, loading: false,
error: null error: null
}); });
// 存储从搜索结果添加的用户信息(这些用户不在 treeData 中)
const [searchedUsers, setSearchedUsers] = useState<Map<string, UserInfo>>(new Map());
// 上传配置状态 - 设置默认值 // 上传配置状态 - 设置默认值
const [priority] = useState<string>("normal"); const [priority] = useState<string>("normal");
@@ -215,7 +194,7 @@ export default function CrossCheckingUpload() {
// 清空已选择的文件和重置上传方式 // 清空已选择的文件和重置上传方式
clearAllFiles(); clearAllFiles();
const selectedType = documentTypes?.find((dt: DocumentType) => dt.id === docTypeId); const selectedType = documentTypes?.find((dt: DocumentType) => dt.id === docTypeId);
console.log("案卷类型切换为:", selectedType?.name, "ID:", docTypeId); // console.log("案卷类型切换为:", selectedType?.name, "ID:", docTypeId);
}; };
// 清空文件 // 清空文件
@@ -264,7 +243,7 @@ export default function CrossCheckingUpload() {
uploadType uploadType
}); });
console.log("选择文件:", file.name, "类型:", uploadType); // console.log("选择文件:", file.name, "类型:", uploadType);
}; };
// 删除文件 // 删除文件
@@ -322,7 +301,7 @@ export default function CrossCheckingUpload() {
} }
// 第一步:上传文件并自动分配任务(新接口) // 第一步:上传文件并自动分配任务(新接口)
console.log("开始批量上传文件并分配任务:", filesToUpload.length, "个,案卷类型:", selectedDocType.name); // console.log("开始批量上传文件并分配任务:", filesToUpload.length, "个,案卷类型:", selectedDocType.name);
// 提取用户ID(从选中的组织架构中获取用户) // 提取用户ID(从选中的组织架构中获取用户)
const userIds = groupChecked.filter(id => { const userIds = groupChecked.filter(id => {
@@ -349,7 +328,7 @@ export default function CrossCheckingUpload() {
// frontendJWT // frontendJWT
// } // }
// console.log("requireParam", requireParam) // // console.log("requireParam", requireParam)
// return; // return;
// 构建负责人ID数组:当前用户(主要负责人)+ 额外负责人 // 构建负责人ID数组:当前用户(主要负责人)+ 额外负责人
@@ -499,44 +478,30 @@ export default function CrossCheckingUpload() {
const navigate = useNavigate(); const navigate = useNavigate();
// 加载组织架构数据 // 加载组织架构数据(首次只加载组织结构,不含用户)
useEffect(() => { useEffect(() => {
const loadOrganizationData = async () => { const loadOrganizationData = async () => {
// 只在步骤2且数据为空且未在加载时执行 // 只在步骤2且数据为空且未在加载时执行
if (currentStep === 2 && userSelectionState.treeData.length === 0 && !userSelectionState.loading) { if (currentStep === 2 && userSelectionState.treeData.length === 0 && !userSelectionState.loading) {
setUserSelectionState(prev => ({ ...prev, loading: true, error: null })); setUserSelectionState(prev => ({ ...prev, loading: true, error: null }));
try { try {
console.log('开始加载组织架构数据'); // console.log('[loadOrganizationData] 开始加载组织架构数据');
// 传递JWT token到API调用 // 首次只加载组织结构,不含用户(方案2
const response = await getOrganizationTree(true, frontendJWT); const response = await getOrganizationTree(false, frontendJWT);
if (response.success && response.data) { if (response.success && response.data) {
console.log('原始API数据:', response.data); // console.log('[loadOrganizationData] 原始API数据:', response.data);
const treeData = convertToTreeData(response.data.organizations); const treeData = convertToTreeData(response.data.organizations);
console.log('转换后的树形数据:', treeData); // console.log('[loadOrganizationData] 转换后的树形数据:', treeData);
// 验证数据转换是否正确
treeData.forEach(org => {
console.log(`组织: ${org.label} (${org.value})`);
if (org.children) {
org.children.forEach(child => {
if (child.isUser) {
console.log(` - 用户: ${child.label} (${child.value})`);
} else {
console.log(` - 子组织: ${child.label} (${child.value})`);
}
});
}
});
setUserSelectionState({ setUserSelectionState({
treeData, treeData,
loading: false, loading: false,
error: null error: null
}); });
} else { } else {
console.error('获取组织架构失败:', response.error); console.error('[loadOrganizationData] 获取组织架构失败:', response.error);
setUserSelectionState({ setUserSelectionState({
treeData: DEFAULT_TREE, treeData: DEFAULT_TREE,
loading: false, loading: false,
@@ -545,7 +510,7 @@ export default function CrossCheckingUpload() {
toastService.error('获取组织架构失败,请刷新页面重试'); toastService.error('获取组织架构失败,请刷新页面重试');
} }
} catch (error) { } catch (error) {
console.error('加载组织架构数据失败:', error); console.error('[loadOrganizationData] 加载组织架构数据失败:', error);
setUserSelectionState({ setUserSelectionState({
treeData: DEFAULT_TREE, treeData: DEFAULT_TREE,
loading: false, loading: false,
@@ -555,23 +520,108 @@ export default function CrossCheckingUpload() {
} }
} }
}; };
loadOrganizationData();
}, [currentStep]); // 只依赖 currentStep,避免无限循环
// 在 CrossCheckingUpload 组件内添加工具函数 loadOrganizationData();
function findUserNameById(tree: TreeNode[], userId: string): string | null { }, [currentStep, frontendJWT]);
for (const node of tree) {
if (node.value === userId && (node as { isUser?: boolean }).isUser) {
return node.label; // 懒加载子节点(点击部门时加载该部门的用户)
const handleLoadChildren = async (node: TreeNodeItem): Promise<TreeNodeItem[]> => {
// console.log('[handleLoadChildren] 懒加载子节点:', node.label, 'ou_id:', node.value);
try {
// 调用接口获取该部门的子树(含用户)
const response = await getOrganizationTree(true, frontendJWT, node.value);
if (response.success && response.data) {
const organizations = response.data.organizations;
if (organizations.length > 0) {
// 从返回的树中找到目标节点,提取其用户数据
const users = extractUsersFromNode(organizations, node.value);
// console.log('[handleLoadChildren] 提取到的用户数量:', users.length);
return users;
}
}
return [];
} catch (error) {
console.error('[handleLoadChildren] 加载子节点失败:', error);
throw error;
} }
if (node.children) { };
const found = findUserNameById(node.children, userId);
if (found) return found; // 搜索用户
const handleSearchUsers = async (keyword: string): Promise<TreeNodeItem[]> => {
// console.log('[handleSearchUsers] 搜索用户:', keyword);
try {
const response = await searchUsers(keyword, 1, 50, frontendJWT);
if (response.success && response.data?.users) {
return convertSearchResultsToTreeNodes(response.data.users);
}
return [];
} catch (error) {
console.error('[handleSearchUsers] 搜索用户失败:', error);
throw error;
} }
} };
return null;
} // 从localStorage读取当前登录用户的完整信息
useEffect(() => {
const loadCurrentUserInfoFromStorage = () => {
if (!currentUserId || !userInfo) {
return;
}
// 如果已经获取过,跳过
if (searchedUsers.has(currentUserId)) {
return;
}
try {
// 从localStorage读取user_info
const storedUserInfo = typeof window !== 'undefined'
? localStorage.getItem('user_info')
: null;
if (storedUserInfo) {
const parsedUserInfo = JSON.parse(storedUserInfo);
// console.log('[loadCurrentUserInfoFromStorage] 从localStorage读取用户信息:', parsedUserInfo);
// 构建符合UserInfo接口的数据
const fullUserInfo: UserInfo = {
id: parsedUserInfo.user_id || userInfo.user_id,
username: parsedUserInfo.username || userInfo.username,
nick_name: parsedUserInfo.nick_name || userInfo.nick_name,
area: parsedUserInfo.area || userInfo.area,
ou_id: parsedUserInfo.ou_id || userInfo.ou_id,
ou_name: parsedUserInfo.ou_name || userInfo.ou_name,
is_leader: parsedUserInfo.is_leader ?? false,
status: 0,
// 以下字段可能存在于localStorage中
tenant_name: parsedUserInfo.tenant_name || null,
dep_name: parsedUserInfo.dep_name || null,
dep_short_name: parsedUserInfo.dep_short_name || null,
email: parsedUserInfo.email,
phone_number: parsedUserInfo.phone_number
};
// 存储到searchedUsers中,供渲染时使用(使用currentUserId作为key
setSearchedUsers(prev => {
const newMap = new Map(prev);
newMap.set(currentUserId, fullUserInfo);
return newMap;
});
}
} catch (error) {
console.error('[loadCurrentUserInfoFromStorage] 读取localStorage用户信息失败:', error);
}
};
loadCurrentUserInfoFromStorage();
}, [currentUserId, userInfo]);
return ( return (
<div className="min-h-screen bg-gray-50 py-8"> <div className="min-h-screen bg-gray-50 py-8">
@@ -630,7 +680,7 @@ export default function CrossCheckingUpload() {
type="default" type="default"
icon="ri-arrow-left-line" icon="ri-arrow-left-line"
onClick={() => { onClick={() => {
console.log('点击返回列表按钮'); // console.log('点击返回列表按钮');
navigate('/cross-checking'); navigate('/cross-checking');
}} }}
> >
@@ -646,7 +696,7 @@ export default function CrossCheckingUpload() {
{currentStep === 2 && ( {currentStep === 2 && (
<> <>
<div className="flex justify-center"> <div className="flex justify-center">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6" style={{width: '1000px'}}> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6" style={{width: '1200px'}}>
<div className="flex flex-row justify-center gap-12"> <div className="flex flex-row justify-center gap-12">
{/* 左侧树状多选 */} {/* 左侧树状多选 */}
<div style={{ minWidth: 300, width: '40%' }}> <div style={{ minWidth: 300, width: '40%' }}>
@@ -665,21 +715,54 @@ export default function CrossCheckingUpload() {
) : ( ) : (
<MultiCascader <MultiCascader
options={userSelectionState.treeData} options={userSelectionState.treeData}
selectedUsers={groupChecked}
placeholder="请选择评查小组成员" placeholder="请选择评查小组成员"
value={groupChecked}
onChange={(values: string[]) => {
// 确保当前用户始终被选中
let newValues = values;
if (currentUserId && !values.includes(currentUserId)) {
newValues = [currentUserId, ...values];
}
setGroupChecked(newValues);
// 移除已被取消选中的负责人
setLeaderIds(prev => prev.filter(id => newValues.includes(id)));
}}
maxHeight={460} maxHeight={460}
searchable={true} searchable={true}
searchPlaceholder="搜索成员..." searchPlaceholder="搜索成员..."
onLoadChildren={handleLoadChildren}
onSearchUsers={handleSearchUsers}
onTreeDataChange={(newTreeData) => {
// 同步懒加载后的树数据到父组件
setUserSelectionState(prev => ({
...prev,
treeData: newTreeData
}));
}}
onAddUser={(userNode) => {
// 从树中添加用户
if (groupChecked.includes(userNode.value)) {
toastService.warning('该成员已添加');
return;
}
const newGroupChecked = [...groupChecked, userNode.value];
setGroupChecked(newGroupChecked);
// 存储用户信息
if (userNode.userInfo) {
setSearchedUsers(prev => {
const newMap = new Map(prev);
newMap.set(userNode.value, userNode.userInfo);
return newMap;
});
}
}}
onSearchUserAdded={(userNode) => {
// 从搜索结果添加用户
if (groupChecked.includes(userNode.value)) {
toastService.warning('该成员已添加');
return;
}
const newGroupChecked = [...groupChecked, userNode.value];
setGroupChecked(newGroupChecked);
// 存储用户信息
if (userNode.userInfo) {
setSearchedUsers(prev => {
const newMap = new Map(prev);
newMap.set(userNode.value, userNode.userInfo);
return newMap;
});
}
}}
/> />
)} )}
</div> </div>
@@ -696,22 +779,37 @@ export default function CrossCheckingUpload() {
if (b === currentUserId) return 1; if (b === currentUserId) return 1;
return 0; return 0;
}).map((member, index) => { }).map((member, index) => {
let displayName: string = member;
let displayOrg = '';
const isUser = member.startsWith('user_'); const isUser = member.startsWith('user_');
const isCurrentUser = member === currentUserId; const isCurrentUser = member === currentUserId;
const isLeader = leaderIds.includes(member); const isLeader = leaderIds.includes(member);
let displayName: string;
let tenantName: string;
let depName: string;
let ouName: string;
let fullOrgInfo: string; // 完整组织信息(用于tooltip)
if (isUser) { if (isUser) {
// 查找真实用户名 // 获取用户完整信息(优先从搜索用户Map中查找)
const userName = findUserNameById(userSelectionState.treeData, member) || member.replace('user_', ''); const userInfo = getUserInfoById(member, userSelectionState.treeData, searchedUsers);
displayName = userName; displayName = userInfo?.nick_name || member.replace('user_', '');
displayOrg = '用户';
// 优先使用 organization_path 中的值(懒加载接口),为空则使用顶层字段(搜索接口)
const orgPath = userInfo?.organization_path;
tenantName = orgPath?.tenant_name || userInfo?.tenant_name || '';
depName = orgPath?.dep_name || userInfo?.dep_name || '';
ouName = userInfo?.ou_name || '';
// 完整信息:分公司-部门
const depOuName = depName && ouName ? `${depName}-${ouName}` : (depName || ouName);
fullOrgInfo = depOuName;
} else { } else {
// 组织 // 组织(不应该有这种情况,因为只有用户才会被选中)
const parts = member.split('-'); displayName = member;
displayName = parts[parts.length - 1]; tenantName = '';
displayOrg = parts.slice(0, -1).join(' - ') || '组织'; depName = '';
ouName = '';
fullOrgInfo = '组织';
} }
return ( return (
@@ -725,46 +823,78 @@ export default function CrossCheckingUpload() {
: '' : ''
}`} }`}
> >
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="font-medium text-gray-800 flex items-center gap-2"> {/* 第一行:成员名称 + 总公司 */}
{displayName} <div className="font-medium text-gray-800 flex items-center gap-5">
{isCurrentUser && ( <span className="flex items-center gap-2">
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500 text-white"> {displayName}
<i className="ri-user-star-fill mr-0.5"></i> {isCurrentUser && (
</span> <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500 text-white">
)} <i className="ri-user-star-fill mr-0.5"></i>
{!isCurrentUser && isLeader && ( </span>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-primary)] text-white"> )}
<i className="ri-star-fill mr-0.5"></i> {!isCurrentUser && isLeader && (
</span> <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-primary)] text-white">
<i className="ri-star-fill mr-0.5"></i>
</span>
)}
</span>
{tenantName && (
<span className="text-gray-500 text-[10px]">{tenantName}</span>
)} )}
</div> </div>
<div className="text-gray-500 mt-1">{displayOrg}</div> {/* 第二行:分公司-部门(省略显示) */}
{(depName || ouName) && (
<div
className="text-gray-500 text-[10px] truncate mt-1"
title={fullOrgInfo}
>
{depName}-{ouName}
</div>
)}
</div> </div>
{/* 当前用户不能取消选中,也不显示设为负责人按钮 */} {/* 当前用户不能删除,也不显示设为负责人按钮 */}
{isCurrentUser ? ( {isCurrentUser ? (
<span className="ml-2 px-2 py-1 rounded text-[10px] bg-gray-100 text-gray-400 cursor-not-allowed"> <span className="ml-2 px-2 py-1 rounded text-[10px] bg-gray-100 text-gray-400 cursor-not-allowed">
</span> </span>
) : isUser ? ( ) : isUser ? (
<button <>
type="button" {/* 设为负责人/取消负责人按钮 */}
className={`ml-2 px-2 py-1 rounded text-[10px] transition-colors ${ <button
isLeader type="button"
? 'bg-gray-100 text-gray-500 hover:bg-gray-200' className={`ml-2 px-2 py-1 rounded text-[10px] transition-colors ${
: 'bg-[var(--color-primary-light)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white' isLeader
}`} ? 'bg-gray-100 text-gray-500 hover:bg-gray-200'
onClick={() => { : 'bg-[var(--color-primary-light)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white'
if (isLeader) { }`}
onClick={() => {
if (isLeader) {
setLeaderIds(prev => prev.filter(id => id !== member));
} else {
setLeaderIds(prev => [...prev, member]);
}
}}
title={isLeader ? '取消负责人' : '设为负责人'}
>
{isLeader ? '取消负责人' : '设为负责人'}
</button>
{/* 删除按钮 */}
<button
type="button"
className="ml-1 w-6 h-6 flex items-center justify-center rounded-full text-gray-400 hover:text-red-500 hover:bg-red-50 transition-colors"
onClick={() => {
// 从已选择列表中移除
const newGroupChecked = groupChecked.filter(id => id !== member);
setGroupChecked(newGroupChecked);
// 同时从负责人列表中移除
setLeaderIds(prev => prev.filter(id => id !== member)); setLeaderIds(prev => prev.filter(id => id !== member));
} else { }}
setLeaderIds(prev => [...prev, member]); title="删除"
} >
}} <i className="ri-close-line text-sm"></i>
title={isLeader ? '取消负责人' : '设为负责人'} </button>
> </>
{isLeader ? '取消负责人' : '设为负责人'}
</button>
) : null} ) : null}
</div> </div>
); );
@@ -805,7 +935,7 @@ export default function CrossCheckingUpload() {
type="default" type="default"
icon="ri-arrow-left-line" icon="ri-arrow-left-line"
onClick={() => { onClick={() => {
console.log('点击返回列表按钮'); // console.log('点击返回列表按钮');
navigate('/cross-checking'); navigate('/cross-checking');
}} }}
> >
@@ -974,7 +1104,7 @@ export default function CrossCheckingUpload() {
type="default" type="default"
icon="ri-arrow-left-line" icon="ri-arrow-left-line"
onClick={() => { onClick={() => {
console.log('点击返回列表按钮'); // console.log('点击返回列表按钮');
navigate('/cross-checking'); navigate('/cross-checking');
}} }}
> >
+13 -4
View File
@@ -148,6 +148,7 @@ export async function action({ request }: ActionFunctionArgs) {
} }
console.log("✅ [Login Action] 登录成功,准备创建 session"); console.log("✅ [Login Action] 登录成功,准备创建 session");
// console.log("📦 [Login Action] 后端返回完整数据:", response.data);
console.log("👤 [Login Action] 用户角色:", user_info.user_role); // 应该是 "admin" console.log("👤 [Login Action] 用户角色:", user_info.user_role); // 应该是 "admin"
console.log("⏰ [Login Action] Token 有效期:", expires_in, "秒 (", expires_in / 3600, "小时)"); console.log("⏰ [Login Action] Token 有效期:", expires_in, "秒 (", expires_in / 3600, "小时)");
@@ -168,8 +169,12 @@ export async function action({ request }: ActionFunctionArgs) {
ou_name: user_info.ou_name, ou_name: user_info.ou_name,
is_leader: user_info.is_leader, is_leader: user_info.is_leader,
user_role: user_info.user_role, user_role: user_info.user_role,
area: user_info.area, // 🔑 用户所属地区 area: user_info.area,
sub: user_info.sub sub: user_info.sub,
// 🔑 包含后端返回的组织信息字段(可能为null)
tenant_name: user_info.tenant_name,
dep_name: user_info.dep_name,
dep_short_name: user_info.dep_short_name,
}))); })));
callbackUrl.searchParams.set('redirectTo', redirectTo); callbackUrl.searchParams.set('redirectTo', redirectTo);
@@ -191,8 +196,12 @@ export async function action({ request }: ActionFunctionArgs) {
ou_name: user_info.ou_name, ou_name: user_info.ou_name,
is_leader: user_info.is_leader, is_leader: user_info.is_leader,
user_role: user_info.user_role, user_role: user_info.user_role,
area: user_info.area, // 🔑 用户所属地区 area: user_info.area,
sub: user_info.sub sub: user_info.sub,
// 🔑 包含后端返回的组织信息字段(可能为null)
tenant_name: user_info.tenant_name,
dep_name: user_info.dep_name,
dep_short_name: user_info.dep_short_name,
} }
}); });
+233 -107
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { ClientLoaderFunctionArgs, ClientActionFunctionArgs } from "@remix-run/react"; import { ClientLoaderFunctionArgs, ClientActionFunctionArgs } from "@remix-run/react";
import { Card } from "~/components/ui/Card"; import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button"; import { Button } from "~/components/ui/Button";
@@ -14,6 +14,7 @@ import {
getRolePermissions, getRolePermissions,
getRoleUsers, getRoleUsers,
getAllUsers, getAllUsers,
getUsersWithRoles,
assignUserRoles, assignUserRoles,
createRole, createRole,
updateRole, updateRole,
@@ -536,52 +537,76 @@ interface AssignUserModalProps {
} }
function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, currentUserArea }: AssignUserModalProps) { function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, currentUserArea }: AssignUserModalProps) {
// 用户列表数据
const [allUsers, setAllUsers] = useState<UserInfo[]>([]); const [allUsers, setAllUsers] = useState<UserInfo[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]); const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
// 搜索和分页状态
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
const [page, setPage] = useState(1);
const [pageSize] = useState(50); // 默认50条
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingUsers, setLoadingUsers] = useState(false); const [loadingUsers, setLoadingUsers] = useState(false);
// 存储每个用户的角色信息
const [userRolesMap, setUserRolesMap] = useState<Map<number, RoleInfo[]>>(new Map()); // 防抖搜索:使用 useCallback 和 useRef 实现
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 当搜索词变化时,500ms 后触发搜索
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
setPage(1); // 搜索时重置到第一页
}, 500);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchTerm]);
// 当模态框打开时,加载用户列表 // 当模态框打开时,加载用户列表
useEffect(() => { useEffect(() => {
if (isOpen && role) { if (isOpen && role) {
loadUsers(); loadUsers();
} }
}, [isOpen, role]); }, [isOpen, role, debouncedSearchTerm, page]); // 添加 page 依赖,分页变化时重新加载
// 加载所有用户及其角色信息 // 加载用户及其角色信息(使用新的 v3 API
const loadUsers = async () => { const loadUsers = async () => {
setLoadingUsers(true); setLoadingUsers(true);
try { try {
const users = await getAllUsers(); const params: { page: number; page_size: number; area?: string; nick_name?: string } = {
page,
page_size: pageSize
};
// v3.3: 市级管理员只能看到同地区的用户(使用 area 字段) // 市级管理员只能看到同地区的用户
let filteredUsers = users;
if (isCityAdmin && currentUserArea) { if (isCityAdmin && currentUserArea) {
filteredUsers = users.filter(user => user.area === currentUserArea); params.area = currentUserArea;
console.log('🔒 [AssignUserModal v3.3] 市级管理员用户过滤:', {
当前地区: currentUserArea,
原始用户数: users.length,
过滤后用户数: filteredUsers.length
});
} }
setAllUsers(filteredUsers); // 搜索关键词
if (debouncedSearchTerm.trim()) {
params.nick_name = debouncedSearchTerm.trim();
}
// 批量获取每个用户的角色 const result = await getUsersWithRoles(params);
const rolesMap = new Map<number, RoleInfo[]>();
await Promise.all( setAllUsers(result.items);
filteredUsers.map(async (user) => { setTotal(result.total);
const roles = await getUserRoles(user.id);
rolesMap.set(user.id, roles);
})
);
setUserRolesMap(rolesMap);
} catch (error) { } catch (error) {
console.error('加载用户列表失败:', error); console.error('❌ [AssignUserModal] 加载用户列表失败:', error);
toastService.error('加载用户列表失败'); toastService.error('加载用户列表失败');
setAllUsers([]);
setTotal(0);
} finally { } finally {
setLoadingUsers(false); setLoadingUsers(false);
} }
@@ -591,6 +616,9 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, curren
const resetState = () => { const resetState = () => {
setSelectedUserIds([]); setSelectedUserIds([]);
setSearchTerm(''); setSearchTerm('');
setDebouncedSearchTerm('');
setPage(1);
setTotal(0);
}; };
// 提交分配 // 提交分配
@@ -604,17 +632,17 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, curren
setLoading(true); setLoading(true);
try { try {
// 检查每个用户是否已有角色 // 检查每个用户是否已有角色(使用已加载的角色数据)
const usersWithRoles: Array<{ userId: number; userName: string; roleName: string }> = []; const usersWithRoles: Array<{ userId: number; userName: string; roleName: string }> = [];
for (const userId of selectedUserIds) { for (const userId of selectedUserIds) {
const userRoles = await getUserRoles(userId); const user = allUsers.find(u => u.id === userId);
if (userRoles.length > 0) { // 使用新 API 返回的 roles 数据
const user = allUsers.find(u => u.id === userId); if (user && user.roles && user.roles.length > 0) {
usersWithRoles.push({ usersWithRoles.push({
userId, userId,
userName: user?.nick_name || user?.username || `用户${userId}`, userName: user.nick_name || user.username || `用户${userId}`,
roleName: userRoles.map(r => r.role_name).join('、') roleName: user.roles.map(r => r.role_name).join('、')
}); });
} }
} }
@@ -665,28 +693,69 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, curren
onClose(); onClose();
}; };
// 全选/取消全选 // 全选/取消全选(当前页)
const handleToggleAll = () => { const handleToggleAll = () => {
const filteredUserIds = filteredUsers.map(u => u.id); const currentPageUserIds = allUsers.map(u => u.id);
if (selectedUserIds.length === filteredUserIds.length) { if (currentPageUserIds.every(id => selectedUserIds.includes(id))) {
setSelectedUserIds([]); // 当前页已全选,则取消全选
setSelectedUserIds(selectedUserIds.filter(id => !currentPageUserIds.includes(id)));
} else { } else {
setSelectedUserIds(filteredUserIds); // 全选当前页
const newSelectedIds = new Set([...selectedUserIds, ...currentPageUserIds]);
setSelectedUserIds(Array.from(newSelectedIds));
}
};
// 计算总页数
const totalPages = Math.ceil(total / pageSize);
// 生成页码列表
const getPageNumbers = (): (number | string)[] => {
const pages: (number | string)[] = [];
const maxVisiblePages = 7; // 最多显示7个页码按钮
if (totalPages <= maxVisiblePages) {
// 总页数小于等于最大显示数,显示所有页码
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// 总页数较多,需要省略号
pages.push(1); // 第一页
if (page > 3) {
pages.push('...');
}
// 当前页附近的页码
const startPage = Math.max(2, page - 1);
const endPage = Math.min(totalPages - 1, page + 1);
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
if (page < totalPages - 2) {
pages.push('...');
}
pages.push(totalPages); // 最后一页
}
return pages;
};
// 切换页码
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages && newPage !== page) {
setPage(newPage);
// 滚动到顶部
document.querySelector('.users-checkbox-list')?.scrollTo({ top: 0, behavior: 'smooth' });
} }
}; };
if (!role) return null; if (!role) return null;
// 过滤用户
const filteredUsers = allUsers.filter(user => {
const searchLower = searchTerm.toLowerCase();
return (
user.nick_name.toLowerCase().includes(searchLower) ||
user.username.toLowerCase().includes(searchLower) ||
user.ou_name.toLowerCase().includes(searchLower)
);
});
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
@@ -718,25 +787,38 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, curren
<i className="ri-search-line"></i> <i className="ri-search-line"></i>
<input <input
type="text" type="text"
placeholder="搜索用户(姓名、用户名、单位" placeholder="搜索用户(姓名)"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
{searchTerm && (
<button
type="button"
className="search-clear"
onClick={() => setSearchTerm('')}
title="清除搜索"
>
<i className="ri-close-line"></i>
</button>
)}
</div> </div>
{/* 全选按钮 */} {/* 全选按钮和统计信息 */}
<div className="select-all-bar"> <div className="select-all-bar">
<label className="user-checkbox-item select-all"> <label className="user-checkbox-item select-all">
<input <input
type="checkbox" type="checkbox"
checked={filteredUsers.length > 0 && selectedUserIds.length === filteredUsers.length} checked={allUsers.length > 0 && allUsers.every(u => selectedUserIds.includes(u.id))}
onChange={handleToggleAll} onChange={handleToggleAll}
disabled={filteredUsers.length === 0} disabled={allUsers.length === 0}
/> />
<span className="user-name"> <span className="user-name">
({selectedUserIds.length} / {filteredUsers.length}) ({allUsers.length > 0 ? allUsers.filter(u => selectedUserIds.includes(u.id)).length : 0} / {allUsers.length})
</span> </span>
</label> </label>
<span className="page-info">
{total} {page} / {totalPages}
</span>
</div> </div>
{/* 用户复选框列表 */} {/* 用户复选框列表 */}
@@ -746,65 +828,109 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, curren
<span>...</span> <span>...</span>
</div> </div>
) : ( ) : (
<div className="users-checkbox-list" style={{ maxHeight: '400px', minHeight: '400px', overflow: 'auto' }}> <>
{filteredUsers.length > 0 ? ( <div className="users-checkbox-list" style={{ maxHeight: '400px', minHeight: '400px', overflow: 'auto' }}>
filteredUsers.map(user => { {allUsers.length > 0 ? (
const userRoles = userRolesMap.get(user.id) || []; allUsers.map(user => {
const hasRoles = userRoles.length > 0; const userRoles = user.roles || [];
const hasRoles = userRoles.length > 0;
return ( return (
<label key={user.id} className="user-checkbox-item"> <label key={user.id} className="user-checkbox-item">
<input <input
type="checkbox" type="checkbox"
checked={selectedUserIds.includes(user.id)} checked={selectedUserIds.includes(user.id)}
onChange={(e) => { onChange={(e) => {
if (e.target.checked) { if (e.target.checked) {
setSelectedUserIds([...selectedUserIds, user.id]); setSelectedUserIds([...selectedUserIds, user.id]);
} else { } else {
setSelectedUserIds(selectedUserIds.filter(id => id !== user.id)); setSelectedUserIds(selectedUserIds.filter(id => id !== user.id));
} }
}} }}
/> />
<div className="user-info"> <div className="user-info">
<div className="user-name"> <div className="user-name">
{user.nick_name} {user.nick_name}
{user.is_leader && ( {user.is_leader && (
<span className="leader-badge" style={{ marginLeft: '8px' }}></span> <span className="leader-badge" style={{ marginLeft: '8px' }}></span>
)} )}
{hasRoles && ( {hasRoles && (
<span <span
className="role-badge" className="role-badge"
style={{ style={{
marginLeft: '8px', marginLeft: '8px',
padding: '2px 8px', padding: '2px 8px',
fontSize: '12px', fontSize: '12px',
backgroundColor: '#f0f9ff', backgroundColor: '#f0f9ff',
color: '#0284c7', color: '#0284c7',
border: '1px solid #bae6fd', border: '1px solid #bae6fd',
borderRadius: '4px' borderRadius: '4px'
}} }}
title={`当前角色: ${userRoles.map(r => r.role_name).join('、')}`} title={`当前角色: ${userRoles.map(r => r.role_name).join('、')}`}
> >
{userRoles.map(r => r.role_name).join('、')} {userRoles.map(r => r.role_name).join('、')}
</span> </span>
)} )}
</div>
<div className="user-meta">
{user.tenant_name}
{user.dep_name && `${user.dep_name}`}
{user.ou_name && `${user.ou_name}`}
</div>
</div> </div>
<div className="user-meta"> </label>
@{user.username} {user.ou_name} );
{user.area && `${user.area}`} })
{user.phone_number && `${user.phone_number}`} ) : (
</div> <div className="empty-state" style={{ padding: '40px 20px' }}>
</div> <i className="ri-user-search-line"></i>
</label> <p>{searchTerm ? '没有找到匹配的用户' : '没有可分配的用户'}</p>
); </div>
}) )}
) : ( </div>
<div className="empty-state" style={{ padding: '40px 20px' }}>
<i className="ri-user-search-line"></i> {/* 分页组件 */}
<p>{searchTerm ? '没有找到匹配的用户' : '没有可分配的用户'}</p> {totalPages > 1 && (
<div className="pagination-container">
<button
type="button"
className="pagination-btn"
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
title="上一页"
>
<i className="ri-arrow-left-s-line"></i>
</button>
{getPageNumbers().map((pageNum, index) => (
typeof pageNum === 'number' ? (
<button
key={index}
type="button"
className={`pagination-page ${page === pageNum ? 'active' : ''}`}
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</button>
) : (
<span key={index} className="pagination-ellipsis">
{pageNum}
</span>
)
))}
<button
type="button"
className="pagination-btn"
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
title="下一页"
>
<i className="ri-arrow-right-s-line"></i>
</button>
</div> </div>
)} )}
</div> </>
)} )}
</div> </div>
</Modal> </Modal>
@@ -1961,7 +2087,7 @@ export default function RolePermissions() {
</div> </div>
<div className="user-username">@{user.username}</div> <div className="user-username">@{user.username}</div>
<div className="user-org"> <div className="user-org">
{/* {JSON.stringify(user)} */} {JSON.stringify(user)}
{user.ou_name} {user.ou_name}
{user.area && <span style={{ marginLeft: '8px', color: '#666' }}> {user.area}</span>} {user.area && <span style={{ marginLeft: '8px', color: '#666' }}> {user.area}</span>}
</div> </div>
+113
View File
@@ -961,6 +961,7 @@
border-radius: 6px; border-radius: 6px;
background: white; background: white;
margin-bottom: 16px; margin-bottom: 16px;
position: relative;
} }
.search-box i { .search-box i {
@@ -980,6 +981,118 @@
color: #c0c4cc; color: #c0c4cc;
} }
/* 搜索清除按钮 */
.search-box .search-clear {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
cursor: pointer;
color: #909399;
border-radius: 50%;
transition: all 0.2s;
padding: 0;
}
.search-box .search-clear:hover {
background: #e6e8eb;
color: #606266;
}
.search-box .search-clear i {
font-size: 16px;
}
/* 分页信息 */
.select-all-bar .page-info {
margin-left: auto;
font-size: 13px;
color: #909399;
}
/* 分页容器 */
.pagination-container {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 16px 0 8px;
border-top: 1px solid #e4e7ed;
margin-top: 12px;
}
.pagination-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #dcdfe6;
background: white;
border-radius: 4px;
cursor: pointer;
color: #606266;
transition: all 0.2s;
padding: 0;
}
.pagination-btn:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.pagination-btn:disabled {
cursor: not-allowed;
opacity: 0.4;
background: #f5f7fa;
}
.pagination-btn i {
font-size: 18px;
}
.pagination-page {
min-width: 32px;
height: 32px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #dcdfe6;
background: white;
border-radius: 4px;
cursor: pointer;
color: #606266;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.pagination-page:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.pagination-page.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.pagination-ellipsis {
min-width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 14px;
user-select: none;
}
/* 无权限卡片 */ /* 无权限卡片 */
.no-permission-card { .no-permission-card {
max-width: 800px; max-width: 800px;
+5 -5
View File
@@ -34,7 +34,6 @@
"axios": "^1.9.0", "axios": "^1.9.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"compression": "^1.7.5", "compression": "^1.7.5",
"cross-env": "^7.0.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"diff": "^7.0.0", "diff": "^7.0.0",
"docx-preview": "^0.3.5", "docx-preview": "^0.3.5",
@@ -67,15 +66,16 @@
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remixicon": "^4.6.0", "remixicon": "^4.6.0",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"uuid": "^11.1.0" "uuid": "^11.1.0",
"cross-env": "^7.0.3"
}, },
"devDependencies": { "devDependencies": {
"@remix-run/dev": "^2.16.2", "@remix-run/dev": "^2.16.2",
"@types/compression": "^1.7.5", "@types/compression": "^1.7.5",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/morgan": "^1.9.9", "@types/morgan": "^1.9.9",
"@types/react": "^18.3.12", "@types/react": "^18.2.20",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.2.7",
"@types/react-pdf": "^7.0.0", "@types/react-pdf": "^7.0.0",
"@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4", "@typescript-eslint/parser": "^6.7.4",
@@ -89,7 +89,7 @@
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.8.3", "typescript": "^5.1.6",
"vite": "^6.0.0", "vite": "^6.0.0",
"vite-tsconfig-paths": "^4.2.1" "vite-tsconfig-paths": "^4.2.1"
}, },
-13457
View File
File diff suppressed because it is too large Load Diff