Merge remote-tracking branch 'origin/shiy-login' into PingChuan

This commit is contained in:
PingChuan
2025-11-20 20:37:08 +08:00
25 changed files with 2299 additions and 762 deletions
+2 -1
View File
@@ -179,6 +179,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
console.log("✅ [Callback] 后端登录成功,JWT token 已获取");
const frontendJWT = loginResponse.data.access_token;
const savedUserInfo = loginResponse.data.user_info;
const backExpiresIn = loginResponse.data.expires_in || (60 * 60 * 8)
// 🔑 提取后端返回的签发时间并转换为时间戳
let tokenIssuedAt = Date.now(); // 默认使用当前时间
@@ -235,7 +236,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
tokenExpiresIn: tokenResponse.expires_in,
tokenExpiresIn: backExpiresIn,
tokenIssuedAt: tokenIssuedAt, // 🔑 传递后端返回的签发时间
userInfo: enhancedUserInfo,
frontendJWT
+40 -102
View File
@@ -6,14 +6,14 @@ import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { Pagination } from "~/components/ui/Pagination";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
import { getRuleTypes, getRuleGroupsByType, type RuleType, type RuleGroup } from "~/api/evaluation_points/rules";
import { toastService } from "~/components/ui/Toast";
import {
getDocumentTypes,
deleteDocumentType,
type DocumentTypeUI,
import {
getDocumentTypes,
deleteDocumentType,
type DocumentTypeUI,
type DocumentTypeSearchParams,
type DocumentTypeGroup
type DocumentTypeGroup,
getParentEvaluationPointGroups
} from "~/api/document-types/document-types";
import documentTypesStyles from "~/styles/pages/document-types_index.css?url";
@@ -40,8 +40,7 @@ interface LoaderData {
pageSize: number;
currentPage: number;
error?: string;
groups: DocumentTypeGroup[];
ruleTypes: RuleType[];
parentGroups: DocumentTypeGroup[];
frontendJWT?: string | null;
}
@@ -69,11 +68,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
};
// 并行获取文档类型数据和父级评查点分组
const ruleTypesResponse = await getRuleTypes(undefined, frontendJWT);
if(ruleTypesResponse.error){
console.error("获取父级评查点分组失败:", ruleTypesResponse.error);
const parentGroupsResponse = await getParentEvaluationPointGroups(frontendJWT);
if(parentGroupsResponse.error){
console.error("获取父级评查点分组失败:", parentGroupsResponse.error);
}
const ruleTypes = ruleTypesResponse.error ? [] : ruleTypesResponse.data;
const parentGroups = parentGroupsResponse.error ? [] : (parentGroupsResponse.data || []);
const typesResponse = await getDocumentTypes(searchParams, frontendJWT);
if(typesResponse.error){
@@ -81,16 +80,16 @@ export async function loader({ request }: LoaderFunctionArgs) {
throw new Error(typesResponse.error);
}
const typesResult = typesResponse.data?.types || [];
// console.log('文档类型数据:', typesResult.data?.types);
// console.log('父级评查点分组:', groupsResult.data);
// console.log('文档类型数据:', typesResult);
// console.log('父级评查点分组:', parentGroups);
return Response.json({
types: typesResult,
total: typesResponse.data?.total || typesResult.length,
pageSize,
currentPage: page,
ruleTypes,
parentGroups,
frontendJWT
});
} catch (error) {
@@ -140,58 +139,18 @@ export default function DocumentTypesList() {
const [isDeleting, setIsDeleting] = useState(false);
// 获取加载器数据
const { types, total, error, ruleTypes, frontendJWT } = useLoaderData<LoaderData>();
const { types, total, error, parentGroups, frontendJWT } = useLoaderData<LoaderData>();
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('provin');
// 状态管理
const [ruleGroups, setRuleGroups] = useState<RuleGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
// 获取当前的ruleType值
const ruleTypeParam = searchParams.get('ruleType');
// 获取搜索参数
const name = searchParams.get('name') || '';
const currentPage = parseInt(searchParams.get('page') || String(1), 10);
const pageSize = parseInt(searchParams.get('pageSize') || String(10), 10);
// 判断是否禁用子级评查分组选择,true表示禁用,false表示不禁用
const isRuleGroupSelectDisabled = loadingGroups || !ruleTypeParam || ruleGroups.length === 0;
// 当评查点类型变化时,加载对应的子级评查分组
useEffect(() => {
// 如果选择了"全部"或未选择,则清空子级评查分组
if (!ruleTypeParam || ruleTypeParam === 'all') {
setRuleGroups([]);
return;
}
// 加载当前类型的子级评查分组
const loadRuleGroups = async () => {
setLoadingGroups(true);
try {
const response = await getRuleGroupsByType(ruleTypeParam, frontendJWT || undefined);
if (response.data) {
setRuleGroups(response.data);
} else if (response.error) {
console.error('加载子级规则组失败:', response.error);
setRuleGroups([]);
}
} catch (error) {
console.error('加载子级规则组出错:', error);
toastService.error('加载子级规则组出错:' + error);
setRuleGroups([]);
} finally {
setLoadingGroups(false);
}
};
loadRuleGroups();
}, [ruleTypeParam]);
// 处理loader加载数据的时候的错误
useEffect(() => {
if(error){
@@ -216,36 +175,16 @@ export default function DocumentTypesList() {
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
// 如果是子级评查分组选择,但是当前应该被禁用,则不处理
if (name === 'groupId' && isRuleGroupSelectDisabled) {
return;
}
if (value) {
newParams.set(name, value);
// 如果是评查点类型变更,清空子级评查分组选择
if (name === 'ruleType') {
newParams.delete('groupId');
// 如果选择了"全部"或空值,也清空子级评查分组选择
if (value === '' || value === 'all') {
setRuleGroups([]);
}
}
} else {
newParams.delete(name);
// 如果清除评查点类型,也清除规则组
if (name === 'ruleType') {
newParams.delete('groupId');
setRuleGroups([]);
}
}
// 切换筛选条件时,重置到第一页
newParams.set('page', '1');
setSearchParams(newParams);
};
@@ -317,7 +256,7 @@ export default function DocumentTypesList() {
{
title: "文档类型名称",
key: "name",
width: "200px",
width: "180px",
render: (_: unknown, record: DocumentTypeUI) => (
<div className="flex items-center">
<i className="ri-file-text-line text-primary mr-2"></i>
@@ -328,13 +267,27 @@ export default function DocumentTypesList() {
{
title: "描述",
key: "description",
width: "300px",
width: "250px",
render: (_: unknown, record: DocumentTypeUI) => (
<div className="text-secondary text-sm truncate max-w-xs" title={record.description}>
{record.description}
</div>
)
},
{
title: "入口模块",
key: "entry_module",
width: "150px",
render: (_: unknown, record: DocumentTypeUI) => (
<div className="flex items-center">
{record.entry_module ? (
<span className="type-badge">{record.entry_module.name}</span>
) : (
<span className="text-gray-400"></span>
)}
</div>
)
},
{
title: "关联的评查点分组",
key: "groups",
@@ -436,29 +389,14 @@ export default function DocumentTypesList() {
name="ruleType"
value={searchParams.get('ruleType') || ''}
options={[
...(ruleTypes || []).map(type => ({
value: type.id,
label: type.name
...(parentGroups || []).map(group => ({
value: group.id,
label: group.name
}))
]}
onChange={handleFilterChange}
className="mr-3 w-[20%]"
/>
<FilterSelect
label="所属子级评查分组"
name="groupId"
value={searchParams.get('groupId') || ''}
options={[
...(isRuleGroupSelectDisabled ? [{ value: "", label: "请先选择评查点类型" }] : []),
...ruleGroups.map(group => ({
value: group.id,
label: group.name
}))
]}
onChange={handleFilterChange}
className={`mr-3 w-[20%] ${isRuleGroupSelectDisabled ? 'opacity-50' : ''}`}
/>
<SearchFilter
label="类型名称"
+50 -24
View File
@@ -5,7 +5,7 @@ import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import documentTypesNewStyles from "~/styles/pages/document-types_new.css?url";
import { getAllRuleGroups, type RuleGroup } from "~/api/evaluation_points/rule-groups";
import { getDocumentType, createDocumentType, updateDocumentType } from "~/api/document-types/document-types";
import { getDocumentType, createDocumentType, updateDocumentType, getEntryModules } from "~/api/document-types/document-types";
import { getPromptTemplates, type PromptTemplateUI } from "~/api/prompts/prompts";
import { toastService } from "~/components/ui/Toast";
@@ -81,8 +81,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
const groupsTree = ruleGroupsResponse.error ? [] : ruleGroupsResponse.data || [];
// 2. 获取各类型的提示词模板
const [llmExtractionTemplatesResponse, vlmExtractionTemplatesResponse, evaluationTemplatesResponse, summaryTemplatesResponse] =
// 2. 获取入口模块列表
const entryModulesResponse = await getEntryModules(frontendJWT);
if (entryModulesResponse.error) {
console.error("获取入口模块失败:", entryModulesResponse.error);
}
const entryModules = entryModulesResponse.error ? [] : (entryModulesResponse.data || []);
// 3. 获取各类型的提示词模板
const [llmExtractionTemplatesResponse, vlmExtractionTemplatesResponse, evaluationTemplatesResponse, summaryTemplatesResponse] =
await Promise.all([
getPromptTemplates({ type: TEMPLATE_TYPES.LLM_EXTRACTION }, frontendJWT),
getPromptTemplates({ type: TEMPLATE_TYPES.VLM_EXTRACTION }, frontendJWT),
@@ -103,6 +110,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
isEdit,
documentType,
ruleGroups: groupsTree,
entryModules,
llmExtractionTemplates: llmExtractionTemplatesResponse.data?.templates || [],
vlmExtractionTemplates: vlmExtractionTemplatesResponse.data?.templates || [],
evaluationTemplates: evaluationTemplatesResponse.data?.templates || [],
@@ -114,6 +122,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
isEdit: false,
documentType: undefined,
ruleGroups: [],
entryModules: [],
llmExtractionTemplates: [],
vlmExtractionTemplates: [],
evaluationTemplates: [],
@@ -132,6 +141,7 @@ export async function action({ request }: ActionFunctionArgs) {
const id = formData.get("id") as string | null;
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const entryModuleId = formData.get("entry_module_id") as string;
const llmExtractionTemplateId = formData.get("llm_extraction_template") as string;
const vlmExtractionTemplateId = formData.get("vlm_extraction_template") as string;
const evaluationTemplateId = formData.get("evaluation_template") as string;
@@ -179,6 +189,7 @@ export async function action({ request }: ActionFunctionArgs) {
name,
description,
group_ids: selectedGroups,
entry_module_id: entryModuleId ? parseInt(entryModuleId) : null,
// 确保映射关系与prompt_config字段对应正确
llm_extraction_template_id: llmExtractionTemplateId ? parseInt(llmExtractionTemplateId) : null,
vlm_extraction_template_id: vlmExtractionTemplateId ? parseInt(vlmExtractionTemplateId) : null,
@@ -222,9 +233,10 @@ export default function DocumentTypeNew() {
const [searchParams] = useSearchParams();
const isEditMode = searchParams.has("id");
const {
documentType,
ruleGroups,
const {
documentType,
ruleGroups,
entryModules,
llmExtractionTemplates,
vlmExtractionTemplates,
evaluationTemplates,
@@ -244,6 +256,7 @@ export default function DocumentTypeNew() {
id: documentType?.id || "",
name: documentType?.name || "",
description: documentType?.description || "",
entryModuleId: documentType?.entry_module?.id?.toString() || "",
llmExtractionTemplateId: documentType?.llm_extraction_template_id || "",
vlmExtractionTemplateId: documentType?.vlm_extraction_template_id || "",
evaluationTemplateId: documentType?.evaluation_template_id || "",
@@ -287,6 +300,7 @@ export default function DocumentTypeNew() {
id: documentType.id,
name: documentType.name,
description: documentType.description,
entryModuleId: documentType.entry_module?.id?.toString() || "",
llmExtractionTemplateId: documentType.llm_extraction_template_id || "",
vlmExtractionTemplateId: documentType.vlm_extraction_template_id || "",
evaluationTemplateId: documentType.evaluation_template_id || "",
@@ -510,7 +524,30 @@ export default function DocumentTypeNew() {
)}
<div className="form-tip"></div>
</div>
{/* 入口模块 */}
<div className="form-group">
<label htmlFor="entry-module" className="form-label">
</label>
<select
id="entry-module"
name="entry_module_id"
className="form-select"
value={formData.entryModuleId}
onChange={handleInputChange}
disabled={isReadOnly}
>
<option value=""></option>
{entryModules.map((module: { id: number; name: string }) => (
<option key={module.id} value={module.id}>
{module.name}
</option>
))}
</select>
<div className="form-tip"></div>
</div>
{/* 类型描述 */}
<div className="form-group">
<label htmlFor="type-description" className="form-label"></label>
@@ -670,30 +707,19 @@ export default function DocumentTypeNew() {
</label>
</div>
{/* 子分组 */}
{/* 子分组 - 仅展示,不可选 */}
{group.children && group.children.length > 0 && expandedGroups[group.id] && (
group.children.map((child: RuleGroup) => (
<div
key={child.id}
className={`checkbox-item child-checkbox-item ${formData.selectedGroups.includes(child.id) ? 'checked' : ''}`}
className="checkbox-item child-checkbox-item"
style={{ paddingLeft: '2.5rem', opacity: 0.9 }}
>
<input
type="radio"
id={`group-${child.id}`}
name="checkpoint_group_ids"
value={child.id}
checked={formData.selectedGroups.includes(child.id)}
onChange={(e) => handleGroupCheckChange(child.id, e.target.checked)}
className="radio-input"
disabled={isReadOnly}
/>
<label
htmlFor={`group-${child.id}`}
className="checkbox-label"
>
<i className="ri-subtract-line" style={{ marginRight: '8px', color: '#9ca3af' }}></i>
<span className="checkbox-label">
{child.name}
<span className="group-badge child-badge"></span>
</label>
</span>
</div>
))
)}
-51
View File
@@ -1,51 +0,0 @@
import { LoaderFunctionArgs } from "@remix-run/node";
import { postgrestGet } from "~/api/postgrest-client";
import { getUserSession } from "~/api/login/auth.server";
/**
* 文档下载路由 - 处理文档下载请求
* 通过重定向到带有授权的连接来允许下载文件
*/
export async function loader({ request }: LoaderFunctionArgs) {
try {
const { frontendJWT } = await getUserSession(request);
// 获取文件路径参数
const url = new URL(request.url);
const filePath = url.searchParams.get("path");
if (!filePath) {
return new Response("缺少文件路径参数", { status: 400 });
}
// 调用Minio API获取带有授权的预签名URL
// 这里假设后端有一个生成预签名URL的API
const response = await postgrestGet<{ presignedUrl: string }>(
'/minio/presign',
{
filter: {
'object_path': `eq.${filePath}`,
'expires_in': 'eq.300' // 5分钟有效期
},
token: frontendJWT
}
);
if (response.error) {
console.error("获取文件下载链接失败:", response.error);
return new Response("获取文件下载链接失败", { status: 500 });
}
if (!response.data?.presignedUrl) {
return new Response("无法获取文件下载链接", { status: 404 });
}
// 重定向到预签名URL,这样浏览器就能直接下载文件
return Response.redirect(response.data.presignedUrl);
} catch (error) {
console.error("文件下载处理失败:", error);
return new Response(
"文件下载处理失败: " + (error instanceof Error ? error.message : "未知错误"),
{ status: 500 }
);
}
}
+513
View File
@@ -0,0 +1,513 @@
import { useState, useEffect } from "react";
import { ClientLoaderFunctionArgs, ClientActionFunctionArgs } from "@remix-run/react";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { toastService } from "~/components/ui/Toast";
import {
getRoles,
getRoutes,
getRoleRoutePermissions,
updateRoleRoutePermissions,
getRoleUsers,
getAllUsers,
assignUserRoles,
createRole,
updateRole,
deleteRole,
type RoleInfo,
type RouteInfo,
type UserInfo
} from "~/api/role-permissions/role-permissions";
import rolePermissionsStyles from "~/styles/pages/role-permissions.css?url";
// 引入样式
export function links() {
return [
{ rel: "stylesheet", href: rolePermissionsStyles }
];
}
// 页面元数据
export const meta = () => {
return [
{ title: "角色权限管理 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "管理系统角色和权限分配" }
];
};
// ClientLoader - 加载初始数据
export async function clientLoader({ request }: ClientLoaderFunctionArgs) {
try {
const [roles, routes, users] = await Promise.all([
getRoles(),
getRoutes(),
getAllUsers()
]);
return {
roles,
routes,
users
};
} catch (error) {
console.error("加载数据失败:", error);
return {
roles: [],
routes: [],
users: []
};
}
}
// ClientAction - 处理用户操作
export async function clientAction({ request }: ClientActionFunctionArgs) {
const formData = await request.formData();
const action = formData.get("action") as string;
try {
switch (action) {
case "updatePermissions": {
const roleId = parseInt(formData.get("roleId") as string);
const routeIds = JSON.parse(formData.get("routeIds") as string);
const result = await updateRoleRoutePermissions(roleId, routeIds);
return result;
}
case "assignUserRoles": {
const userId = parseInt(formData.get("userId") as string);
const roleIds = JSON.parse(formData.get("roleIds") as string);
const result = await assignUserRoles(userId, roleIds);
return result;
}
case "createRole": {
const roleData = JSON.parse(formData.get("roleData") as string);
const result = await createRole(roleData);
return result;
}
case "updateRole": {
const roleId = parseInt(formData.get("roleId") as string);
const roleData = JSON.parse(formData.get("roleData") as string);
const result = await updateRole(roleId, roleData);
return result;
}
case "deleteRole": {
const roleId = parseInt(formData.get("roleId") as string);
const result = await deleteRole(roleId);
return result;
}
default:
return { success: false, message: "未知操作" };
}
} catch (error) {
console.error("操作失败:", error);
return {
success: false,
message: error instanceof Error ? error.message : "操作失败"
};
}
}
// 主组件
export default function RolePermissions() {
const [roles, setRoles] = useState<RoleInfo[]>([]);
const [routes, setRoutes] = useState<RouteInfo[]>([]);
const [users, setUsers] = useState<UserInfo[]>([]);
const [selectedRole, setSelectedRole] = useState<RoleInfo | null>(null);
const [activeTab, setActiveTab] = useState<'permissions' | 'users'>('permissions');
const [loading, setLoading] = useState(true);
// 路由权限相关状态
const [selectedRouteIds, setSelectedRouteIds] = useState<number[]>([]);
const [roleUsers, setRoleUsers] = useState<UserInfo[]>([]);
// 加载初始数据
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [rolesData, routesData, usersData] = await Promise.all([
getRoles(),
getRoutes(),
getAllUsers()
]);
setRoles(rolesData);
setRoutes(routesData);
setUsers(usersData);
// 默认选中第一个角色
if (rolesData.length > 0) {
handleSelectRole(rolesData[0]);
}
} catch (error) {
console.error("加载数据失败:", error);
toastService.error("加载数据失败");
} finally {
setLoading(false);
}
};
// 选择角色
const handleSelectRole = async (role: RoleInfo) => {
setSelectedRole(role);
// 加载该角色的权限
const permissions = await getRoleRoutePermissions(role.id);
const routeIds = permissions.map(p => p.route_id);
setSelectedRouteIds(routeIds);
// 加载该角色的用户列表
const users = await getRoleUsers(role.id);
setRoleUsers(users);
};
// 递归获取所有路由ID(包括子路由)
const getAllRouteIds = (routes: RouteInfo[]): number[] => {
let ids: number[] = [];
routes.forEach(route => {
ids.push(route.id);
if (route.children && route.children.length > 0) {
ids = ids.concat(getAllRouteIds(route.children));
}
});
return ids;
};
// 切换路由权限
const handleToggleRoute = (routeId: number, checked: boolean) => {
if (checked) {
setSelectedRouteIds([...selectedRouteIds, routeId]);
} else {
setSelectedRouteIds(selectedRouteIds.filter(id => id !== routeId));
}
};
// 切换父路由(包括所有子路由)
const handleToggleParentRoute = (route: RouteInfo, checked: boolean) => {
const childIds = route.children ? getAllRouteIds(route.children) : [];
const allIds = [route.id, ...childIds];
if (checked) {
const newIds = [...selectedRouteIds, ...allIds].filter(
(id, index, self) => self.indexOf(id) === index
);
setSelectedRouteIds(newIds);
} else {
setSelectedRouteIds(
selectedRouteIds.filter(id => !allIds.includes(id))
);
}
};
// 保存权限
const handleSavePermissions = async () => {
if (!selectedRole) return;
try {
const formData = new FormData();
formData.append("action", "updatePermissions");
formData.append("roleId", selectedRole.id.toString());
formData.append("routeIds", JSON.stringify(selectedRouteIds));
const response = await fetch("/role-permissions", {
method: "POST",
body: formData
});
const result = await response.json();
if (result.success) {
toastService.success(result.message);
} else {
toastService.error(result.message);
}
} catch (error) {
console.error("保存权限失败:", error);
toastService.error("保存权限失败");
}
};
// 渲染路由树
const renderRouteTree = (routes: RouteInfo[], level = 0) => {
return routes.map(route => {
const hasChildren = route.children && route.children.length > 0;
const isChecked = selectedRouteIds.includes(route.id);
const allChildIds = hasChildren ? getAllRouteIds(route.children!) : [];
const checkedChildCount = allChildIds.filter(id =>
selectedRouteIds.includes(id)
).length;
const isIndeterminate =
hasChildren && checkedChildCount > 0 && checkedChildCount < allChildIds.length;
return (
<div key={route.id} className="route-item" style={{ paddingLeft: `${level * 20}px` }}>
<div className="route-item-content">
<input
type="checkbox"
id={`route-${route.id}`}
checked={isChecked}
ref={el => {
if (el) el.indeterminate = isIndeterminate;
}}
onChange={(e) => {
if (hasChildren) {
handleToggleParentRoute(route, e.target.checked);
} else {
handleToggleRoute(route.id, e.target.checked);
}
}}
className="route-checkbox"
/>
<label htmlFor={`route-${route.id}`} className="route-label">
{route.icon && <i className={`${route.icon} route-icon`}></i>}
<span className="route-title">{route.route_title}</span>
<span className="route-path">{route.route_path}</span>
</label>
</div>
{hasChildren && (
<div className="route-children">
{renderRouteTree(route.children!, level + 1)}
</div>
)}
</div>
);
});
};
if (loading) {
return (
<div className="role-permissions-page">
<div className="loading-container">
<i className="ri-loader-4-line spin"></i>
<span>...</span>
</div>
</div>
);
}
return (
<div className="role-permissions-page">
{/* 页面头部 */}
<div className="page-header">
<h2 className="page-title">
<i className="ri-shield-user-line"></i>
</h2>
<div className="page-actions">
<Button
type="primary"
icon="ri-add-line"
onClick={() => {
toastService.info("创建角色功能开发中...");
}}
>
</Button>
</div>
</div>
<div className="permissions-container">
{/* 左侧:角色列表 */}
<Card className="roles-panel" title="角色列表" bodyClassName="p-0">
<div className="roles-list">
{roles.map(role => (
<div
key={role.id}
className={`role-item ${selectedRole?.id === role.id ? 'active' : ''}`}
onClick={() => handleSelectRole(role)}
>
<div className="role-info">
<div className="role-header">
<span className="role-name">{role.role_name}</span>
{role.is_system_role && (
<span className="system-badge"></span>
)}
</div>
<div className="role-key">{role.role_key}</div>
<div className="role-desc">{role.description}</div>
<div className="role-meta">
<span className="data-scope">
<i className="ri-database-line"></i>
{role.data_scope}
</span>
<span className="priority">
<i className="ri-sort-asc"></i>
: {role.priority}
</span>
</div>
</div>
{!role.is_system_role && (
<div className="role-actions">
<button
className="btn-icon"
onClick={(e) => {
e.stopPropagation();
toastService.info("编辑角色功能开发中...");
}}
title="编辑"
>
<i className="ri-edit-line"></i>
</button>
<button
className="btn-icon text-error"
onClick={(e) => {
e.stopPropagation();
if (confirm(`确定要删除角色"${role.role_name}"吗?`)) {
toastService.info("删除角色功能开发中...");
}
}}
title="删除"
>
<i className="ri-delete-bin-line"></i>
</button>
</div>
)}
</div>
))}
</div>
</Card>
{/* 右侧:角色详情和权限设置 */}
<div className="permissions-detail">
{selectedRole ? (
<>
{/* Tab 切换 */}
<Card className="tabs-card">
<div className="tabs-header">
<button
className={`tab-btn ${activeTab === 'permissions' ? 'active' : ''}`}
onClick={() => setActiveTab('permissions')}
>
<i className="ri-shield-check-line"></i>
</button>
<button
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
onClick={() => setActiveTab('users')}
>
<i className="ri-team-line"></i>
({roleUsers.length})
</button>
</div>
<div className="tabs-content">
{/* 路由权限Tab */}
{activeTab === 'permissions' && (
<div className="permissions-tab">
<div className="permissions-header">
<h3> "{selectedRole.role_name}" </h3>
<Button
type="primary"
icon="ri-save-line"
onClick={handleSavePermissions}
>
</Button>
</div>
<div className="routes-tree">
{renderRouteTree(routes)}
</div>
<div className="permissions-summary">
<i className="ri-information-line"></i>
<strong>{selectedRouteIds.length}</strong>
</div>
</div>
)}
{/* 用户列表Tab */}
{activeTab === 'users' && (
<div className="users-tab">
<div className="users-header">
<h3> "{selectedRole.role_name}" </h3>
<Button
type="primary"
icon="ri-user-add-line"
onClick={() => {
toastService.info("分配用户功能开发中...");
}}
>
</Button>
</div>
<div className="users-list">
{roleUsers.length > 0 ? (
roleUsers.map(user => (
<div key={user.id} className="user-card">
<div className="user-avatar">
<i className="ri-user-line"></i>
</div>
<div className="user-info">
<div className="user-name">
{user.nick_name}
{user.is_leader && (
<span className="leader-badge"></span>
)}
</div>
<div className="user-username">@{user.username}</div>
<div className="user-org">{user.ou_name}</div>
<div className="user-contact">
{user.phone_number && (
<span>
<i className="ri-phone-line"></i>
{user.phone_number}
</span>
)}
{user.email && (
<span>
<i className="ri-mail-line"></i>
{user.email}
</span>
)}
</div>
</div>
<div className="user-actions">
<button
className="btn-icon text-error"
onClick={() => {
if (confirm(`确定要移除用户"${user.nick_name}"的该角色吗?`)) {
toastService.info("移除角色功能开发中...");
}
}}
title="移除角色"
>
<i className="ri-user-unfollow-line"></i>
</button>
</div>
</div>
))
) : (
<div className="empty-state">
<i className="ri-user-line"></i>
<p></p>
</div>
)}
</div>
</div>
)}
</div>
</Card>
</>
) : (
<Card>
<div className="empty-state">
<i className="ri-shield-line"></i>
<p></p>
</div>
</Card>
)}
</div>
</div>
</div>
);
}