feat: 1. 本地化思源黑体的字体包并优先使用。

2. 添加权限映射表和全局查看权限的hook,便于路由控制不同权限按钮显示/隐藏。
3. 删除评查点分组的部分旧api方法。
4. 对接评查点分组接口,文档类型接口, 提示词管理接口, 入口模块管理的接口。
5. 优化角色权限管理的接口,完善不用地区的访问权限认证。
6. 优化主页交叉评查和设置的入口样式和布局。
7. 优化评查点分组,评查规则的功能权限校验。
This commit is contained in:
2025-11-29 10:37:35 +08:00
parent 61facf5d71
commit 30e100ef3e
29 changed files with 2527 additions and 2126 deletions
+82 -56
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate, Form, useLoaderData } from '@remix-run/react';
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import styles from "~/styles/pages/home.css?url";
@@ -52,6 +52,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 🔑 检查用户是否有系统设置权限
let hasSettingsAccess = false;
let hasCrossCheckingAccess = false;
let hasChatLLMAccess = false;
let settingsChildren: { path: string; title: string }[] = [];
if (userRole && frontendJWT) {
@@ -74,14 +75,19 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 检查是否存在顶级路由 '/cross-checking'
hasCrossCheckingAccess = routesResult.data.some(route => route.path === '/cross-checking');
// 检查是否存在顶级路由 '/chat-with-llm'
hasChatLLMAccess = routesResult.data.some(route => route.path === '/chat-with-llm');
// console.log(`🔑 [Index Loader] 用户${hasSettingsAccess ? '有' : '没有'}系统设置权限`);
// console.log(`🔑 [Index Loader] 系统设置子路由数量: ${settingsChildren.length}`);
// console.log(`🔑 [Index Loader] 用户${hasCrossCheckingAccess ? '有' : '没有'}交叉评查权限`);
// console.log(`🔑 [Index Loader] 用户${hasChatLLMAccess ? '有' : '没有'}智慧法务大模型权限`);
}
}
// 返回用户信息、入口模块和权限给客户端
return Response.json({ userRole, userInfo, entryModules, hasSettingsAccess, hasCrossCheckingAccess, settingsChildren });
return Response.json({ userRole, userInfo, entryModules, hasSettingsAccess, hasCrossCheckingAccess, hasChatLLMAccess, settingsChildren });
}
export default function Index() {
@@ -310,6 +316,17 @@ export default function Index() {
</div>
</div>
<div className="user-info">
{/* 系统设置按钮 - 只在有权限时显示 */}
{loaderData.hasSettingsAccess && (
<button
onClick={handleEnterSettings}
className="settings-button"
aria-label="系统设置"
title="系统设置"
>
<i className="ri-settings-4-line"></i>
</button>
)}
<span className="datetime">{currentDateTime.date} {currentDateTime.time}</span>
<div className="user">
{(() => {
@@ -340,67 +357,76 @@ export default function Index() {
<div className="index-main-content-container">
<h1 className="welcome-text">- -</h1>
{/* 模块网格区域 */}
<div className="modules-container">
{/* 动态渲染入口模块 */}
{loaderData.entryModules && loaderData.entryModules.length > 0 ? (
<>
{loaderData.entryModules.map((module) => (
<div
key={module.id}
className="module-card"
onClick={() => handleModuleClick(module)}
onKeyDown={(e) => handleKeyDown(module, e)}
role="button"
tabIndex={0}
aria-label={module.name}
>
<img
src={getModuleIcon(module)}
alt={module.name}
className="w-12 h-12 mx-1"
/>
<span className="module-name">{module.name}</span>
</div>
))}
{loaderData.entryModules.map((module) => {
// 判断是否为智慧法务大模型,如果是且有交叉评查权限,则在其之前插入交叉评查卡片
const isLLMModule = module.name === '智慧法务大模型';
{/* 🔑 交叉评查入口 - 只有有权限的用户才能看到 */}
{loaderData.hasCrossCheckingAccess && (
<div
className="module-card"
onClick={handleEnterCrossChecking}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleEnterCrossChecking();
}
}}
role="button"
tabIndex={0}
aria-label="交叉评查"
>
<i className="ri-shuffle-line text-5xl text-primary"></i>
<span className="module-name"></span>
</div>
)}
// 🔑 如果是智慧法务大模型且用户没有访问权限,则不渲染该模块
if (isLLMModule && !loaderData.hasChatLLMAccess) {
return null;
}
{/* 🔑 系统设置入口 - 只有有权限的用户才能看到 */}
{loaderData.hasSettingsAccess && (
<div
className="module-card"
onClick={handleEnterSettings}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleEnterSettings();
}
}}
role="button"
tabIndex={0}
aria-label="系统设置"
>
<i className="ri-settings-4-line text-5xl text-primary"></i>
<span className="module-name"></span>
</div>
)}
return (
<React.Fragment key={module.id}>
{/* 在智慧法务大模型之前插入交叉评查入口 */}
{isLLMModule && loaderData.hasCrossCheckingAccess && (
<div
className="module-card"
onClick={handleEnterCrossChecking}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleEnterCrossChecking();
}
}}
role="button"
tabIndex={0}
aria-label="交叉评查"
>
<img
src="/images/icon_cross_checking.png"
alt="交叉评查"
className="w-12 h-12 mx-1"
onError={(e) => {
// 如果图片加载失败,使用 icon
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {
const icon = document.createElement('i');
icon.className = 'ri-shuffle-line';
icon.style.fontSize = '48px';
icon.style.color = 'var(--color-primary)';
parent.insertBefore(icon, parent.firstChild);
}
}}
/>
<span className="module-name"></span>
</div>
)}
{/* 渲染原有模块 */}
<div
className="module-card"
onClick={() => handleModuleClick(module)}
onKeyDown={(e) => handleKeyDown(module, e)}
role="button"
tabIndex={0}
aria-label={module.name}
>
<img
src={getModuleIcon(module)}
alt={module.name}
className="w-12 h-12 mx-1"
/>
<span className="module-name">{module.name}</span>
</div>
</React.Fragment>
);
})}
</>
) : (
<div className="text-center text-gray-500 py-8">
+82 -40
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useSearchParams, useNavigate, useLoaderData, useRouteLoaderData } from "@remix-run/react";
import { useSearchParams, useNavigate, useLoaderData } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Table } from "~/components/ui/Table";
import { Card } from "~/components/ui/Card";
@@ -8,6 +8,7 @@ import { Pagination } from "~/components/ui/Pagination";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
import { toastService } from "~/components/ui/Toast";
import { messageService } from "~/components/ui/MessageModal";
import { usePermission } from "~/hooks/usePermission";
import {
getDocumentTypes,
deleteDocumentType,
@@ -51,39 +52,50 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
const url = new URL(request.url);
const name = url.searchParams.get('name') || undefined;
const ruleType = url.searchParams.get('ruleType') || undefined;
const groupId = url.searchParams.get('groupId') || undefined;
const groupId = url.searchParams.get('group_id') || undefined;
const entryModuleId = url.searchParams.get('entry_module_id') || undefined;
const page = parseInt(url.searchParams.get('page') || '1', 10);
const pageSize = parseInt(url.searchParams.get('pageSize') || '10', 10);
// 构建搜索参数
const searchParams: DocumentTypeSearchParams = {
name,
ruleType,
groupId,
group_id: groupId ? parseInt(groupId, 10) : undefined,
entry_module_id: entryModuleId ? parseInt(entryModuleId, 10) : undefined,
page,
pageSize
};
// 并行获取文档类型数据和父级评查点分组
const parentGroupsResponse = await getParentEvaluationPointGroups(frontendJWT);
const [parentGroupsResponse, typesResponse] = await Promise.all([
getParentEvaluationPointGroups(frontendJWT),
getDocumentTypes(searchParams, frontendJWT)
]);
// 如果获取父级评查点分组失败,返回空数组(不阻塞页面加载)
if(parentGroupsResponse.error){
console.error("获取父级评查点分组失败:", parentGroupsResponse.error);
}
const parentGroups = parentGroupsResponse.error ? [] : (parentGroupsResponse.data || []);
const typesResponse = await getDocumentTypes(searchParams, frontendJWT);
// 如果获取文档类型失败(如403无权限),返回空数组和错误信息
if(typesResponse.error){
console.error("获取文档类型失败:", typesResponse.error);
throw new Error(typesResponse.error);
return Response.json({
types: [],
total: 0,
pageSize,
currentPage: page,
parentGroups: [],
frontendJWT,
error: typesResponse.error
});
}
const typesResult = typesResponse.data?.types || [];
// console.log('文档类型数据:', typesResult);
// console.log('父级评查点分组:', parentGroups);
const typesResult = typesResponse.data?.types || [];
return Response.json({
types: typesResult,
@@ -95,12 +107,16 @@ export async function loader({ request }: LoaderFunctionArgs) {
});
} catch (error) {
console.error("加载文档类型列表失败:", error);
return Response.json(
{
error: error || "加载文档类型列表失败",
status: 500
}
);
const errorMessage = error instanceof Error ? error.message : "加载文档类型列表失败";
return Response.json({
types: [],
total: 0,
pageSize: 10,
currentPage: 1,
parentGroups: [],
frontendJWT: null,
error: errorMessage
});
}
}
@@ -142,20 +158,32 @@ export default function DocumentTypesList() {
// 获取加载器数据
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 { canCreate, canUpdate, canDelete, canView } = usePermission();
const canCreateType = canCreate('document_type');
const canUpdateType = canUpdate('document_type');
const canDeleteType = canDelete('document_type');
const canViewType = canView('document_type');
// 获取搜索参数
const name = searchParams.get('name') || '';
const currentPage = parseInt(searchParams.get('page') || String(1), 10);
const pageSize = parseInt(searchParams.get('pageSize') || String(10), 10);
// 处理测试loader返回的信息
useEffect(() => {
console.log('返回的父级评查点分组数据',parentGroups)
}, [parentGroups])
// 处理loader加载数据的时候的错误
useEffect(() => {
if(error){
toastService.error(error);
// 如果是无权限错误,显示友好提示
if(error.includes('Permission denied') || error.includes('无权限') || error.includes('权限不足')){
toastService.error('无权限访问文档类型管理,请联系系统管理员');
} else {
toastService.error(error);
}
}
}, [error]);
@@ -202,6 +230,12 @@ export default function DocumentTypesList() {
// 处理删除文档类型
const handleDelete = async (id: number) => {
// 权限检查
if (!canDeleteType) {
toastService.warning('您没有删除权限');
return;
}
messageService.show({
title: "确认删除",
message: "确定要删除该文档类型吗?此操作不会影响关联的评查点分组,但可能会影响使用该类型的文档评查。",
@@ -259,7 +293,7 @@ export default function DocumentTypesList() {
newParams.set('page', '1');
setSearchParams(newParams);
};
// 定义表格列配置
const columns = [
{
@@ -331,14 +365,19 @@ export default function DocumentTypesList() {
key: "operation",
width: "150px",
render: (_: unknown, record: DocumentTypeUI) => (
<>
<button
className="operation-btn text-primary"
onClick={() => handleEdit(record.id)}
>
<i className="ri-edit-line"></i> {hasEditPermission ? '编辑' : '查看'}
</button>
{hasEditPermission && (
<div className="operations-cell">
{canViewType && (
<>
<button
className="operation-btn text-primary"
onClick={() => handleEdit(record.id)}
>
<i className={canUpdateType ? "ri-edit-line" : "ri-eye-line"}></i>
{canUpdateType ? '编辑' : '查看'}
</button>
</>
)}
{canDeleteType && (
<button
className="operation-btn text-error !hidden"
onClick={() => handleDelete(record.id)}
@@ -347,7 +386,10 @@ export default function DocumentTypesList() {
<i className="ri-delete-bin-line"></i>
</button>
)}
</>
{!canViewType && !canDeleteType && (
<span className="text-gray-400">-</span>
)}
</div>
)
}
];
@@ -364,7 +406,7 @@ export default function DocumentTypesList() {
<div className="page-header">
<h2 className="page-title"></h2>
<div>
{hasEditPermission && (
{canCreateType && (
<Button
type="primary"
icon="ri-add-line"
@@ -394,12 +436,12 @@ export default function DocumentTypesList() {
noActionDivider={true}
>
<FilterSelect
label="父级评查分组"
name="ruleType"
value={searchParams.get('ruleType') || ''}
label="评查分组"
name="group_id"
value={searchParams.get('group_id') || ''}
options={[
...(parentGroups || []).map(group => ({
value: group.id,
value: group.id.toString(),
label: group.name
}))
]}
+247 -214
View File
@@ -1,13 +1,21 @@
import React, { useState, useEffect, useRef } from "react";
import { Form, useActionData, useLoaderData, useNavigate, useSearchParams, useRouteLoaderData } from "@remix-run/react";
import { Form, useActionData, useLoaderData, useNavigate, useSearchParams } from "@remix-run/react";
import { redirect, type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import documentTypesNewStyles from "~/styles/pages/document-types_new.css?url";
import { getAllEvaluationPointGroups, type RuleGroup } from "~/api/evaluation_points/rule-groups";
import { getDocumentType, createDocumentType, updateDocumentType, getEntryModules } from "~/api/document-types/document-types";
import { getPromptTemplates, type PromptTemplateUI } from "~/api/prompts/prompts";
import { type RuleGroup } from "~/api/evaluation_points/rule-groups";
import {
getDocumentType,
createDocumentType,
updateDocumentType,
getEntryModules,
getRootEvaluationPointGroups_ForDocTypes,
getPromptTemplateOptions
} from "~/api/document-types/document-types";
import { getEvaluationPointGroupChildren } from "~/api/evaluation_points/rule-groups";
import { toastService } from "~/components/ui/Toast";
import { usePermission } from "~/hooks/usePermission";
export function links() {
return [{ rel: "stylesheet", href: documentTypesNewStyles }];
@@ -38,9 +46,7 @@ export const meta: MetaFunction = ({ location }) => {
// 定义模板类型
const TEMPLATE_TYPES = {
LLM_EXTRACTION: "LLM_Extraction",
VLM_EXTRACTION: "VLM_Extraction",
EVALUATION: "Evaluation",
SUMMARY: "Summary"
VLM_EXTRACTION: "VLM_Extraction"
};
// 定义动作返回的数据类型
@@ -52,8 +58,6 @@ interface ActionData {
general?: string;
llmExtractionTemplate?: string;
vlmExtractionTemplate?: string;
evaluationTemplate?: string;
summaryTemplate?: string;
};
values?: Record<string, string | string[]>;
}
@@ -65,22 +69,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
const url = new URL(request.url);
const id = url.searchParams.get("id");
const isEdit = id ? true : false;
// 1. 获取评查点分组 - 使用 FastAPI v3 的 getAllEvaluationPointGroups 获取所有分组
const ruleGroupsResponse = await getAllEvaluationPointGroups(false, true, frontendJWT);
// 1. 获取一级评查点分组(后续通过点击展开按钮动态加载子分组
const ruleGroupsResponse = await getRootEvaluationPointGroups_ForDocTypes(frontendJWT);
if (ruleGroupsResponse.error) {
console.error("获取评查点分组失败:", ruleGroupsResponse.error);
// throw new Error(ruleGroupsResponse.error);
console.error("获取一级评查点分组失败:", ruleGroupsResponse.error);
}
const rootGroups = ruleGroupsResponse.error ? [] : ruleGroupsResponse.data || [];
// ruleGroupsResponse.data已经是树形结构数据,getAllEvaluationPointGroups内部已处理好parent-children关系
const groupsTree = ruleGroupsResponse.error ? [] : ruleGroupsResponse.data || [];
// 2. 获取入口模块列表
const entryModulesResponse = await getEntryModules(frontendJWT);
if (entryModulesResponse.error) {
@@ -88,16 +88,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
const entryModules = entryModulesResponse.error ? [] : (entryModulesResponse.data || []);
// 3. 获取各类型的提示词模板
const [llmExtractionTemplatesResponse, vlmExtractionTemplatesResponse, evaluationTemplatesResponse, summaryTemplatesResponse] =
// 3. 获取提示词模板(只获取 LLM 和 VLM
const [llmExtractionTemplatesResponse, vlmExtractionTemplatesResponse] =
await Promise.all([
getPromptTemplates({ type: TEMPLATE_TYPES.LLM_EXTRACTION }, frontendJWT),
getPromptTemplates({ type: TEMPLATE_TYPES.VLM_EXTRACTION }, frontendJWT),
getPromptTemplates({ type: TEMPLATE_TYPES.EVALUATION }, frontendJWT),
getPromptTemplates({ type: TEMPLATE_TYPES.SUMMARY }, frontendJWT)
getPromptTemplateOptions(TEMPLATE_TYPES.LLM_EXTRACTION, frontendJWT),
getPromptTemplateOptions(TEMPLATE_TYPES.VLM_EXTRACTION, frontendJWT)
]);
// 3. 如果是编辑模式,获取文档类型详情
// 4. 如果是编辑模式,获取文档类型详情
let documentType = undefined;
if (id) {
const typeResponse = await getDocumentType(id, frontendJWT);
@@ -105,16 +103,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
documentType = typeResponse.data;
}
}
return Response.json({
isEdit,
documentType,
ruleGroups: groupsTree,
ruleGroups: rootGroups,
entryModules,
llmExtractionTemplates: llmExtractionTemplatesResponse.data?.templates || [],
vlmExtractionTemplates: vlmExtractionTemplatesResponse.data?.templates || [],
evaluationTemplates: evaluationTemplatesResponse.data?.templates || [],
summaryTemplates: summaryTemplatesResponse.data?.templates || []
llmExtractionTemplates: llmExtractionTemplatesResponse.data || [],
vlmExtractionTemplates: vlmExtractionTemplatesResponse.data || []
});
} catch (error) {
console.error("加载数据失败:", error);
@@ -125,8 +121,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
entryModules: [],
llmExtractionTemplates: [],
vlmExtractionTemplates: [],
evaluationTemplates: [],
summaryTemplates: [],
error: error || "加载数据失败"
});
}
@@ -136,7 +130,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
export async function action({ request }: ActionFunctionArgs) {
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
const formData = await request.formData();
const id = formData.get("id") as string | null;
const name = formData.get("name") as string;
@@ -144,24 +138,22 @@ export async function action({ request }: ActionFunctionArgs) {
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;
const summaryTemplateId = formData.get("summary_template") as string;
// 获取选中的评查点分组ID列表
const selectedGroups = formData.getAll("checkpoint_group_ids") as string[];
// 表单验证
const errors: ActionData["errors"] = {};
// 收集所有错误
if (!name || name.trim() === "") {
errors.name = "文档类型名称不能为空";
}
if (selectedGroups.length === 0) {
errors.groups = "请至少选择一个关联的评查点分组";
}
if (!llmExtractionTemplateId) {
errors.llmExtractionTemplate = "请选择llm抽取提示词模板";
}
@@ -170,33 +162,22 @@ export async function action({ request }: ActionFunctionArgs) {
errors.vlmExtractionTemplate = "请选择vlm抽取提示词模板";
}
if (!evaluationTemplateId) {
errors.evaluationTemplate = "请选择评查提示词模板";
}
if (!summaryTemplateId) {
errors.summaryTemplate = "请选择总结提示词模板";
}
// 如果有错误,返回错误信息
if (Object.keys(errors).length > 0) {
return Response.json({ errors, result: false });
}
try {
// 构建文档类型数据
// 构建文档类型数据 - group_ids 转换为 number[]
const documentTypeData = {
name,
description,
group_ids: selectedGroups,
group_ids: selectedGroups.map(id => parseInt(id, 10)),
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,
evaluation_template_id: evaluationTemplateId ? parseInt(evaluationTemplateId) : null,
summary_template_id: summaryTemplateId ? parseInt(summaryTemplateId) : null
vlm_extraction_template_id: vlmExtractionTemplateId ? parseInt(vlmExtractionTemplateId) : null
};
// 调用API创建或更新文档类型
let response;
if (id) {
@@ -209,12 +190,12 @@ export async function action({ request }: ActionFunctionArgs) {
// 创建新文档类型
response = await createDocumentType(documentTypeData, frontendJWT);
}
if (response.error) {
console.error("保存/更新文档类型失败:", response.error);
throw new Error(response.error);
}
// 操作成功,重定向到列表页
return redirect("/document-types");
} catch (error) {
@@ -232,24 +213,27 @@ export default function DocumentTypeNew() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const isEditMode = searchParams.has("id");
const {
documentType,
ruleGroups,
entryModules,
llmExtractionTemplates,
vlmExtractionTemplates,
evaluationTemplates,
summaryTemplates
vlmExtractionTemplates
} = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('provin');
const isReadOnly = !hasEditPermission;
// 权限控制
const { canCreate, canUpdate } = usePermission();
const canCreateType = canCreate('document_type');
const canUpdateType = canUpdate('document_type');
const urlMode = searchParams.get('mode');
const isViewMode = urlMode === 'view';
const hasEditPermission = isEditMode ? canUpdateType : canCreateType;
const isReadOnly = isViewMode || !hasEditPermission;
// 状态管理
const [formData, setFormData] = useState({
@@ -257,11 +241,9 @@ export default function DocumentTypeNew() {
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 || "",
summaryTemplateId: documentType?.summary_template_id || "",
selectedGroups: documentType?.groups?.map((g: { id: string }) => g.id) || []
llmExtractionTemplateId: documentType?.llm_extraction_template_id?.toString() || "",
vlmExtractionTemplateId: documentType?.vlm_extraction_template_id?.toString() || "",
selectedGroups: documentType?.groups?.map((g: { id: string | number }) => g.id.toString()) || []
});
// 添加本地验证错误状态
@@ -275,10 +257,13 @@ export default function DocumentTypeNew() {
name: false,
llmExtractionTemplate: false,
vlmExtractionTemplate: false,
evaluationTemplate: false,
summaryTemplate: false,
groups: false
});
// 客户端调试信息ruleGroups
useEffect(() => {
console.log('返回的评查点分组数据',ruleGroups)
}, [ruleGroups])
// 从actionData初始化本地错误
useEffect(() => {
@@ -292,6 +277,12 @@ export default function DocumentTypeNew() {
// 分组展开状态
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
// 动态加载的子分组数据(groupId -> children[]
const [groupChildrenMap, setGroupChildrenMap] = useState<Record<string, RuleGroup[]>>({});
// 子分组加载状态
const [loadingChildren, setLoadingChildren] = useState<Record<string, boolean>>({});
// 当文档类型数据加载完成时更新表单
useEffect(() => {
@@ -301,30 +292,62 @@ export default function DocumentTypeNew() {
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 || "",
summaryTemplateId: documentType.summary_template_id || "",
selectedGroups: documentType.groups.map((g: { id: string }) => g.id)
llmExtractionTemplateId: documentType.llm_extraction_template_id?.toString() || "",
vlmExtractionTemplateId: documentType.vlm_extraction_template_id?.toString() || "",
selectedGroups: documentType.groups.map((g: { id: string | number }) => g.id.toString())
});
// 初始化展开状态 - 对于有选中子分组的父分组,默认展开
const newExpandedGroups: Record<string, boolean> = {};
ruleGroups.forEach((parentGroup: RuleGroup) => {
// 如果父分组被选中或者有子分组被选中,则展开
const isParentSelected = documentType.groups.some((g: { id: string }) => g.id === parentGroup.id);
const hasSelectedChild = parentGroup.children &&
parentGroup.children.some(child =>
documentType.groups.some((g: { id: string }) => g.id === child.id)
);
if (isParentSelected || hasSelectedChild) {
newExpandedGroups[parentGroup.id] = true;
// 初始化展开状态 - 如果选中的是一级分组,需要加载子分组并展开
const loadInitialChildren = async () => {
const newExpandedGroups: Record<string, boolean> = {};
const newChildrenMap: Record<string, RuleGroup[]> = {};
// 获取 frontendJWT
let frontendJWT: string | undefined = undefined;
if (typeof window !== 'undefined') {
const userInfoStr = localStorage.getItem('user_info');
if (userInfoStr) {
try {
const userInfo = JSON.parse(userInfoStr);
frontendJWT = userInfo.frontendJWT;
} catch (e) {
console.error('解析用户信息失败:', e);
}
}
}
});
setExpandedGroups(newExpandedGroups);
// 遍历所有一级分组,检查是否被选中
for (const parentGroup of ruleGroups) {
const isParentSelected = documentType.groups.some((g: { id: string | number }) =>
g.id.toString() === parentGroup.id.toString()
);
if (isParentSelected) {
// 标记为展开
newExpandedGroups[parentGroup.id] = true;
// 加载子分组
try {
const response = await getEvaluationPointGroupChildren(
parentGroup.id,
{ pageSize: 1000 },
frontendJWT
);
if (!response.error && response.data) {
newChildrenMap[parentGroup.id] = response.data;
}
} catch (error) {
console.error(`加载分组 ${parentGroup.id} 的子分组失败:`, error);
}
}
}
setExpandedGroups(newExpandedGroups);
setGroupChildrenMap(newChildrenMap);
};
loadInitialChildren();
}
}, [documentType, ruleGroups]);
@@ -337,10 +360,6 @@ export default function DocumentTypeNew() {
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择llm抽取提示词模板" : "";
case 'vlmExtractionTemplate':
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择vlm抽取提示词模板" : "";
case 'evaluationTemplate':
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择评查提示词模板" : "";
case 'summaryTemplate':
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择总结提示词模板" : "";
case 'groups':
return Array.isArray(value) && value.length === 0 ? "请至少选择一个关联的评查点分组" : "";
default:
@@ -350,68 +369,115 @@ export default function DocumentTypeNew() {
// 处理分组勾选
const handleGroupCheckChange = (
groupId: string,
groupId: string,
isChecked: boolean
) => {
// 选模式:清空之前所有选择,只保留当前选中的
// 选模式:添加或移除选中的分组
let newSelectedGroups: string[] = [];
if (isChecked) {
// 添加当前选中的分组
newSelectedGroups = [groupId];
// 添加当前选中的分组(避免重复)
newSelectedGroups = [...formData.selectedGroups, groupId];
} else {
// 移除取消选中的分组
newSelectedGroups = formData.selectedGroups.filter(id => id !== groupId);
}
setFormData(prev => ({ ...prev, selectedGroups: newSelectedGroups }));
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, groups: true}));
// 实时验证
const error = validateField('groups', newSelectedGroups);
setFormErrors(prev => ({...prev, groups: error}));
};
// 修复展开/折叠功能
const handleGroupExpand = (groupId: string, event: React.MouseEvent) => {
// 修复展开/折叠功能 - 动态加载子分组
const handleGroupExpand = async (groupId: string, event: React.MouseEvent) => {
// 阻止事件冒泡,避免触发checkbox选中
event.stopPropagation();
const isCurrentlyExpanded = expandedGroups[groupId];
// 如果当前是折叠状态,准备展开
if (!isCurrentlyExpanded) {
// 检查是否已经加载过子分组
if (!groupChildrenMap[groupId]) {
// 还未加载,开始加载
setLoadingChildren(prev => ({ ...prev, [groupId]: true }));
try {
// 获取用户 token
let frontendJWT: string | undefined = undefined;
if (typeof window !== 'undefined') {
const userInfoStr = localStorage.getItem('user_info');
if (userInfoStr) {
try {
const userInfo = JSON.parse(userInfoStr);
frontendJWT = userInfo.frontendJWT;
} catch (e) {
console.error('解析用户信息失败:', e);
}
}
}
// 调用 API 获取子分组
const response = await getEvaluationPointGroupChildren(
groupId,
{ pageSize: 1000 }, // 获取所有子分组
frontendJWT
);
if (response.error) {
console.error('获取子分组失败:', response.error);
toastService.error('获取子分组失败');
setLoadingChildren(prev => ({ ...prev, [groupId]: false }));
return;
}
// 保存子分组数据
setGroupChildrenMap(prev => ({
...prev,
[groupId]: response.data || []
}));
setLoadingChildren(prev => ({ ...prev, [groupId]: false }));
} catch (error) {
console.error('获取子分组异常:', error);
toastService.error('获取子分组失败');
setLoadingChildren(prev => ({ ...prev, [groupId]: false }));
return;
}
}
}
// 切换展开/折叠状态
setExpandedGroups(prev => ({
...prev,
[groupId]: !prev[groupId]
[groupId]: !isCurrentlyExpanded
}));
};
// 处理表单输入变化
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
// 根据name属性映射到对应的formData字段
let fieldName = name;
if (name === 'llm_extraction_template') {
fieldName = 'llmExtractionTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, llmExtractionTemplate: true}));
} else if (name === 'vlm_extraction_template') {
fieldName = 'vlmExtractionTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, vlmExtractionTemplate: true}));
} else if (name === 'evaluation_template') {
fieldName = 'evaluationTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, evaluationTemplate: true}));
} else if (name === 'summary_template') {
fieldName = 'summaryTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, summaryTemplate: true}));
} else if (name === 'name') {
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, name: true}));
}
setFormData(prev => ({ ...prev, [fieldName]: value }));
// 实时验证
if (name === 'name') {
const error = validateField(name, value);
@@ -422,42 +488,43 @@ export default function DocumentTypeNew() {
} else if (name === 'vlm_extraction_template') {
const error = validateField('vlmExtractionTemplate', value);
setFormErrors(prev => ({...prev, vlmExtractionTemplate: error}));
} else if (name === 'evaluation_template') {
const error = validateField('evaluationTemplate', value);
setFormErrors(prev => ({...prev, evaluationTemplate: error}));
} else if (name === 'summary_template') {
const error = validateField('summaryTemplate', value);
setFormErrors(prev => ({...prev, summaryTemplate: error}));
}
};
// 处理表单提交前验证
const handleBeforeSubmit = (e: React.FormEvent) => {
// 权限检查
if (isEditMode && !canUpdateType) {
toastService.warning('您没有修改权限,无法保存更改');
e.preventDefault();
return;
}
if (!isEditMode && !canCreateType) {
toastService.warning('您没有创建权限,无法新增文档类型');
e.preventDefault();
return;
}
// 标记所有字段为已触摸
setTouchedFields({
name: true,
llmExtractionTemplate: true,
vlmExtractionTemplate: true,
evaluationTemplate: true,
summaryTemplate: true,
groups: true
});
// 验证所有字段
const errors = {
name: validateField('name', formData.name),
llmExtractionTemplate: validateField('llmExtractionTemplate', formData.llmExtractionTemplateId),
vlmExtractionTemplate: validateField('vlmExtractionTemplate', formData.vlmExtractionTemplateId),
evaluationTemplate: validateField('evaluationTemplate', formData.evaluationTemplateId),
summaryTemplate: validateField('summaryTemplate', formData.summaryTemplateId),
groups: validateField('groups', formData.selectedGroups)
};
setFormErrors(errors);
// 如果有错误,阻止提交
if (errors.name || errors.llmExtractionTemplate || errors.vlmExtractionTemplate ||
errors.evaluationTemplate || errors.summaryTemplate || errors.groups) {
if (errors.name || errors.llmExtractionTemplate || errors.vlmExtractionTemplate || errors.groups) {
e.preventDefault();
}
};
@@ -576,8 +643,8 @@ export default function DocumentTypeNew() {
onChange={handleInputChange}
disabled={isReadOnly}
>
<option value="">llm抽取提示词模板</option>
{llmExtractionTemplates.map((template: PromptTemplateUI) => (
{/* <option value="">请选择llm抽取提示词模板</option> */}
{llmExtractionTemplates.map((template: { id: number; template_name: string }) => (
<option key={template.id} value={template.id}>
{template.template_name}
</option>
@@ -600,8 +667,8 @@ export default function DocumentTypeNew() {
onChange={handleInputChange}
disabled={isReadOnly}
>
<option value="">vlm抽取提示词模板</option>
{vlmExtractionTemplates.map((template: PromptTemplateUI) => (
{/* <option value="">请选择vlm抽取提示词模板</option> */}
{vlmExtractionTemplates.map((template: { id: number; template_name: string }) => (
<option key={template.id} value={template.id}>
{template.template_name}
</option>
@@ -612,54 +679,6 @@ export default function DocumentTypeNew() {
)}
<div className="form-tip">vlm提示词模板</div>
</div>
{/* 评查提示词模板 */}
<div className="w-full">
<label htmlFor="evaluation-template" className="form-label"> <span className="text-red-500">*</span></label>
<select
id="evaluation-template"
name="evaluation_template"
className={`form-select ${touchedFields.evaluationTemplate && formErrors?.evaluationTemplate ? 'input-error' : ''}`}
value={formData.evaluationTemplateId}
onChange={handleInputChange}
disabled={isReadOnly}
>
<option value=""></option>
{evaluationTemplates.map((template: PromptTemplateUI) => (
<option key={template.id} value={template.id}>
{template.template_name}
</option>
))}
</select>
{touchedFields.evaluationTemplate && formErrors?.evaluationTemplate && (
<div className="error-message">{formErrors.evaluationTemplate}</div>
)}
<div className="form-tip"></div>
</div>
{/* 总结提示词模板 */}
<div className="w-full">
<label htmlFor="summary-template" className="form-label"> <span className="text-red-500">*</span></label>
<select
id="summary-template"
name="summary_template"
className={`form-select ${touchedFields.summaryTemplate && formErrors?.summaryTemplate ? 'input-error' : ''}`}
value={formData.summaryTemplateId}
onChange={handleInputChange}
disabled={isReadOnly}
>
<option value=""></option>
{summaryTemplates.map((template: PromptTemplateUI) => (
<option key={template.id} value={template.id}>
{template.template_name}
</option>
))}
</select>
{touchedFields.summaryTemplate && formErrors?.summaryTemplate && (
<div className="error-message">{formErrors.summaryTemplate}</div>
)}
<div className="form-tip"></div>
</div>
</div>
{/* 关联评查点分组 */}
@@ -689,13 +708,13 @@ export default function DocumentTypeNew() {
<i className={`ri-arrow-${expandedGroups[group.id] ? 'down' : 'right'}-s-line text-primary`}></i>
</button>
<input
type="radio"
type="checkbox"
id={`group-${group.id}`}
name="checkpoint_group_ids"
value={group.id}
checked={formData.selectedGroups.includes(group.id)}
onChange={(e) => handleGroupCheckChange(group.id, e.target.checked)}
className="radio-input"
className="checkbox-input"
disabled={isReadOnly}
/>
<label
@@ -707,21 +726,35 @@ 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"
style={{ paddingLeft: '2.5rem', opacity: 0.9 }}
>
<i className="ri-subtract-line" style={{ marginRight: '8px', color: '#9ca3af' }}></i>
<span className="checkbox-label">
{child.name}
<span className="group-badge child-badge"></span>
</span>
</div>
))
{/* 子分组 - 动态加载并展示 */}
{expandedGroups[group.id] && (
<>
{loadingChildren[group.id] ? (
<div
className="checkbox-item child-checkbox-item"
style={{ paddingLeft: '2.5rem', opacity: 0.9 }}
>
<i className="ri-loader-4-line spin" style={{ marginRight: '8px', color: '#9ca3af' }}></i>
<span className="checkbox-label" style={{ color: '#9ca3af' }}>
...
</span>
</div>
) : (
groupChildrenMap[group.id]?.map((child: RuleGroup) => (
<div
key={child.id}
className="checkbox-item child-checkbox-item"
style={{ paddingLeft: '2.5rem', opacity: 0.9 }}
>
<i className="ri-subtract-line" style={{ marginRight: '8px', color: '#9ca3af' }}></i>
<span className="checkbox-label">
{child.name}
<span className="group-badge child-badge"></span>
</span>
</div>
))
)}
</>
)}
</React.Fragment>
))}
+12 -8
View File
@@ -1008,14 +1008,18 @@ export default function DocumentsIndex() {
})()}
</td>
<td className="px-4 py-3" style={{ width: '8%' }}>
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
historyDoc.auditStatus === 1 ? 'bg-green-100 text-green-800' :
historyDoc.auditStatus === -1 ? 'bg-red-100 text-red-800' :
'bg-blue-100 text-blue-800'
}`}>
<i className={`${auditStatusMapping[historyDoc.auditStatus]?.icon || 'ri-time-line'} mr-1`}></i>
<span>{auditStatusMapping[historyDoc.auditStatus]?.label || '待审核'}</span>
</div>
{(() => {
// 处理auditStatus为null或undefined的情况,默认为0(待审核)
const auditStatus = historyDoc.auditStatus != null ? historyDoc.auditStatus : 0;
const statusKey = auditStatus.toString();
const statusInfo = auditStatusMapping[statusKey] || auditStatusMapping["0"];
return (
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-${statusInfo.color}-100 text-${statusInfo.color}-800`}>
<i className={`${statusInfo.icon} mr-1`}></i>
<span>{statusInfo.label}</span>
</div>
);
})()}
</td>
<td className="px-4 py-3" style={{ width: '15%' }}>
<ResultStats
+26 -21
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useSearchParams, useNavigate, useLoaderData, useRouteLoaderData, useRevalidator } from "@remix-run/react";
import { useSearchParams, useNavigate, useLoaderData, useRevalidator } from "@remix-run/react";
import { ClientLoaderFunctionArgs, MetaFunction } from "@remix-run/react";
import { Table } from "~/components/ui/Table";
import { Card } from "~/components/ui/Card";
@@ -8,6 +8,7 @@ import { Pagination } from "~/components/ui/Pagination";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
import { toastService } from "~/components/ui/Toast";
import { messageService } from "~/components/ui/MessageModal";
import { usePermission } from "~/hooks/usePermission";
import {
getEntryModules,
deleteEntryModule,
@@ -113,16 +114,12 @@ export default function EntryModulesList() {
const loaderData = useLoaderData<LoaderData>();
const { modules, total, error } = loaderData;
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('admin') || userRole.toLowerCase().includes('developer');
// 调试信息
useEffect(() => {
console.log('📋 [EntryModules] 用户角色:', userRole);
console.log('📋 [EntryModules] 是否有编辑权限:', hasEditPermission);
}, [userRole, hasEditPermission]);
// ✅ 使用权限 Hook
const { canCreate, canUpdate, canDelete, canView } = usePermission();
const canCreateModule = canCreate('entry_module');
const canUpdateModule = canUpdate('entry_module');
const canDeleteModule = canDelete('entry_module');
const canViewModule = canView('entry_module');
// 获取搜索参数
const name = searchParams.get('name') || '';
@@ -179,6 +176,12 @@ export default function EntryModulesList() {
// 处理删除入口模块
const handleDelete = async (id: number) => {
// ✅ 检查删除权限
if (!canDeleteModule) {
toastService.warning('您没有删除权限');
return;
}
messageService.show({
title: "确认删除",
message: "确定要删除该入口模块吗?此操作不可撤销。",
@@ -317,15 +320,16 @@ export default function EntryModulesList() {
width: '180px',
render: (_: any, record: EntryModule) => (
<div className="operations-cell">
<button
onClick={() => handleEdit(record.id!)}
className="operation-btn"
disabled={!hasEditPermission}
title={hasEditPermission ? "编辑入口模块" : "无权限"}
>
<i className="ri-edit-line"></i> {hasEditPermission ? '编辑' : '查看'}
</button>
{hasEditPermission && (
{ canViewModule &&
<button
onClick={() => handleEdit(record.id!)}
className="operation-btn"
title="查看/编辑入口模块"
>
<i className="ri-edit-line"></i> {canUpdateModule ? '编辑' : '查看'}
</button>
}
{canDeleteModule && (
<button
type="button"
className="operation-btn !text-[--color-error]"
@@ -350,7 +354,8 @@ export default function EntryModulesList() {
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-600 mt-1">Logo图片和适用地区设置</p>
</div>
{hasEditPermission && (
{/* ✅ 仅在有创建权限时显示新建按钮 */}
{canCreateModule && (
<Button
type="primary"
icon="ri-add-line"
+51 -11
View File
@@ -5,6 +5,7 @@ import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { toastService } from "~/components/ui/Toast";
import { Modal } from "~/components/ui/Modal";
import { usePermission } from "~/hooks/usePermission";
import {
getEntryModuleById,
createEntryModule,
@@ -88,6 +89,15 @@ export default function EntryModuleNew() {
const id = searchParams.get('id');
const isEditMode = !!id;
// ✅ 使用权限 Hook
const { canCreate, canUpdate } = usePermission();
const canCreateModule = canCreate('entry_module');
const canUpdateModule = canUpdate('entry_module');
// ✅ 根据当前操作类型判断权限
const hasEditPermission = isEditMode ? canUpdateModule : canCreateModule;
const isReadOnly = !hasEditPermission;
// 表单状态
const [name, setName] = useState(module?.name || '');
const [description, setDescription] = useState(module?.description || '');
@@ -104,6 +114,17 @@ export default function EntryModuleNew() {
const fileInputRef = useRef<HTMLInputElement>(null);
// ✅ 页面加载时检查权限并提示(仅在只读模式下提示)
useEffect(() => {
if (isReadOnly) {
if (isEditMode) {
toastService.info('当前为查看模式,您没有编辑权限');
} else {
toastService.warning('您没有创建入口模块的权限');
}
}
}, [isReadOnly, isEditMode]);
// 处理loader加载数据的时候的错误
useEffect(() => {
if (error) {
@@ -208,6 +229,17 @@ export default function EntryModuleNew() {
// 处理表单提交
const handleSubmit = async () => {
// ✅ Runtime permission check
if (isEditMode && !canUpdateModule) {
toastService.warning('您没有修改权限,无法保存更改');
return;
}
if (!isEditMode && !canCreateModule) {
toastService.warning('您没有创建权限,无法新增入口模块');
return;
}
if (!validateForm()) return;
setIsSubmitting(true);
@@ -270,10 +302,10 @@ export default function EntryModuleNew() {
<div className="page-header">
<div>
<h1 className="text-2xl font-bold text-gray-900">
{isEditMode ? '编辑入口模块' : '新建入口模块'}
{isEditMode ? (isReadOnly ? '查看入口模块' : '编辑入口模块') : '新建入口模块'}
</h1>
<p className="text-sm text-gray-600 mt-1">
{isEditMode ? '修改入口模块信息' : '创建新的入口模块'}
{isEditMode ? (isReadOnly ? '查看入口模块信息' : '修改入口模块信息') : '创建新的入口模块'}
</p>
</div>
</div>
@@ -293,6 +325,7 @@ export default function EntryModuleNew() {
placeholder="请输入模块名称,如:合同管理"
maxLength={255}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
disabled={isReadOnly}
/>
</div>
@@ -305,6 +338,7 @@ export default function EntryModuleNew() {
placeholder="请输入模块描述"
className="w-full px-3 py-2 border border-gray-300 rounded-md"
rows={4}
disabled={isReadOnly}
/>
</div>
@@ -317,6 +351,7 @@ export default function EntryModuleNew() {
type="default"
icon="ri-upload-line"
onClick={() => fileInputRef.current?.click()}
disabled={isReadOnly}
>
{logoPreview ? '更换图片' : '上传图片'}
</Button>
@@ -330,6 +365,7 @@ export default function EntryModuleNew() {
accept="image/*"
onChange={handleLogoChange}
className="hidden"
disabled={isReadOnly}
/>
{logoPreview && (
<div className="mt-3">
@@ -355,13 +391,14 @@ export default function EntryModuleNew() {
{AREA_OPTIONS.map(option => (
<label
key={option.value}
className="flex items-center space-x-2 cursor-pointer"
className={`flex items-center space-x-2 ${isReadOnly ? 'cursor-not-allowed' : 'cursor-pointer'}`}
>
<input
type="checkbox"
checked={selectedAreas.includes(option.value)}
onChange={() => handleAreaToggle(option.value)}
className="w-4 h-4 border-gray-300 rounded cursor-pointer"
disabled={isReadOnly}
/>
<span className="text-sm text-gray-700">{option.label}</span>
</label>
@@ -379,14 +416,17 @@ export default function EntryModuleNew() {
>
</Button>
<Button
type="primary"
onClick={handleSubmit}
loading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? '提交中...' : (isEditMode ? '保存' : '创建')}
</Button>
{/* ✅ 仅在有对应权限时显示保存/创建按钮 */}
{hasEditPermission && (
<Button
type="primary"
onClick={handleSubmit}
loading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? '提交中...' : (isEditMode ? '保存' : '创建')}
</Button>
)}
</div>
</Card>
+67 -31
View File
@@ -9,6 +9,7 @@ import { Table } from "~/components/ui/Table";
import { Pagination } from "~/components/ui/Pagination";
import { getPromptTemplates, deletePromptTemplate, type PromptTemplateUI } from "~/api/prompts/prompts";
import { toastService, messageService } from "~/components/ui";
import { usePermission, PermissionGuard } from "~/hooks/usePermission";
// 样式链接
export function links() {
@@ -41,7 +42,7 @@ interface ActionData {
// 数据加载器
export async function loader({ request }: LoaderFunctionArgs) {
try {
// 获取用户会话信息
// 获取用户会话信息(服务端需要获取 JWT token
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
@@ -102,14 +103,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
// Action函数 - 处理删除请求
export async function action({ request }: ActionFunctionArgs) {
// 获取用户会话信息(服务端需要获取 JWT token)
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
const formData = await request.formData();
const id = formData.get("id") as string;
const intent = formData.get("intent") as string;
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
if (intent === "delete" && id) {
try {
const result = await deletePromptTemplate(id, frontendJWT);
@@ -138,16 +139,35 @@ export default function PromptsIndex() {
const [isLoading, setIsLoading] = useState(false);
const fetcher = useFetcher<ActionData>();
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('provin');
// 🔐 使用新的权限检查Hook
const {
canCreate,
canUpdate,
canDelete,
canView,
hasPermission,
permissions,
userRole
} = usePermission();
// 检查各项权限
const canCreateTemplate = canCreate('prompt_template');
const canEditTemplate = canUpdate('prompt_template');
const canDeleteTemplate = canDelete('prompt_template');
const canViewTemplate = canView('prompt_template');
// 调试信息
useEffect(() => {
console.log('📋 [Prompts] 用户角色:', userRole);
console.log('📋 [Prompts] 是否有编辑权限:', hasEditPermission);
}, [userRole, hasEditPermission]);
// useEffect(() => {
// console.log('📋 [Prompts] 模板数据:', templates);
// console.log('📋 [Prompts] 用户角色:', userRole);
// console.log('📋 [Prompts] 权限列表:', permissions);
// console.log('📋 [Prompts] 权限检查结果:', {
// canCreate: canCreateTemplate,
// canEdit: canEditTemplate,
// canDelete: canDeleteTemplate,
// canView: canViewTemplate
// });
// }, [userRole, permissions, templates, canCreateTemplate, canEditTemplate, canDeleteTemplate, canViewTemplate]);
// 处理搜索名称
const handleNameSearch = (value: string) => {
@@ -234,6 +254,13 @@ export default function PromptsIndex() {
});
};
// 监听 loader 错误
useEffect(() => {
if (error) {
toastService.error(error);
}
}, [error]);
// 监听 fetcher 状态变化
useEffect(() => {
if (fetcher.state === 'idle' && fetcher.data) {
@@ -366,6 +393,7 @@ export default function PromptsIndex() {
render: (_: unknown, record: PromptTemplateUI) => (
<div>
{record.status === 'system' ? (
// 系统预设模板:只能查看,有编辑权限的可以复制
<>
<button
className="operation-btn text-primary"
@@ -373,7 +401,8 @@ export default function PromptsIndex() {
>
<i className="ri-eye-line"></i>
</button>
{hasEditPermission && (
{/* 🔐 复制按钮需要创建权限 */}
{canCreateTemplate && (
<button
className="operation-btn text-primary"
onClick={() => handleCloneTemplate(record.id)}
@@ -383,14 +412,27 @@ export default function PromptsIndex() {
)}
</>
) : (
// 自定义模板:根据权限显示编辑/查看/删除
<>
<button
className="operation-btn text-primary"
onClick={() => hasEditPermission ? handleEditTemplate(record.id) : handleViewTemplate(record.id)}
>
<i className={hasEditPermission ? "ri-edit-line" : "ri-eye-line"}></i> {hasEditPermission ? '编辑' : '查看'}
</button>
{hasEditPermission && (
{/* 🔐 有编辑权限显示编辑按钮,否则显示查看按钮 */}
{canEditTemplate ? (
<button
className="operation-btn text-primary"
onClick={() => handleEditTemplate(record.id)}
>
<i className="ri-edit-line"></i>
</button>
) : canViewTemplate ? (
<button
className="operation-btn text-primary"
onClick={() => handleViewTemplate(record.id)}
>
<i className="ri-eye-line"></i>
</button>
) : null}
{/* 🔐 删除按钮需要删除权限 */}
{canDeleteTemplate && (
<button
className="operation-btn text-error"
onClick={() => handleDeleteTemplate(record.id)}
@@ -412,7 +454,8 @@ export default function PromptsIndex() {
<div className="page-header">
<h2 className="page-title"></h2>
<div>
{hasEditPermission && (
{/* 🔐 使用权限控制显示新增按钮 */}
<PermissionGuard permission="prompt_template:create:write">
<Button
type="primary"
icon="ri-add-line"
@@ -420,7 +463,7 @@ export default function PromptsIndex() {
>
</Button>
)}
</PermissionGuard>
</div>
</div>
@@ -485,14 +528,7 @@ export default function PromptsIndex() {
className="flex-1 min-w-[200px]"
/>
</FilterPanel>
{/* 错误信息 */}
{error && (
<div className="error-alert mb-4 p-4 bg-red-50 text-red-700 rounded-md">
<i className="ri-error-warning-line mr-2"></i> {error}
</div>
)}
{/* 数据表格 */}
<Card bodyClassName="px-4 py-4">
<Table
+17 -18
View File
@@ -4,7 +4,7 @@ import { Link, useLoaderData, useNavigation, useActionData, Form } from "@remix-
import { Button } from "~/components/ui/Button";
import newStyles from "~/styles/pages/prompts_new.css?url";
import { getPromptTemplate, createPromptTemplate, updatePromptTemplate, type PromptTemplateUI } from "~/api/prompts/prompts";
// import { toastService } from "~/components/ui";
import { toastService } from "~/components/ui";
// 样式链接
export function links() {
@@ -352,7 +352,21 @@ export default function PromptsNew() {
setPageTitle("新增提示词模板");
}
}, [template, mode, actionData?.formData]);
// 监听 loader 错误
useEffect(() => {
if (error) {
toastService.error(error);
}
}, [error]);
// 监听 action 错误
useEffect(() => {
if (actionData?.errors?.general) {
toastService.error(actionData.errors.general);
}
}, [actionData?.errors?.general]);
// 处理输入变化
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
@@ -462,22 +476,7 @@ export default function PromptsNew() {
)}
</div>
</div>
{/* 错误信息 */}
{error && (
<div className="alert alert-error mb-4">
<i className="ri-error-warning-line"></i>
<div>{error}</div>
</div>
)}
{actionData?.errors?.general && (
<div className="alert alert-error mb-4">
<i className="ri-error-warning-line"></i>
<div>{actionData.errors.general}</div>
</div>
)}
{/* 查看模式提示 */}
{isViewMode && (
<div className="alert alert-info">
+233 -11
View File
@@ -529,9 +529,11 @@ interface AssignUserModalProps {
onClose: () => void;
onSuccess: () => void;
role: RoleInfo | null;
isCityAdmin?: boolean;
currentUserArea?: string;
}
function AssignUserModal({ isOpen, onClose, onSuccess, role }: AssignUserModalProps) {
function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, currentUserArea }: AssignUserModalProps) {
const [allUsers, setAllUsers] = useState<UserInfo[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [searchTerm, setSearchTerm] = useState('');
@@ -552,12 +554,24 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role }: AssignUserModalPr
setLoadingUsers(true);
try {
const users = await getAllUsers();
setAllUsers(users);
// v3.3: 市级管理员只能看到同地区的用户(使用 area 字段)
let filteredUsers = users;
if (isCityAdmin && currentUserArea) {
filteredUsers = users.filter(user => user.area === currentUserArea);
console.log('🔒 [AssignUserModal v3.3] 市级管理员用户过滤:', {
当前地区: currentUserArea,
原始用户数: users.length,
过滤后用户数: filteredUsers.length
});
}
setAllUsers(filteredUsers);
// 批量获取每个用户的角色
const rolesMap = new Map<number, RoleInfo[]>();
await Promise.all(
users.map(async (user) => {
filteredUsers.map(async (user) => {
const roles = await getUserRoles(user.id);
rolesMap.set(user.id, roles);
})
@@ -678,6 +692,14 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role }: AssignUserModalPr
}
>
<div className="assign-user-modal">
{/* v3.3: 市级管理员地区过滤提示 */}
{isCityAdmin && currentUserArea && (
<div className="form-notice info" style={{ marginBottom: '12px' }}>
<i className="ri-information-line"></i>
<span> {currentUserArea} </span>
</div>
)}
{/* 搜索框 */}
<div className="search-box">
<i className="ri-search-line"></i>
@@ -784,6 +806,12 @@ export default function RolePermissions() {
const [activeTab, setActiveTab] = useState<'permissions' | 'users'>('permissions');
const [loading, setLoading] = useState(true);
// v3.3: 检查当前用户角色和地区
const [currentUserRole, setCurrentUserRole] = useState('');
const [currentUserArea, setCurrentUserArea] = useState('');
const [isProvincialAdmin, setIsProvincialAdmin] = useState(false);
const [isCityAdmin, setIsCityAdmin] = useState(false);
// 模态框状态
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
@@ -800,6 +828,13 @@ export default function RolePermissions() {
} | null>(null);
const [deleteCountdown, setDeleteCountdown] = useState(3);
// 权限警告Modal状态
const [showPermissionWarning, setShowPermissionWarning] = useState(false);
const [pendingRouteChange, setPendingRouteChange] = useState<{
routeId: number;
checked: boolean;
} | null>(null);
// 路由权限相关状态
const [selectedRouteIds, setSelectedRouteIds] = useState<number[]>([]);
const [roleUsers, setRoleUsers] = useState<UserInfo[]>([]);
@@ -829,19 +864,62 @@ export default function RolePermissions() {
const loadData = async () => {
setLoading(true);
try {
// v3.3: 检查当前用户角色和地区
if (typeof window !== 'undefined') {
const userInfoStr = localStorage.getItem('user_info');
if (userInfoStr) {
try {
const userInfo = JSON.parse(userInfoStr);
const userRole = userInfo.user_role || '';
const userArea = userInfo.area || ''; // v3.3: 使用 area 字段进行地区隔离
setCurrentUserRole(userRole);
setCurrentUserArea(userArea);
setIsProvincialAdmin(userRole === 'provincial_admin');
setIsCityAdmin(userRole === 'admin');
console.log('🔑 [RolePermissions v3.3] 当前用户信息:', {
role: userRole,
area: userArea,
isProvincialAdmin: userRole === 'provincial_admin',
isCityAdmin: userRole === 'admin'
});
} catch (e) {
console.error('❌ [RolePermissions] 解析用户信息失败:', e);
}
}
}
const [rolesData, routesData, usersData] = await Promise.all([
getRoles(),
getRoutes(),
getAllUsers()
]);
setRoles(rolesData);
setRoutes(routesData);
setUsers(usersData);
// v3.3: 角色列表对所有人可见(不过滤)
const filteredRoles = rolesData;
// 默认选中第一个角色
if (rolesData.length > 0) {
handleSelectRole(rolesData[0]);
// v3.3: 根据用户地区过滤可见的用户列表
let filteredUsers = usersData;
if (isCityAdmin && currentUserArea) {
// 市级管理员只能看到同地区的用户(使用 area 字段)
filteredUsers = usersData.filter(user =>
user.area === currentUserArea
);
console.log('🔒 [RolePermissions v3.3] 市级管理员用户过滤:', {
当前地区: currentUserArea,
原始用户数: usersData.length,
过滤后用户数: filteredUsers.length
});
}
setRoles(filteredRoles);
setRoutes(routesData);
setUsers(filteredUsers);
// 默认选中第一个角色(使用过滤后的列表)
if (filteredRoles.length > 0) {
handleSelectRole(filteredRoles[0]);
}
} catch (error) {
console.error("加载数据失败:", error);
@@ -888,6 +966,20 @@ export default function RolePermissions() {
setRoleUsers(users);
};
// 递归查找路由
const findRouteById = (routes: RouteInfo[], routeId: number): RouteInfo | null => {
for (const route of routes) {
if (route.id === routeId) {
return route;
}
if (route.children && route.children.length > 0) {
const found = findRouteById(route.children, routeId);
if (found) return found;
}
}
return null;
};
// 递归获取所有路由ID(包括子路由)
const getAllRouteIds = (routes: RouteInfo[]): number[] => {
let ids: number[] = [];
@@ -900,8 +992,34 @@ export default function RolePermissions() {
return ids;
};
// 递归检查路由树中是否包含指定路径的路由
const containsRoutePath = (routes: RouteInfo[], targetPath: string): boolean => {
for (const route of routes) {
if (route.route_path === targetPath) {
return true;
}
if (route.children && route.children.length > 0) {
if (containsRoutePath(route.children, targetPath)) {
return true;
}
}
}
return false;
};
// 切换路由权限
const handleToggleRoute = (routeId: number, checked: boolean) => {
// 检查是否正在取消勾选 /role-permissions 路由
if (!checked) {
const route = findRouteById(routes, routeId);
if (route && route.route_path === '/role-permissions') {
// 显示警告模态框
setPendingRouteChange({ routeId, checked });
setShowPermissionWarning(true);
return;
}
}
if (checked) {
setSelectedRouteIds([...selectedRouteIds, routeId]);
} else {
@@ -911,6 +1029,20 @@ export default function RolePermissions() {
// 切换父路由(包括所有子路由)
const handleToggleParentRoute = (route: RouteInfo, checked: boolean) => {
// 检查是否正在取消勾选包含 /role-permissions 的父路由
if (!checked) {
const allRoutes = route.children ? [route, ...route.children] : [route];
const hasRolePermissionsRoute = allRoutes.some(r => r.route_path === '/role-permissions') ||
(route.children && containsRoutePath(route.children, '/role-permissions'));
if (route.route_path === '/role-permissions' || hasRolePermissionsRoute) {
// 显示警告模态框,传递 route 对象表示是父路由操作
setPendingRouteChange({ routeId: route.id, checked });
setShowPermissionWarning(true);
return;
}
}
const childIds = route.children ? getAllRouteIds(route.children) : [];
const allIds = [route.id, ...childIds];
@@ -926,6 +1058,32 @@ export default function RolePermissions() {
}
};
// 确认取消角色权限管理路由
const confirmRemovePermissionRoute = () => {
if (!pendingRouteChange) return;
const { routeId, checked } = pendingRouteChange;
const route = findRouteById(routes, routeId);
if (route) {
// 如果是父路由,取消所有子路由
if (route.children && route.children.length > 0) {
const childIds = getAllRouteIds(route.children);
const allIds = [route.id, ...childIds];
setSelectedRouteIds(selectedRouteIds.filter(id => !allIds.includes(id)));
} else {
// 单个路由
setSelectedRouteIds(selectedRouteIds.filter(id => id !== routeId));
}
}
// 关闭模态框并重置状态
setShowPermissionWarning(false);
setPendingRouteChange(null);
toastService.warning('已取消角色权限管理路由,请谨慎保存权限配置');
};
// v3.0: 切换路由展开状态(显示/隐藏权限列表)
const handleToggleRouteExpand = (routeId: number) => {
setExpandedRouteIds(prev =>
@@ -1070,16 +1228,27 @@ export default function RolePermissions() {
}
};
// 保存权限 - v3.0: 同时保存路由权限和API权限
// 保存权限 - v3.3: 同时保存路由权限和API权限,仅省级管理员可操作
const handleSavePermissions = async () => {
if (!selectedRole) return;
// v3.3: 前置权限检查(仅省级管理员)
if (!isProvincialAdmin) {
toastService.error('权限不足:仅省级管理员可以修改角色路由权限');
return;
}
try {
// 1. 保存路由权限
const routeResult = await updateRoleRoutePermissions(selectedRole.id, selectedRouteIds);
// v3.3: 处理权限不足错误
if (!routeResult.success) {
toastService.error(routeResult.message);
if (routeResult.code === 4003) {
toastService.error('权限不足:仅省级管理员可以修改角色路由权限');
} else {
toastService.error(routeResult.message);
}
return;
}
@@ -1146,6 +1315,7 @@ export default function RolePermissions() {
}
}}
className="route-checkbox"
disabled={!isProvincialAdmin}
/>
<label htmlFor={`route-${route.id}`} className="route-label">
{route.icon && <i className={`${route.icon} route-icon`}></i>}
@@ -1212,6 +1382,7 @@ export default function RolePermissions() {
checked={selectedPermissionIds.includes(permission.id)}
onChange={(e) => handleTogglePermission(permission.id, e.target.checked)}
style={{ margin: 0 }}
disabled={!isProvincialAdmin}
/>
<span
style={{
@@ -1398,12 +1569,20 @@ export default function RolePermissions() {
{/* 路由权限Tab */}
{activeTab === 'permissions' && (
<div className="permissions-tab">
{/* v3.3: 权限提示(仅省级管理员可修改) */}
{!isProvincialAdmin && (
<div className="form-notice warning" style={{ marginBottom: '16px' }}>
<i className="ri-information-line"></i>
<span></span>
</div>
)}
<div className="permissions-header">
<h3> "{selectedRole.role_name}" </h3>
<Button
type="primary"
icon="ri-save-line"
onClick={handleSavePermissions}
disabled={!isProvincialAdmin}
>
</Button>
@@ -1536,6 +1715,8 @@ export default function RolePermissions() {
}
}}
role={selectedRole}
isCityAdmin={isCityAdmin}
currentUserArea={currentUserArea}
/>
{/* 确认删除模态框 */}
@@ -1614,6 +1795,47 @@ export default function RolePermissions() {
</div>
</div>
</Modal>
{/* 权限警告模态框 */}
<Modal
isOpen={showPermissionWarning}
onClose={() => {
setShowPermissionWarning(false);
setPendingRouteChange(null);
}}
title="⚠️ 警告:取消角色权限管理路由"
size="medium"
>
<div style={{ padding: '20px 0' }}>
<p style={{ marginBottom: '16px', fontSize: '15px', lineHeight: '1.6', color: '#ff6b00' }}>
<strong>"/role-permissions"</strong>
</p>
<p style={{ marginBottom: '16px', fontSize: '14px', lineHeight: '1.6' }}>
<strong></strong>访
</p>
<p style={{ marginBottom: '16px', fontSize: '14px', color: '#666' }}>
"保存权限"
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
<Button
variant="secondary"
onClick={() => {
setShowPermissionWarning(false);
setPendingRouteChange(null);
}}
>
</Button>
<Button
variant="danger"
onClick={confirmRemovePermissionRoute}
>
</Button>
</div>
</div>
</Modal>
</div>
);
}
+46 -19
View File
@@ -1,5 +1,5 @@
import { type MetaFunction } from "@remix-run/node";
import { useLoaderData, Link, useNavigate, useSearchParams, useRouteLoaderData } from "@remix-run/react";
import { useLoaderData, Link, useNavigate, useSearchParams } from "@remix-run/react";
import { useState, useEffect } from "react";
import indexStyles from "~/styles/pages/rule-groups_index.css?url";
import { Card } from "~/components/ui/Card";
@@ -17,6 +17,7 @@ import {
batchDeleteEvaluationPointGroups
} from "~/api/evaluation_points/rule-groups";
import { toastService, messageService } from "~/components/ui";
import { usePermission } from "~/hooks/usePermission";
export function links() {
return [{ rel: "stylesheet", href: indexStyles }];
@@ -78,8 +79,7 @@ export async function loader({ request }: { request: Request }) {
export default function RuleGroupsIndex() {
const loaderData = useLoaderData<typeof loader>();
const { groups: initialGroups, totalCount = 0, page = 1, pageSize = 50, frontendJWT } = loaderData;
const rootData = useRouteLoaderData("root") as { userRole: string };
const { groups: initialGroups, frontendJWT } = loaderData;
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
@@ -88,8 +88,13 @@ export default function RuleGroupsIndex() {
const [filteredChildrenMap, setFilteredChildrenMap] = useState<Record<string, RuleGroup[]>>({});
const [initialLoading, setInitialLoading] = useState<boolean>(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]); // 🆕 批量选择状态
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('provin');
// ✅ 使用权限 Hook
const { canCreate, canUpdate, canDelete, canBatch } = usePermission();
const canCreateGroup = canCreate('evaluation_group');
const canUpdateGroup = canUpdate('evaluation_group');
const canDeleteGroup = canDelete('evaluation_group');
const canBatchOperation = canBatch('evaluation_group'); // ✅ 批量操作权限
// 初始加载时自动加载所有子分组
useEffect(() => {
@@ -230,6 +235,12 @@ export default function RuleGroupsIndex() {
// 处理删除分组
const handleDeleteGroup = async (groupId: string) => {
// ✅ 检查删除权限
if (!canDeleteGroup) {
toastService.warning('您没有删除权限');
return;
}
messageService.show({
title: "确认删除",
message: "确定要删除该分组吗?此操作将同时删除该分组下的所有评查点,且不可恢复。",
@@ -277,6 +288,12 @@ export default function RuleGroupsIndex() {
// 🆕 批量启用/禁用
const handleBatchEnable = async (enable: boolean) => {
// ✅ 检查更新权限
if (!canUpdateGroup) {
toastService.warning('您没有更新权限');
return;
}
if (selectedIds.length === 0) {
toastService.warning('请先选择要操作的分组');
return;
@@ -299,6 +316,12 @@ export default function RuleGroupsIndex() {
// 🆕 批量删除
const handleBatchDelete = async () => {
// ✅ 检查删除权限
if (!canDeleteGroup) {
toastService.warning('您没有删除权限');
return;
}
if (selectedIds.length === 0) {
toastService.warning('请先选择要删除的分组');
return;
@@ -569,8 +592,8 @@ export default function RuleGroupsIndex() {
// 定义表格列配置
const columns = [
// 🆕 复选框列
...(hasEditPermission ? [{
// 🆕 复选框列 - 仅在有批量操作权限时显示
...(canBatchOperation ? [{
title: (
<input
type="checkbox"
@@ -676,9 +699,9 @@ export default function RuleGroupsIndex() {
onClick={() => navigate(`/rule-groups/new?id=${record.id}`)}
className="operation-btn"
>
<i className="ri-edit-line"></i> {hasEditPermission ? '编辑' : '查看'}
<i className="ri-edit-line"></i> {canUpdateGroup ? '编辑' : '查看'}
</button>
{hasEditPermission && (
{canDeleteGroup && (
<button
type="button"
className="operation-btn !text-[--color-error]"
@@ -720,7 +743,8 @@ export default function RuleGroupsIndex() {
>
</Button>
{hasEditPermission && selectedIds.length > 0 && (
{/* ✅ 批量启用/禁用按钮:仅当有更新权限且有选中项时显示 */}
{canUpdateGroup && selectedIds.length > 0 && (
<>
<Button
type="default"
@@ -738,17 +762,20 @@ export default function RuleGroupsIndex() {
>
({selectedIds.length})
</Button>
<Button
type="danger"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="mr-2"
>
({selectedIds.length})
</Button>
</>
)}
{hasEditPermission && (
{/* ✅ 批量删除按钮:仅当有删除权限且有选中项时显示 */}
{canDeleteGroup && selectedIds.length > 0 && (
<Button
type="danger"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="mr-2"
>
({selectedIds.length})
</Button>
)}
{canCreateGroup && (
<Button
type="primary"
icon="ri-add-line"
+61 -18
View File
@@ -1,10 +1,11 @@
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { useLoaderData, useActionData, useNavigation, Form, useRouteLoaderData } from "@remix-run/react";
import { useLoaderData, useActionData, useNavigation, Form } from "@remix-run/react";
import { useEffect, useState, useRef } from "react";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { toastService } from "~/components/ui/Toast";
import { usePermission } from "~/hooks/usePermission";
import ruleGroupsNewStyles from "~/styles/pages/rule-groups_new.css?url";
import {
getEvaluationPointGroups,
@@ -246,16 +247,19 @@ export default function RuleGroupNew() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
// 判断表单是否为只读模式(只有包含'provin'的用户才有编辑权限)
const hasEditPermission = userRole.toLowerCase().includes('provin');
const isReadOnly = !hasEditPermission;
// ✅ Use permission Hook
const { canCreate, canUpdate } = usePermission();
const canCreateGroup = canCreate('evaluation_group');
const canUpdateGroup = canUpdate('evaluation_group');
// 解构数据
const { group, parentGroups, isEdit, error } = data;
// ✅ 根据当前操作类型判断权限
const hasEditPermission = isEdit ? canUpdateGroup : canCreateGroup;
const isReadOnly = !hasEditPermission;
// 表单状态管理 - 使用受控组件
const [formValues, setFormValues] = useState<{
groupType: "primary" | "secondary";
@@ -299,13 +303,29 @@ export default function RuleGroupNew() {
parentId: false
});
// ✅ 页面加载时检查权限并提示(仅在只读模式下提示)
// useEffect(() => {
// console.log("权限",canCreateGroup,canUpdateGroup)
// if (isReadOnly) {
// if (isEdit) {
// toastService.info('当前为查看模式,您没有编辑权限');
// } else {
// toastService.warning('您没有创建分组的权限');
// }
// }
// }, [isReadOnly, isEdit]);
// 从 actionData 初始化表单错误
useEffect(() => {
if (actionData?.errors) {
setFormErrors(actionData.errors);
// ✅ 如果有通用错误,使用 toast 显示
if (actionData.errors.general) {
toastService.error(actionData.errors.general);
}
}
}, [actionData]);
// 根据加载的组数据初始化表单
useEffect(() => {
if (group) {
@@ -452,31 +472,53 @@ export default function RuleGroupNew() {
// 处理表单提交前验证
const handleBeforeSubmit = (e: React.FormEvent) => {
// ✅ Runtime permission check
if (isEdit && !canUpdateGroup) {
e.preventDefault();
toastService.warning('您没有修改权限,无法保存更改');
return;
}
if (!isEdit && !canCreateGroup) {
e.preventDefault();
toastService.warning('您没有创建权限,无法新增分组');
return;
}
// 如果是只读模式,阻止提交
if (isReadOnly) {
e.preventDefault();
toastService.info('当前为只读模式,无法提交');
return;
}
// 标记所有字段为已触摸
setTouchedFields({
name: true,
code: true,
parentId: true
});
// 验证所有字段
const errors = {
name: validateField('name', formValues.name),
code: validateField('code', formValues.code),
parentId: validateField('parentId', formValues.parentId)
};
setFormErrors(errors);
// 如果有错误,阻止提交
if (errors.name || errors.code || (formValues.groupType === "secondary" && errors.parentId)) {
// 如果有错误,阻止提交并提示
const hasErrors = errors.name || errors.code || (formValues.groupType === "secondary" && errors.parentId);
if (hasErrors) {
e.preventDefault();
// ✅ 收集所有错误信息并提示
const errorMessages = [];
if (errors.name) errorMessages.push(errors.name);
if (errors.code) errorMessages.push(errors.code);
if (errors.parentId) errorMessages.push(errors.parentId);
toastService.error(`表单验证失败:${errorMessages[0]}`);
}
};
@@ -502,16 +544,17 @@ export default function RuleGroupNew() {
<p className="page-subtitle"></p>
</div>
<div className="header-actions">
<Button
type="default"
<Button
type="default"
to="/rule-groups"
className="mr-3"
>
<i className="ri-arrow-left-line"></i>
</Button>
{!isReadOnly && (
<Button
type="primary"
{/* ✅ 仅在有对应权限时显示保存按钮 */}
{hasEditPermission && (
<Button
type="primary"
form="group-form"
disabled={isSubmitting}
>
+66 -42
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams, Link, useNavigate, useFetcher, useRouteLoaderData, useLocation } from "@remix-run/react";
import { useLoaderData, useSearchParams, Link, useNavigate, useFetcher, useLocation } from "@remix-run/react";
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Tag } from '~/components/ui/Tag';
@@ -15,6 +15,7 @@ import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterP
import { Pagination } from '~/components/ui/Pagination';
import { messageService } from '~/components/ui/MessageModal';
import { toastService } from '~/components/ui/Toast';
import { usePermission } from '~/hooks/usePermission';
import {
getRulesList,
deleteRule,
@@ -25,7 +26,6 @@ import {
type RuleType as ApiRuleType,
type RuleGroup
} from '~/api/evaluation_points/rules';
import type { UserRole } from '~/root';
export const links = () => [
{ rel: "stylesheet", href: rulesStyles }
@@ -193,13 +193,20 @@ const priorityLabels = {
export default function RulesIndex() {
const loaderData = useLoaderData<typeof loader>();
const rootData = useRouteLoaderData("root") as { userRole: UserRole };
const { rules: initialRules, totalCount: initialTotalCount, currentPage, pageSize, ruleTypes: initialRuleTypes, initialLoad } = loaderData;
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const fetcher = useFetcher<ActionResponse>();
const location = useLocation();
// ✅ 使用权限 Hook
const { canCreate, canUpdate, canDelete, canBatch, canView } = usePermission();
const canCreateRule = canCreate('evaluation_point');
const canUpdateRule = canUpdate('evaluation_point');
const canDeleteRule = canDelete('evaluation_point');
const canBatchRule = canBatch('evaluation_point');
const canViewRule = canView('evaluation_point');
// 状态管理
const [ruleGroups, setRuleGroups] = useState<RuleGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
@@ -243,15 +250,6 @@ export default function RulesIndex() {
// 判断是否禁用规则组选择
const isRuleGroupSelectDisabled = loadingGroups || !ruleTypeParam || ruleGroups.length === 0;
// 检查用户是否为开发者角色
const userRole = rootData?.userRole || 'common';
const isDeveloper = userRole.includes('admin');
// 调试日志
// console.log("🔑 [Rules List] rootData:", rootData);
// console.log("🔑 [Rules List] 用户角色:", userRole);
// console.log("🔑 [Rules List] 是否为管理员:", isDeveloper);
// 在组件渲染时初始化状态
// useEffect(() => {
// setFilteredRules(initialRules);
@@ -523,6 +521,12 @@ export default function RulesIndex() {
// 删除评查点
const handleDeleteClick = (rule: Rule) => {
// ✅ 检查删除权限
if (!canDeleteRule) {
toastService.warning('您没有删除权限');
return;
}
messageService.show({
title: "确认删除",
message: `确认删除评查点【${rule.name}】吗?`,
@@ -535,7 +539,7 @@ export default function RulesIndex() {
const form = new FormData();
form.append("_action", "delete");
form.append("ruleId", rule.id);
fetcher.submit(form, { method: "post" });
}
});
@@ -563,6 +567,12 @@ export default function RulesIndex() {
// 批量启用/禁用
const handleBatchEnable = async (isEnabled: boolean) => {
// ✅ 检查批量操作权限
if (!canBatchRule) {
toastService.warning('您没有批量操作权限');
return;
}
if (selectedIds.length === 0) {
toastService.warning('请先选择要操作的评查点');
return;
@@ -594,6 +604,12 @@ export default function RulesIndex() {
// 批量删除
const handleBatchDelete = async () => {
// ✅ 检查批量删除权限
if (!canBatchRule || !canDeleteRule) {
toastService.warning('您没有批量删除权限');
return;
}
if (selectedIds.length === 0) {
toastService.warning('请先选择要删除的评查点');
return;
@@ -664,8 +680,8 @@ export default function RulesIndex() {
// 定义表格列配置
const columns = [
// 添加复选框列(仅开发者可见)
...(isDeveloper ? [{
// 添加复选框列(有批量操作权限时可见)
...(canBatchRule ? [{
title: (
<input
type="checkbox"
@@ -762,24 +778,30 @@ export default function RulesIndex() {
width: "10%",
render: (_: unknown, record: Rule) => (
<div className="operations-cell">
{isDeveloper ? (
// 开发者可以看到编辑、复制、删除
{/* ✅ 查看/编辑和复制按钮 - 需要查看权限 */}
{canViewRule && (
<>
<Link to={`/rules/new?id=${record.id}`} className="operation-btn">
<i className="ri-edit-line"></i>
{/* ✅ 编辑/查看按钮 - 根据权限显示编辑或查看 */}
<Link to={`/rules/new?id=${record.id}${!canUpdateRule ? '&mode=view' : ''}`} className="operation-btn">
<i className={canUpdateRule ? "ri-edit-line" : "ri-eye-line"}></i> {canUpdateRule ? '编辑' : '查看'}
</Link>
<button className="operation-btn" onClick={() => handleCopy(record)}>
<i className="ri-file-copy-line"></i>
</button>
<button className="operation-btn operation-btn-danger" onClick={() => handleDeleteClick(record)}>
<i className="ri-delete-bin-line"></i>
</button>
{/* ✅ 复制按钮 - 有创建权限时显示 */}
{canCreateRule && (
<button className="operation-btn" onClick={() => handleCopy(record)}>
<i className="ri-file-copy-line"></i>
</button>
)}
</>
) : (
// 普通用户只能查看
<Link to={`/rules/new?id=${record.id}&mode=view`} className="operation-btn">
<i className="ri-eye-line"></i>
</Link>
)}
{/* ✅ 删除按钮 - 只需要删除权限 */}
{canDeleteRule && (
<button className="operation-btn operation-btn-danger" onClick={() => handleDeleteClick(record)}>
<i className="ri-delete-bin-line"></i>
</button>
)}
{/* 如果什么权限都没有,显示 - */}
{!canViewRule && !canDeleteRule && (
<span className="text-gray-400">-</span>
)}
</div>
)
@@ -805,8 +827,8 @@ export default function RulesIndex() {
)}
</div>
<div className="flex items-center gap-2">
{/* 批量操作按钮(仅在有选择时显示) */}
{isDeveloper && selectedIds.length > 0 && (
{/* 批量操作按钮(有批量权限且有选择时显示) */}
{canBatchRule && selectedIds.length > 0 && (
<>
<Button
type="default"
@@ -824,18 +846,20 @@ export default function RulesIndex() {
>
({selectedIds.length})
</Button>
<Button
type="danger"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="btn-batch-delete"
>
({selectedIds.length})
</Button>
{canDeleteRule && (
<Button
type="danger"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="btn-batch-delete"
>
({selectedIds.length})
</Button>
)}
</>
)}
{/* 新增按钮 */}
{isDeveloper && (
{/* 新增按钮 - 有创建权限时显示 */}
{canCreateRule && (
<Button type="primary" icon="ri-add-line" to="/rules/new" className="btn-add-rule">
</Button>
+42 -7
View File
@@ -49,7 +49,7 @@ import type { EvaluationPointGroup } from "~/models/evaluation_point_groups";
// 导入RuleContext上下文
import { RuleContext } from "~/contexts/RuleContext";
import { toastService } from '~/components/ui/Toast';
import type { UserRole } from '~/root';
import { usePermission } from '~/hooks/usePermission';
import { getPromptTemplateOptions } from '~/api/prompts/prompts';
import {
createEvaluationPoint,
@@ -148,26 +148,50 @@ export default function RuleNew() {
const [isCopyMode, setIsCopyMode] = useState(false); // 添加复制模式状态
const [isLoading, setIsLoading] = useState(false);
const [instanceKey, setInstanceKey] = useState<string>('new');
// 从root路由获取用户角色和JWT token
const rootData = useRouteLoaderData("root") as { userRole: UserRole; frontendJWT?: string };
const userRole = rootData?.userRole || 'common';
// 从root路由获取JWT token
const rootData = useRouteLoaderData("root") as { frontendJWT?: string };
const frontendJWT = rootData?.frontendJWT;
// ✅ 使用权限 Hook
const { canCreate, canUpdate } = usePermission();
const canCreateRule = canCreate('evaluation_point');
const canUpdateRule = canUpdate('evaluation_point');
// ✅ 判断表单是否为只读模式
// 从 URL 检查是否为查看模式
const searchParams = new URLSearchParams(location.search);
const urlMode = searchParams.get('mode');
const isViewMode = urlMode === 'view';
// 根据模式和权限决定是否只读
const hasEditPermission = isEditMode ? canUpdateRule : canCreateRule;
const isReadOnly = isViewMode || !hasEditPermission;
// 使用 ref 跟踪当前加载的 URL,避免重复加载
const loadedUrlRef = useRef<string>('');
const [formData, setFormData] = useState<EvaluationPoint>({});
const [evaluationPointGroups, setEvaluationPointGroups] = useState<EvaluationPointGroup[]>([]);
// 判断表单是否为只读模式
const isReadOnly = userRole === 'common';
// 添加用于共享的字段数据状态
const [extractionFields, setExtractionFields] = useState<string[]>([]);
// VLM字段类型选项
const [vlmFieldTypeOptions, setVlmFieldTypeOptions] = useState<Array<{ value: string; label: string }>>([]);
// ✅ 页面加载时检查权限并提示(仅在只读模式下提示)
useEffect(() => {
if (isReadOnly && !isLoading) {
if (isViewMode) {
// toastService.info('当前为查看模式');
} else if (isEditMode && !canUpdateRule) {
toastService.info('当前为查看模式,您没有编辑权限');
} else if (!isEditMode && !canCreateRule) {
toastService.warning('您没有创建评查点的权限');
}
}
}, [isReadOnly, isViewMode, isEditMode, canUpdateRule, canCreateRule, isLoading]);
/**
* 从表单数据中提取所有字段
* 用于编辑模式下初始化字段数据
@@ -417,6 +441,17 @@ export default function RuleNew() {
const handleSave = async () => {
// console.log("保存评查点", formData);
// ✅ Runtime permission check
if (isEditMode && !canUpdateRule) {
toastService.warning('您没有修改权限,无法保存更改');
return;
}
if (!isEditMode && !canCreateRule) {
toastService.warning('您没有创建权限,无法新增评查点');
return;
}
// ========== 验证必填字段 ==========
// 1. 验证评查点名称