feat: 1. 添加企查查的按钮。新增相关组件和对接接口进行显示。

2. 为51707端口添加只存在交叉评查入口的项目启动配置。入口页添加相关的区分。
3. 完善文档列表的权限功能控制。
4. 隐藏系统概览中高风险用户的统计模块。
fix: 1. 修复合同起草无权访问却生成了新的模板文件的问题。
2. 修复文档类型无法编辑入口模块的问题。
This commit is contained in:
2025-12-13 02:59:34 +08:00
parent 5c47b20e1d
commit daa53289af
23 changed files with 3370 additions and 183 deletions
+130 -72
View File
@@ -5,7 +5,7 @@ import styles from "~/styles/pages/home.css?url";
import dayjs from 'dayjs';
import { getUserSession, logout } from "~/api/login/auth.server";
import { toastService } from '~/components/ui';
import { DOCUMENT_URL } from '~/config/api-config';
import { DOCUMENT_URL, CROSS_CHECKING_ONLY_MODE, CROSS_CHECKING_ONLY_PORT, getCurrentPort } from '~/config/api-config';
export const links = () => [
{ rel: "stylesheet", href: styles }
@@ -87,8 +87,26 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
}
// 🔑 判断是否启用交叉评查专属模式
// 条件:CROSS_CHECKING_ONLY_MODE=true 且 当前端口为 51707
const currentPort = getCurrentPort();
const isCrossCheckingOnlyMode = CROSS_CHECKING_ONLY_MODE && currentPort === CROSS_CHECKING_ONLY_PORT;
if (isCrossCheckingOnlyMode) {
console.log(`🔒 [Index Loader] 交叉评查专属模式已启用 (端口: ${currentPort})`);
}
// 返回用户信息、入口模块和权限给客户端
return Response.json({ userRole, userInfo, entryModules, hasSettingsAccess, hasCrossCheckingAccess, hasChatLLMAccess, settingsChildren });
return Response.json({
userRole,
userInfo,
entryModules,
hasSettingsAccess,
hasCrossCheckingAccess,
hasChatLLMAccess,
settingsChildren,
isCrossCheckingOnlyMode // 新增:交叉评查专属模式标志
});
}
export default function Index() {
@@ -326,8 +344,8 @@ export default function Index() {
</div>
</div>
<div className="user-info">
{/* 系统设置按钮 - 只在有权限时显示 */}
{loaderData.hasSettingsAccess && (
{/* 系统设置按钮 - 只在有权限且非交叉评查专属模式时显示 */}
{loaderData.hasSettingsAccess && !loaderData.isCrossCheckingOnlyMode && (
<button
onClick={handleEnterSettings}
className="settings-button"
@@ -369,79 +387,119 @@ export default function Index() {
{/* 模块网格区域 */}
<div className="modules-container">
{/* 动态渲染入口模块 */}
{loaderData.entryModules && loaderData.entryModules.length > 0 ? (
<>
{loaderData.entryModules.map((module) => {
// 判断是否为智慧法务助手,如果是且有交叉评查权限,则在其之前插入交叉评查卡片
const isLLMModule = module.name === '智慧法务助手';
{/* 🔒 交叉评查专属模式:只显示交叉评查入口 */}
{loaderData.isCrossCheckingOnlyMode ? (
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@2x.png"
alt="交叉评查"
className="w-12 h-12 mx-1"
onError={(e) => {
(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="text-center text-gray-500 py-8">
</div>
)
) : (
/* 正常模式:显示所有入口模块 */
loaderData.entryModules && loaderData.entryModules.length > 0 ? (
<>
{loaderData.entryModules.map((module) => {
// 判断是否为智慧法务助手,如果是且有交叉评查权限,则在其之前插入交叉评查卡片
const isLLMModule = module.name === '智慧法务助手';
// 🔑 如果是智慧法务助手且用户没有访问权限,则不渲染该模块
if (isLLMModule && !loaderData.hasChatLLMAccess) {
return null;
}
// 🔑 如果是智慧法务助手且用户没有访问权限,则不渲染该模块
if (isLLMModule && !loaderData.hasChatLLMAccess) {
return null;
}
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@2x.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);
return (
<React.Fragment key={module.id}>
{/* 在智慧法务助手之前插入交叉评查入口 */}
{isLLMModule && loaderData.hasCrossCheckingAccess && (
<div
className="module-card"
onClick={handleEnterCrossChecking}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleEnterCrossChecking();
}
}}
/>
<span className="module-name"></span>
</div>
)}
role="button"
tabIndex={0}
aria-label="交叉评查"
>
<img
src="/images/icon_cross@2x.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={isLLMModule ? '/images/icon_assistant.png' : 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">
</div>
{/* 渲染原有模块 */}
<div
className="module-card"
onClick={() => handleModuleClick(module)}
onKeyDown={(e) => handleKeyDown(module, e)}
role="button"
tabIndex={0}
aria-label={module.name}
>
<img
src={isLLMModule ? '/images/icon_assistant.png' : 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">
</div>
)
)}
</div>
</div>
+26 -2
View File
@@ -1,6 +1,6 @@
import type { MetaFunction, LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { useLoaderData, useNavigate, useSubmit } from '@remix-run/react';
import { useLoaderData, useNavigate, useSubmit, useActionData } from '@remix-run/react';
import { useState, useEffect } from 'react';
import { getContractTemplate } from '~/api/contract-template/templates';
import type { ContractTemplate } from '~/api/contract-template/templates';
@@ -9,6 +9,7 @@ import filePreviewStyles from '~/styles/components/file-preview-isolation.css?ur
import { getUserSession } from '~/api/login/auth.server';
import { createDraftContract } from '~/api/contracts/draft-service.server';
import { apiRequest, downloadFile } from '~/api/axios-client';
import { checkRoutePermission } from '~/api/auth/check-route-permission.server';
// 导入FilePreview组件
import { FilePreview } from '~/components/reviews';
@@ -76,11 +77,20 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
// 获取用户信息和JWT
const { userInfo, frontendJWT } = await getUserSession(request);
const { userInfo, frontendJWT, userRole } = await getUserSession(request);
if (!userInfo?.sub) {
return Response.json({ error: '未登录' }, { status: 401 });
}
// 🔒 在执行任何操作之前,先检查用户是否有权限访问目标路由
const targetPath = '/contract-draft';
const permissionCheck = await checkRoutePermission(targetPath, userRole, frontendJWT || undefined);
if (!permissionCheck.allowed) {
console.warn(`[Action] 用户无权访问 ${targetPath}:`, permissionCheck.error);
return Response.json({ error: permissionCheck.error || '您没有权限使用起草合同功能' }, { status: 403 });
}
try {
// 解析表单数据
const formData = await request.formData();
@@ -148,8 +158,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
}
// Action 返回的数据类型
interface ActionData {
error?: string;
}
export default function ContractTemplateDetail() {
const { template }: { template: ContractTemplate } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigate = useNavigate();
const submit = useSubmit();
const [isCreatingDraft, setIsCreatingDraft] = useState(false);
@@ -162,6 +178,14 @@ export default function ContractTemplateDetail() {
window.scrollTo({ top: 0, behavior: 'instant' });
}, []);
// 处理 action 返回的错误
useEffect(() => {
if (actionData?.error) {
toastService.error(actionData.error);
setIsCreatingDraft(false);
}
}, [actionData]);
const handleBack = () => {
navigate('/contract-template/list');
};
+1 -1
View File
@@ -196,7 +196,7 @@ export async function action({ request }: ActionFunctionArgs) {
}, { status: 500 });
}
console.log('用户任务详情返回:', response.data);
// console.log('用户任务详情返回:', response.data);
return Response.json({
success: true,
data: response.data
+13 -12
View File
@@ -163,6 +163,7 @@ export default function DocumentTypesList() {
const canCreateType = canCreate('document_type');
const canUpdateType = canUpdate('document_type');
const canDeleteType = canDelete('document_type');
// console.log('document_type---canDeleteType',canDeleteType)
const canViewType = canView('document_type');
// 获取搜索参数
@@ -299,7 +300,7 @@ export default function DocumentTypesList() {
{
title: "文档类型名称",
key: "name",
width: "200px",
width: "220px",
render: (_: unknown, record: DocumentTypeUI) => (
<div className="flex items-center">
<i className="ri-file-text-line text-primary mr-2"></i>
@@ -310,10 +311,10 @@ export default function DocumentTypesList() {
{
title: "描述",
key: "description",
width: "250px",
width: "260px",
render: (_: unknown, record: DocumentTypeUI) => (
<div className="text-secondary text-sm truncate max-w-xs" title={record.description}>
{record.description}
<div className="text-secondary text-sm truncate max-w-[300px]" title={record.description}>
{record.description || '-'}
</div>
)
},
@@ -326,7 +327,7 @@ export default function DocumentTypesList() {
{record.entry_module ? (
<span className="entry-module-badge">{record.entry_module.name}</span>
) : (
<span className="text-gray-400"></span>
<span className="text-gray-400">-</span>
)}
</div>
)
@@ -343,7 +344,7 @@ export default function DocumentTypesList() {
</span>
))
) : (
<span className="text-gray-400"></span>
<span className="text-gray-400">-</span>
)}
</div>
)
@@ -351,19 +352,19 @@ export default function DocumentTypesList() {
{
title: "创建时间",
key: "created_at",
width: "150px",
render: (_: unknown, record: DocumentTypeUI) => record.created_at
width: "160px",
render: (_: unknown, record: DocumentTypeUI) => record.created_at || '-'
},
{
title: "更新时间",
key: "updated_at",
width: "150px",
render: (_: unknown, record: DocumentTypeUI) => record.updated_at
width: "160px",
render: (_: unknown, record: DocumentTypeUI) => record.updated_at || '-'
},
{
title: "操作",
key: "operation",
width: "150px",
width: "160px",
render: (_: unknown, record: DocumentTypeUI) => (
<div className="operations-cell">
{canViewType && (
@@ -379,7 +380,7 @@ export default function DocumentTypesList() {
)}
{canDeleteType && (
<button
className="operation-btn text-error !hidden"
className="operation-btn text-error"
onClick={() => handleDelete(record.id)}
disabled={isDeleting}
>
+2
View File
@@ -477,6 +477,8 @@ export default function DocumentTypeNew() {
} else if (name === 'vlm_extraction_template') {
fieldName = 'vlmExtractionTemplateId';
setTouchedFields(prev => ({...prev, vlmExtractionTemplate: true}));
} else if (name === 'entry_module_id') {
fieldName = 'entryModuleId';
} else if (name === 'name') {
setTouchedFields(prev => ({...prev, name: true}));
}
+15 -7
View File
@@ -3,6 +3,7 @@ import { useLoaderData, useActionData, useNavigate, Form } from "@remix-run/reac
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { usePermission } from "~/hooks/usePermission";
import documentEditStyles from "~/styles/pages/documents_edit.css?url";
import { getDocument, updateDocument } from "~/api/files/documents";
import { getDocumentTypes } from "~/api/document-types/document-types";
@@ -208,6 +209,10 @@ export default function DocumentEdit() {
const [numPages, setNumPages] = useState(0);
const [loadError, setLoadError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
// 权限控制
const { hasPermission } = usePermission();
const canUpdate = hasPermission('document:document:update');
// 表单状态管理 - 使用受控组件
const [formValues, setFormValues] = useState({
@@ -476,13 +481,16 @@ export default function DocumentEdit() {
>
</Button>
<Button
type="primary"
icon="ri-save-line"
form="edit-form"
>
</Button>
{/* 保存修改按钮 - 需要 document:document:update 权限 */}
{canUpdate && (
<Button
type="primary"
icon="ri-save-line"
form="edit-form"
>
</Button>
)}
</div>
</div>
+117 -83
View File
@@ -3,6 +3,7 @@ import { useSearchParams, useLoaderData, useFetcher, useNavigate,Link } from "@r
import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { usePermission, PermissionGuard } from "~/hooks/usePermission";
// import { Table } from "~/components/ui/Table";
import { Pagination } from "~/components/ui/Pagination";
import { FileTypeTag } from "~/components/ui/FileTypeTag";
@@ -184,6 +185,11 @@ export default function DocumentsIndex() {
const fetcher = useFetcher<ActionResponse>();
const navigate = useNavigate();
// 权限控制
const { hasPermission } = usePermission();
const canView = hasPermission('document:document:view');
const canUpdate = hasPermission('document:document:update');
// 存储从 sessionStorage 获取的 documentTypeIds
const [documentTypeIds, setDocumentTypeIds] = useState<number[] | null>(null);
@@ -1172,29 +1178,39 @@ export default function DocumentsIndex() {
<td className="text-xs text-gray-600 px-4 py-3" style={{ width: '10%' }}>{historyDoc.uploadTime}</td>
<td className="px-4 py-3" style={{ width: '25%' }}>
<div className="operations-cell flex flex-wrap gap-1">
<Link
to={`/reviews?id=${historyDoc.id}&previousRoute=documents`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
</Link>
<Link
to={`/documents/edit?id=${historyDoc.id}`}
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
>
<i className="ri-edit-line"></i>
</Link>
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => handleDownload(historyDoc.path)}
>
<i className="ri-download-line"></i>
</button>
{parentDoc.type === '1' && historyDoc.fileStatus === 'Processed' && (
{/* 查看按钮 - 需要 document:document:view 权限 */}
{canView && (
<Link
to={`/reviews?id=${historyDoc.id}&previousRoute=documents`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
</Link>
)}
{/* 修改按钮 - 需要 document:document:view 权限 */}
{canView && (
<Link
to={`/documents/edit?id=${historyDoc.id}`}
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
>
<i className="ri-edit-line"></i>
</Link>
)}
{/* 下载按钮 - 需要 document:document:view 权限 */}
{canView && (
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => handleDownload(historyDoc.path)}
>
<i className="ri-download-line"></i>
</button>
)}
{/* 追加附件和上传模板按钮 - 需要 document:document:view 权限 */}
{canView && parentDoc.type === '1' && historyDoc.fileStatus === 'Processed' && (
<>
<button
type="button"
@@ -1224,14 +1240,17 @@ export default function DocumentsIndex() {
</button>
</>
)}
<button
type="button"
className="text-xs px-2 py-1 h-7 text-error hover:underline hover:text-red-700"
onClick={() => handleDelete(historyDoc.id.toString(), historyDoc.name, historyDoc.fileStatus)}
>
<i className="ri-delete-bin-line"></i>
</button>
{/* 删除按钮 - 需要 document:document:view 权限 */}
{canView && (
<button
type="button"
className="text-xs px-2 py-1 h-7 text-error hover:underline hover:text-red-700"
onClick={() => handleDelete(historyDoc.id.toString(), historyDoc.name, historyDoc.fileStatus)}
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</div>
</td>
</tr>
@@ -1393,53 +1412,65 @@ export default function DocumentsIndex() {
width: "25%",
render: (_: unknown, record: DocumentUI) => (
<div className="operations-cell flex flex-wrap gap-1">
{(record.auditStatus === 0 || record.auditStatus == null) ? (
<button
onClick={() => handleReviewFileClick(record.id, record.auditStatus)}
disabled={record.fileStatus !== 'Processed'}
className={`text-xs px-2 py-1 h-7 mr-1 ${
record.fileStatus === 'Processed'
? 'hover:underline hover:cursor-pointer text-primary'
: 'text-gray-400 cursor-not-allowed opacity-60'
}`}
{/* 查看/开始审核按钮 - 需要 document:document:view 权限 */}
{canView && (
<>
{(record.auditStatus === 0 || record.auditStatus == null) ? (
<button
onClick={() => handleReviewFileClick(record.id, record.auditStatus)}
disabled={record.fileStatus !== 'Processed'}
className={`text-xs px-2 py-1 h-7 mr-1 ${
record.fileStatus === 'Processed'
? 'hover:underline hover:cursor-pointer text-primary'
: 'text-gray-400 cursor-not-allowed opacity-60'
}`}
>
<i className="ri-play-circle-line"></i>
</button>
) : record.auditStatus === 3 ? (
//record.auditStatus === 3 目前这个状态不存在,所以除了待审核(0)-开始审核,其他都是审核中(2)-查看
<Link
to={`/documents/${record.id}/progress`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
</Link>
) : (
<Link
to={`/reviews?id=${record.id}&previousRoute=documents`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
</Link>
)}
</>
)}
{/* 修改按钮 - 需要 document:document:view 权限 */}
{canView && (
<Link
to={`/documents/edit?id=${record.id}`}
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
>
<i className="ri-play-circle-line"></i>
</button>
) : record.auditStatus === 3 ? (
//record.auditStatus === 3 目前这个状态不存在,所以除了待审核(0)-开始审核,其他都是审核中(2)-查看
<Link
to={`/documents/${record.id}/progress`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
</Link>
) : (
<Link
to={`/reviews?id=${record.id}&previousRoute=documents`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
<i className="ri-edit-line"></i>
</Link>
)}
<Link
to={`/documents/edit?id=${record.id}`}
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
>
<i className="ri-edit-line"></i>
</Link>
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => handleDownload(record.path)}
>
<i className="ri-download-line"></i>
</button>
{record.type === '1' && record.fileStatus === 'Processed' && (
{/* 下载按钮 - 需要 document:document:view 权限 */}
{canView && (
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => handleDownload(record.path)}
>
<i className="ri-download-line"></i>
</button>
)}
{/* 追加附件和上传模板按钮 - 需要 document:document:view 权限 */}
{canView && record.type === '1' && record.fileStatus === 'Processed' && (
<>
<button
type="button"
@@ -1469,14 +1500,17 @@ export default function DocumentsIndex() {
</button>
</>
)}
<button
type="button"
className="text-xs px-2 py-1 h-7 text-error hover:underline hover:text-red-700"
onClick={() => handleDelete(record.id.toString(), record.name, record.fileStatus)}
>
<i className="ri-delete-bin-line"></i>
</button>
{/* 删除按钮 - 需要 document:document:view 权限 */}
{canView && (
<button
type="button"
className="text-xs px-2 py-1 h-7 text-error hover:underline hover:text-red-700"
onClick={() => handleDelete(record.id.toString(), record.name, record.fileStatus)}
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</div>
)
}
+2 -2
View File
@@ -448,8 +448,8 @@ export default function Home() {
</Card>
)}
{/* 高风险用户 */}
{topRiskUsers.available && (
{/* 高风险用户 隐藏*/}
{topRiskUsers.available && false &&(
<Card
title="高风险用户 Top 5"
icon="ri-shield-user-line"