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
@@ -0,0 +1,190 @@
/**
* 服务端路由权限检查工具
* 用于在 action 中检查用户是否有权限访问目标路由
*/
import { getUserRoutesByRole, type MenuItem } from './user-routes';
/**
* 从 MenuItem 数组中提取所有路径(包括子路由)
*/
function extractAllPaths(menuItems: MenuItem[]): string[] {
const paths: string[] = [];
function traverse(items: MenuItem[]) {
for (const item of items) {
paths.push(item.path);
if (item.children && item.children.length > 0) {
traverse(item.children);
}
}
}
traverse(menuItems);
return paths;
}
/**
* 检查路径段是否看起来像动态ID
*/
function isDynamicIdSegment(segment: string): boolean {
// 纯数字
if (/^\d+$/.test(segment)) {
return true;
}
// UUID格式
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
return true;
}
// 包含数字的混合ID
if (/\d/.test(segment) && !/^[a-z]+(-[a-z]+)*$/i.test(segment)) {
return true;
}
return false;
}
/**
* 检查路径是否在允许列表中
*/
function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
// 精确匹配
if (allowedPaths.includes(pathname)) {
return true;
}
// 动态路由匹配
for (const allowedPath of allowedPaths) {
if (pathname.startsWith(allowedPath + '/')) {
const subPath = pathname.substring(allowedPath.length + 1);
const segments = subPath.split('/');
const firstSegment = segments[0];
if (isDynamicIdSegment(firstSegment)) {
return true;
}
}
}
// 根路径
if (pathname === '/') {
return true;
}
return false;
}
export interface CheckRoutePermissionResult {
/** 是否有权限访问 */
allowed: boolean;
/** 错误信息(当 allowed 为 false 时) */
error?: string;
/** 用户允许访问的所有路由 */
allowedPaths?: string[];
}
/**
* 检查用户是否有权限访问指定路由
*
* @param targetPath 目标路由路径(如 '/contract-draft/1'
* @param userRole 用户角色
* @param jwt JWT token
* @returns 权限检查结果
*
* @example
* ```ts
* // 在 action 中使用
* export async function action({ request }: ActionFunctionArgs) {
* const { userInfo, frontendJWT } = await getUserSession(request);
*
* // 检查用户是否有权限访问目标路由
* const permissionCheck = await checkRoutePermission(
* '/contract-draft/1',
* userInfo.role,
* frontendJWT
* );
*
* if (!permissionCheck.allowed) {
* return Response.json({ error: permissionCheck.error }, { status: 403 });
* }
*
* // 继续执行 action 逻辑...
* }
* ```
*/
export async function checkRoutePermission(
targetPath: string,
userRole: string,
jwt?: string
): Promise<CheckRoutePermissionResult> {
if (!jwt) {
return {
allowed: false,
error: '未提供认证信息'
};
}
try {
// 获取用户的路由权限(包含隐藏路由,用于权限校验)
const routesResult = await getUserRoutesByRole(userRole, jwt, true);
if (!routesResult.success || !routesResult.data) {
return {
allowed: false,
error: routesResult.error || '获取用户权限失败'
};
}
// 提取所有允许的路径
const allowedPaths = extractAllPaths(routesResult.data);
// 检查目标路径是否在允许列表中
const allowed = isPathAllowed(targetPath, allowedPaths);
if (!allowed) {
console.warn(`[checkRoutePermission] 用户无权访问路由: ${targetPath}`);
console.warn(`[checkRoutePermission] 用户允许的路由: ${allowedPaths.join(', ')}`);
}
return {
allowed,
error: allowed ? undefined : '您没有权限访问目标页面',
allowedPaths
};
} catch (error) {
console.error('[checkRoutePermission] 权限检查失败:', error);
return {
allowed: false,
error: '权限检查失败,请稍后重试'
};
}
}
/**
* 简化版:直接检查是否有权限,抛出 403 Response
*
* @example
* ```ts
* export async function action({ request }: ActionFunctionArgs) {
* const { userInfo, frontendJWT } = await getUserSession(request);
*
* // 如果没有权限,会自动抛出 403 Response
* await requireRoutePermission('/contract-draft/1', userInfo.role, frontendJWT);
*
* // 继续执行 action 逻辑...
* }
* ```
*/
export async function requireRoutePermission(
targetPath: string,
userRole: string,
jwt?: string
): Promise<void> {
const result = await checkRoutePermission(targetPath, userRole, jwt);
if (!result.allowed) {
throw new Response(result.error || '无权访问', { status: 403 });
}
}
+281
View File
@@ -0,0 +1,281 @@
/**
* 企查查 API 接口封装
* 提供企业工商信息和失信核查查询功能
*/
import { post } from '../axios-client';
import type {
BusinessInfoResult,
DishonestyResult,
Paging,
} from '~/components/corporate-information/types';
// ==================== 请求/响应类型定义 ====================
/** 查询企业信息请求参数 */
export interface QueryCompanyRequest {
/** 查询关键词(企业名称或统一社会信用代码),2-200字符 */
keyword: string;
/** 是否强制刷新,默认false */
force_refresh?: boolean;
}
/** 查询企业信息响应数据 */
export interface QueryCompanyData {
/** 查询关键词 */
search_key: string;
/** 统一社会信用代码 */
credit_code: string;
/** 企业名称 */
company_name: string;
/** 企业工商信息(410接口原始返回) */
enterprise: BusinessInfoResult | null;
/** 失信核查信息(740接口原始返回) */
dishonesty: {
/** 核查结果:0-无失信记录,1-有失信记录 */
VerifyResult: number;
/** 失信记录列表 */
Data: DishonestyResult['Data'] | null;
} | null;
/** 是否有失信记录 */
has_dishonesty: boolean;
/** 失信记录数量 */
dishonesty_count: number;
/** 数据更新时间 */
updated_at: string;
}
/** 查询企业信息响应 */
export interface QueryCompanyResponse {
/** 查询是否成功 */
success: boolean;
/** 响应消息 */
message: string;
/** 企业信息 */
data: QueryCompanyData | null;
/** 错误码(失败时返回) */
error_code?: string;
/** 错误详情(失败时返回) */
error_details?: {
order_number: string;
is_charged: boolean;
is_valid_request: boolean;
};
}
// ==================== Mock 数据 ====================
/** Mock 企业工商信息(腾讯科技示例) */
const MOCK_ENTERPRISE_DATA: BusinessInfoResult = {
KeyNo: 'p5ef6e1c39f4817d33dfb4e9',
Name: '腾讯科技(深圳)有限公司',
No: '440301103206221',
BelongOrg: '深圳市市场监督管理局',
OperId: 'p5ef6e1c39f4817d33dfb4e9op',
OperName: '马化腾',
DesignatedRepresentativeList: [],
StartDate: '2000-02-24 00:00:00',
EndDate: '',
Status: '存续',
Province: 'GD',
UpdatedDate: '2024-12-10 15:30:00',
CreditCode: '91440300708461136T',
RegistCapi: '65000万人民币',
RegisteredCapital: '65000',
RegisteredCapitalUnit: '万',
RegisteredCapitalCCY: 'CNY',
EconKind: '有限责任公司',
Address: '深圳市南山区粤海街道科技中一路腾讯大厦35层',
Scope: '从事计算机软硬件及周边设备、通讯设备、数码产品、网络设备的技术开发、销售及相关的技术咨询、技术服务;经营进出口业务;从事广告业务;物业管理',
TermStart: '2000-02-24 00:00:00',
TermEnd: '2050-02-24 00:00:00',
CheckDate: '2023-08-15 00:00:00',
OrgNo: '708461136',
IsOnStock: '1',
StockNumber: '00700.HK',
StockType: '港股',
OriginalName: [
{
Name: '深圳市腾讯计算机系统有限公司',
ChangeDate: '2000-06-01',
},
],
ImageUrl: 'https://img.qichacha.com/xxx.png',
EntType: '1',
RecCap: '65000万人民币',
PaidUpCapital: '65000',
PaidUpCapitalUnit: '万',
PaidUpCapitalCCY: 'CNY',
RevokeInfo: {
CancelDate: '',
CancelReason: '',
RevokeDate: '',
RevokeReason: '',
},
Area: {
Province: '广东省',
City: '深圳市',
County: '南山区',
},
AreaCode: '440305',
};
/** Mock 失信记录(有失信记录的示例) */
const MOCK_DISHONESTY_WITH_RECORDS = {
VerifyResult: 1,
Data: [
{
Id: 'c2edc410f8ceed3ed256055baa8df1432',
Liandate: '2021-08-10',
Anno: '2021)粤0106执34224号',
Executegov: '广州市天河区人民法院',
Executestatus: '全部未履行',
Publicdate: '2022-01-07',
Executeno: '2020)粤0106民初12345号',
ActionRemark: '有履行能力而拒不履行生效法律文书确定义务',
Amount: '1098000',
},
],
};
/** Mock 无失信记录 */
const MOCK_DISHONESTY_NO_RECORDS = {
VerifyResult: 0,
Data: null,
};
/** 是否使用 Mock 数据 */
const USE_MOCK = true;
// ==================== API 方法 ====================
/**
* 查询企业信息(工商信息 + 失信核查)
* @param params 查询参数
* @returns 企业完整信息
*/
export async function queryCompanyInfo(
params: QueryCompanyRequest
): Promise<QueryCompanyResponse> {
// 使用 Mock 数据
if (USE_MOCK) {
// 模拟网络延迟
await new Promise((resolve) => setTimeout(resolve, 800));
// 根据关键词返回不同的 Mock 数据
const hasDishonesty = params.keyword.includes('失信') || params.keyword.includes('建设');
return {
success: true,
message: '查询成功',
data: {
search_key: params.keyword,
credit_code: MOCK_ENTERPRISE_DATA.CreditCode,
company_name: MOCK_ENTERPRISE_DATA.Name,
enterprise: MOCK_ENTERPRISE_DATA,
dishonesty: hasDishonesty ? MOCK_DISHONESTY_WITH_RECORDS : MOCK_DISHONESTY_NO_RECORDS,
has_dishonesty: hasDishonesty,
dishonesty_count: hasDishonesty ? 1 : 0,
updated_at: new Date().toISOString(),
},
};
}
// 真实 API 调用
const response = await post<QueryCompanyResponse>('/api/v2/qichacha/company', params);
if (response.error) {
return {
success: false,
message: response.error,
data: null,
error_code: String(response.status),
};
}
return response.data as QueryCompanyResponse;
}
/**
* 仅查询工商信息
* @param params 查询参数
* @returns 企业工商信息
*/
export async function queryEnterpriseInfo(
params: QueryCompanyRequest
): Promise<QueryCompanyResponse> {
if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 500));
return {
success: true,
message: '查询成功',
data: {
search_key: params.keyword,
credit_code: MOCK_ENTERPRISE_DATA.CreditCode,
company_name: MOCK_ENTERPRISE_DATA.Name,
enterprise: MOCK_ENTERPRISE_DATA,
dishonesty: null,
has_dishonesty: false,
dishonesty_count: 0,
updated_at: new Date().toISOString(),
},
};
}
const response = await post<QueryCompanyResponse>('/api/v2/qichacha/enterprise', params);
if (response.error) {
return {
success: false,
message: response.error,
data: null,
error_code: String(response.status),
};
}
return response.data as QueryCompanyResponse;
}
/**
* 仅查询失信信息
* @param params 查询参数
* @returns 失信核查信息
*/
export async function queryDishonestyInfo(
params: QueryCompanyRequest
): Promise<QueryCompanyResponse> {
if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 500));
const hasDishonesty = params.keyword.includes('失信') || params.keyword.includes('建设');
return {
success: true,
message: '查询成功',
data: {
search_key: params.keyword,
credit_code: '',
company_name: params.keyword,
enterprise: null,
dishonesty: hasDishonesty ? MOCK_DISHONESTY_WITH_RECORDS : MOCK_DISHONESTY_NO_RECORDS,
has_dishonesty: hasDishonesty,
dishonesty_count: hasDishonesty ? 1 : 0,
updated_at: new Date().toISOString(),
},
};
}
const response = await post<QueryCompanyResponse>('/api/v2/qichacha/dishonesty', params);
if (response.error) {
return {
success: false,
message: response.error,
data: null,
error_code: String(response.status),
};
}
return response.data as QueryCompanyResponse;
}
@@ -0,0 +1,287 @@
/**
* 企业工商信息展示组件
*/
import type { BusinessInfoProps } from './types';
import { EntTypeLabel } from './types';
/** 格式化日期(移除时间部分) */
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '-';
// 移除时间部分,只保留日期
return dateStr.split(' ')[0] || dateStr;
}
/** 格式化金额 */
function formatAmount(amount: string | null | undefined, unit?: string, ccy?: string): string {
if (!amount) return '-';
const currencyMap: Record<string, string> = {
'CNY': '人民币',
'USD': '美元',
'HKD': '港币',
'EUR': '欧元',
};
const currencyLabel = ccy ? currencyMap[ccy] || ccy : '';
return `${amount}${unit || ''}${currencyLabel ? ` (${currencyLabel})` : ''}`;
}
export function BusinessInfo({ data, loading, error }: BusinessInfoProps) {
// 加载中状态
if (loading) {
return (
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-center py-12">
<i className="ri-loader-4-line animate-spin text-2xl text-gray-400 mr-2"></i>
<span className="text-gray-500">...</span>
</div>
</div>
);
}
// 错误状态
if (error) {
return (
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-center py-12 text-red-500">
<i className="ri-error-warning-line text-2xl mr-2"></i>
<span>{error}</span>
</div>
</div>
);
}
// 无数据状态
if (!data) {
return (
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-center py-12 text-gray-400">
<i className="ri-building-line text-2xl mr-2"></i>
<span></span>
</div>
</div>
);
}
// 判断是否有注销/吊销信息
const hasRevokeInfo = data.RevokeInfo && (data.RevokeInfo.CancelDate || data.RevokeInfo.RevokeDate);
// 获取状态标签颜色
const getStatusColor = (status: string) => {
if (status === '存续' || status === '在业' || status === '开业') {
return 'bg-green-100 text-green-700 border-green-200';
}
if (status === '注销' || status === '吊销') {
return 'bg-red-100 text-red-700 border-red-200';
}
return 'bg-gray-100 text-gray-700 border-gray-200';
};
return (
<div className="bg-white rounded-lg shadow-sm">
{/* 标题栏 */}
<div className="px-6 py-4 border-b border-gray-100">
<div className="flex items-center justify-between">
<div className="flex items-center">
<i className="ri-building-2-line text-xl text-[#00684a] mr-2"></i>
<h3 className="text-lg font-medium text-gray-900"></h3>
</div>
{data.Status && (
<span className={`px-3 py-1 text-sm rounded-full border ${getStatusColor(data.Status)}`}>
{data.Status}
</span>
)}
</div>
</div>
{/* 企业头部信息 */}
<div className="px-6 py-4 bg-gray-50 border-b border-gray-100">
<div className="flex items-start gap-4">
{data.ImageUrl && (
<img
src={data.ImageUrl}
alt="企业Logo"
className="w-16 h-16 rounded-lg object-contain bg-white border border-gray-200"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="flex-1 min-w-0">
<h2 className="text-xl font-semibold text-gray-900 truncate">{data.Name}</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-gray-500">
{data.CreditCode && (
<span className="bg-white px-2 py-0.5 rounded border border-gray-200">
{data.CreditCode}
</span>
)}
{data.EntType && (
<span className="bg-[#e6f4ff] text-[#0958d9] px-2 py-0.5 rounded border border-[#91caff]">
{EntTypeLabel[data.EntType] || '其他'}
</span>
)}
{data.IsOnStock === '1' && (
<span className="bg-orange-100 text-orange-700 px-2 py-0.5 rounded border border-orange-200">
{data.StockType}{data.StockNumber}
</span>
)}
</div>
{/* 曾用名 */}
{data.OriginalName && data.OriginalName.length > 0 && (
<div className="mt-2 text-sm text-gray-500">
<span className="text-gray-400"></span>
{data.OriginalName.map((item, index) => (
<span key={index} className="mr-2">
{item.Name}
{item.ChangeDate && <span className="text-gray-400 text-xs ml-1">({formatDate(item.ChangeDate)})</span>}
</span>
))}
</div>
)}
</div>
</div>
</div>
{/* 详细信息 */}
<div className="px-6 py-4">
{/* 基本信息 */}
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-700 mb-3 flex items-center">
<i className="ri-information-line mr-1 text-gray-400"></i>
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<InfoItem label="法定代表人" value={data.OperName} />
<InfoItem label="企业类型" value={data.EconKind} />
<InfoItem label="成立日期" value={formatDate(data.StartDate)} />
<InfoItem label="核准日期" value={formatDate(data.CheckDate)} />
<InfoItem label="营业期限" value={`${formatDate(data.TermStart)}${formatDate(data.TermEnd)}`} />
<InfoItem label="登记机关" value={data.BelongOrg} />
<InfoItem label="工商注册号" value={data.No} />
<InfoItem label="组织机构代码" value={data.OrgNo} />
{data.Area && (
<InfoItem
label="所属地区"
value={`${data.Area.Province || ''}${data.Area.City || ''}${data.Area.County || ''}`}
/>
)}
</div>
</div>
{/* 资本信息 */}
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-700 mb-3 flex items-center">
<i className="ri-money-cny-circle-line mr-1 text-gray-400"></i>
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoItem
label="注册资本"
value={data.RegistCapi || formatAmount(data.RegisteredCapital, data.RegisteredCapitalUnit, data.RegisteredCapitalCCY)}
/>
<InfoItem
label="实缴资本"
value={data.RecCap || formatAmount(data.PaidUpCapital, data.PaidUpCapitalUnit, data.PaidUpCapitalCCY)}
/>
</div>
</div>
{/* 注册地址 */}
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-700 mb-3 flex items-center">
<i className="ri-map-pin-line mr-1 text-gray-400"></i>
</h4>
<p className="text-sm text-gray-600 bg-gray-50 p-3 rounded-lg">{data.Address || '-'}</p>
</div>
{/* 经营范围 */}
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-700 mb-3 flex items-center">
<i className="ri-file-list-3-line mr-1 text-gray-400"></i>
</h4>
<p className="text-sm text-gray-600 bg-gray-50 p-3 rounded-lg leading-relaxed">
{data.Scope || '-'}
</p>
</div>
{/* 委派代表 */}
{data.DesignatedRepresentativeList && data.DesignatedRepresentativeList.length > 0 && (
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-700 mb-3 flex items-center">
<i className="ri-user-star-line mr-1 text-gray-400"></i>
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50">
<th className="px-4 py-2 text-left font-medium text-gray-600"></th>
<th className="px-4 py-2 text-left font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
{data.DesignatedRepresentativeList.map((rep, index) => (
<tr key={index} className="border-b border-gray-100">
<td className="px-4 py-2 text-gray-700">{rep.PartnerName || '-'}</td>
<td className="px-4 py-2 text-gray-700">{rep.DelegatedName || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 注销/吊销信息 */}
{hasRevokeInfo && (
<div className="mb-6">
<h4 className="text-sm font-medium text-red-600 mb-3 flex items-center">
<i className="ri-error-warning-line mr-1"></i>
/
</h4>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.RevokeInfo?.CancelDate && (
<>
<InfoItem label="注销日期" value={formatDate(data.RevokeInfo.CancelDate)} labelClassName="text-red-600" />
<InfoItem label="注销原因" value={data.RevokeInfo.CancelReason} labelClassName="text-red-600" />
</>
)}
{data.RevokeInfo?.RevokeDate && (
<>
<InfoItem label="吊销日期" value={formatDate(data.RevokeInfo.RevokeDate)} labelClassName="text-red-600" />
<InfoItem label="吊销原因" value={data.RevokeInfo.RevokeReason} labelClassName="text-red-600" />
</>
)}
</div>
</div>
</div>
)}
{/* 更新时间 */}
{data.UpdatedDate && (
<div className="text-xs text-gray-400 text-right">
{formatDate(data.UpdatedDate)}
</div>
)}
</div>
</div>
);
}
/** 信息项组件 */
interface InfoItemProps {
label: string;
value: string | null | undefined;
labelClassName?: string;
}
function InfoItem({ label, value, labelClassName = 'text-gray-500' }: InfoItemProps) {
return (
<div className="flex flex-col">
<span className={`text-xs ${labelClassName}`}>{label}</span>
<span className="text-sm text-gray-800 mt-0.5">{value || '-'}</span>
</div>
);
}
@@ -0,0 +1,363 @@
/**
* 企业信息模态框组件
* 用于展示企业工商信息和失信核查信息
*/
import { useState, useEffect, useCallback } from 'react';
import { CorporateInformation } from './CorporateInformation';
import { toastService } from '../ui/Toast';
import { messageService } from '../ui/MessageModal';
import type { BusinessInfoResult, DishonestyResult, Paging } from './types';
export interface CorporateInfoModalProps {
/** 是否显示模态框 */
visible: boolean;
/** 关闭模态框回调 */
onClose: () => void;
/** 企业名称(用于标题显示) */
companyName?: string;
/** 企业工商信息 */
businessInfo: BusinessInfoResult | null;
/** 失信核查数据 */
dishonestyInfo: DishonestyResult | null;
/** 失信核查分页信息 */
dishonestyPaging?: Paging | null;
/** 企业工商信息加载中 */
businessLoading?: boolean;
/** 失信核查加载中 */
dishonestyLoading?: boolean;
/** 企业工商信息错误 */
businessError?: string | null;
/** 失信核查错误 */
dishonestyError?: string | null;
/** 数据更新时间(ISO格式) */
updatedAt?: string | null;
/** 强制刷新回调(对接企查查重新查询) */
onForceRefresh?: () => Promise<void>;
}
/**
* 格式化更新时间(UTC转北京时间)
* @param isoString ISO格式的时间字符串
* @returns 格式化后的北京时间字符串
*/
function formatUpdatedTime(isoString: string | null | undefined): string {
if (!isoString) return '-';
try {
const date = new Date(isoString);
// 检查日期是否有效
if (isNaN(date.getTime())) {
return '-';
}
// 使用 Intl.DateTimeFormat 格式化为北京时间
const formatter = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
return formatter.format(date);
} catch {
return '-';
}
}
export function CorporateInfoModal({
visible,
onClose,
companyName,
businessInfo,
dishonestyInfo,
dishonestyPaging,
businessLoading = false,
dishonestyLoading = false,
businessError,
dishonestyError,
updatedAt,
onForceRefresh,
}: CorporateInfoModalProps) {
// 强制刷新按钮加载状态
const [refreshing, setRefreshing] = useState(false);
// ESC 键关闭模态框
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape' && visible) {
onClose();
}
},
[visible, onClose]
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
// 阻止背景滚动
useEffect(() => {
if (visible) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [visible]);
// 处理强制刷新按钮点击
const handleForceRefreshClick = () => {
messageService.show({
title: '确认重新查询',
message: '该操作将对接企查查API进行实时查询,会产生费用(约0.50元/次)。确定要继续吗?',
type: 'warning',
confirmText: '确认查询',
cancelText: '取消',
onConfirm: async () => {
if (onForceRefresh) {
setRefreshing(true);
try {
await onForceRefresh();
toastService.success('已提交查询,请稍后再查看企业信息');
onClose();
} catch (error) {
console.error('强制刷新失败:', error);
toastService.error('查询失败,请稍后重试');
} finally {
setRefreshing(false);
}
}
},
});
};
if (!visible) {
return null;
}
return (
<div
className="corporate-info-modal-overlay"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
onClick={(e) => {
// 点击背景关闭
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="corporate-info-modal"
style={{
backgroundColor: '#fff',
borderRadius: '8px',
width: '90%',
maxWidth: '900px',
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 模态框头部 */}
<div
className="corporate-info-modal-header"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 24px',
borderBottom: '1px solid #e5e7eb',
flexShrink: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<i
className="ri-building-line"
style={{ fontSize: '20px', color: '#00684a' }}
></i>
<h3
style={{
margin: 0,
fontSize: '18px',
fontWeight: 600,
color: '#1f2937',
}}
>
</h3>
{companyName && (
<span
style={{
marginLeft: '8px',
padding: '2px 8px',
backgroundColor: '#f3f4f6',
borderRadius: '4px',
fontSize: '14px',
color: '#6b7280',
}}
>
{companyName}
</span>
)}
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '4px',
transition: 'background-color 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#f3f4f6';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
title="关闭 (ESC)"
>
<i
className="ri-close-line"
style={{ fontSize: '24px', color: '#6b7280' }}
></i>
</button>
</div>
{/* 模态框内容 */}
<div
className="corporate-info-modal-body"
style={{
padding: '24px',
overflowY: 'auto',
flex: 1,
}}
>
<CorporateInformation
businessInfo={businessInfo}
dishonestyInfo={dishonestyInfo}
dishonestyPaging={dishonestyPaging}
businessLoading={businessLoading}
dishonestyLoading={dishonestyLoading}
businessError={businessError}
dishonestyError={dishonestyError}
/>
</div>
{/* 模态框底部 */}
<div
className="corporate-info-modal-footer"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 24px',
borderTop: '1px solid #e5e7eb',
flexShrink: 0,
}}
>
{/* 左侧:最近查询时间 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
color: '#6b7280',
}}
>
<i className="ri-time-line" style={{ fontSize: '14px' }}></i>
<span>{formatUpdatedTime(updatedAt)}</span>
</div>
{/* 右侧:按钮组 */}
<div style={{ display: 'flex', gap: '12px' }}>
{/* 对接企查查重新查询按钮 */}
<button
onClick={handleForceRefreshClick}
disabled={refreshing || businessLoading || dishonestyLoading}
style={{
padding: '8px 16px',
backgroundColor: refreshing ? '#d1d5db' : '#00684a',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
color: '#ffffff',
cursor: refreshing || businessLoading || dishonestyLoading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
gap: '6px',
opacity: refreshing || businessLoading || dishonestyLoading ? 0.6 : 1,
}}
onMouseEnter={(e) => {
if (!refreshing && !businessLoading && !dishonestyLoading) {
e.currentTarget.style.backgroundColor = '#005a3f';
}
}}
onMouseLeave={(e) => {
if (!refreshing && !businessLoading && !dishonestyLoading) {
e.currentTarget.style.backgroundColor = '#00684a';
}
}}
>
<i className={refreshing ? 'ri-loader-4-line' : 'ri-refresh-line'} style={{ fontSize: '14px' }}></i>
{refreshing ? '查询中...' : '对接企查查重新查询'}
</button>
{/* 关闭按钮 */}
<button
onClick={onClose}
style={{
padding: '8px 24px',
backgroundColor: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
color: '#374151',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#e5e7eb';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#f3f4f6';
}}
>
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
/**
* 企业综合信息展示组件
* 整合企业工商信息和失信核查信息
*/
import { BusinessInfo } from './BusinessInfo';
import { DishonestyInfo } from './DishonestyInfo';
import type { CorporateInformationProps } from './types';
export function CorporateInformation({
businessInfo,
dishonestyInfo,
dishonestyPaging,
businessLoading,
dishonestyLoading,
businessError,
dishonestyError,
}: CorporateInformationProps) {
return (
<div className="space-y-6">
{/* 企业工商信息 */}
<BusinessInfo
data={businessInfo}
loading={businessLoading}
error={businessError}
/>
{/* 失信核查信息 */}
<DishonestyInfo
data={dishonestyInfo}
paging={dishonestyPaging}
loading={dishonestyLoading}
error={dishonestyError}
/>
</div>
);
}
@@ -0,0 +1,219 @@
/**
* 失信核查信息展示组件
*/
import type { DishonestyInfoProps, DishonestyRecord } from './types';
/** 格式化日期 */
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '-';
return dateStr;
}
/** 格式化金额(元转万元) */
function formatAmount(amountStr: string | null | undefined): string {
if (!amountStr) return '-';
const amount = parseFloat(amountStr);
if (isNaN(amount)) return amountStr;
if (amount >= 10000) {
return `${(amount / 10000).toFixed(2)}万元`;
}
return `${amount.toFixed(2)}`;
}
/** 获取履行情况标签颜色 */
function getExecuteStatusColor(status: string): string {
if (status === '全部履行') {
return 'bg-green-100 text-green-700 border-green-200';
}
if (status === '部分履行') {
return 'bg-yellow-100 text-yellow-700 border-yellow-200';
}
if (status === '全部未履行') {
return 'bg-red-100 text-red-700 border-red-200';
}
return 'bg-gray-100 text-gray-700 border-gray-200';
}
export function DishonestyInfo({ data, paging, loading, error }: DishonestyInfoProps) {
// 加载中状态
if (loading) {
return (
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-center py-12">
<i className="ri-loader-4-line animate-spin text-2xl text-gray-400 mr-2"></i>
<span className="text-gray-500">...</span>
</div>
</div>
);
}
// 错误状态
if (error) {
return (
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-center py-12 text-red-500">
<i className="ri-error-warning-line text-2xl mr-2"></i>
<span>{error}</span>
</div>
</div>
);
}
// 无失信记录状态
if (!data || data.VerifyResult === 0 || !data.Data || data.Data.length === 0) {
return (
<div className="bg-white rounded-lg shadow-sm">
<div className="px-6 py-4 border-b border-gray-100">
<div className="flex items-center">
<i className="ri-shield-check-line text-xl text-[#00684a] mr-2"></i>
<h3 className="text-lg font-medium text-gray-900"></h3>
</div>
</div>
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
<i className="ri-shield-check-fill text-3xl text-green-500"></i>
</div>
<p className="text-green-600 font-medium"></p>
<p className="text-sm text-gray-400 mt-1">/</p>
</div>
</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-sm">
{/* 标题栏 */}
<div className="px-6 py-4 border-b border-gray-100">
<div className="flex items-center justify-between">
<div className="flex items-center">
<i className="ri-error-warning-fill text-xl text-red-500 mr-2"></i>
<h3 className="text-lg font-medium text-gray-900"></h3>
</div>
<div className="flex items-center gap-2">
<span className="bg-red-100 text-red-700 px-3 py-1 text-sm rounded-full border border-red-200">
</span>
{paging && paging.TotalRecords > 0 && (
<span className="text-sm text-gray-500">
<span className="font-medium text-red-600">{paging.TotalRecords}</span>
</span>
)}
</div>
</div>
</div>
{/* 失信记录列表 */}
<div className="px-6 py-4">
<div className="space-y-4">
{data.Data.map((record, index) => (
<DishonestyRecordCard key={record.Id || index} record={record} index={index + 1} />
))}
</div>
{/* 分页提示 */}
{paging && paging.TotalRecords > paging.PageSize && (
<div className="mt-4 text-center text-sm text-gray-500">
{paging.PageIndex} {Math.ceil(paging.TotalRecords / paging.PageSize)}
</div>
)}
</div>
</div>
);
}
/** 失信记录卡片组件 */
interface DishonestyRecordCardProps {
record: DishonestyRecord;
index: number;
}
function DishonestyRecordCard({ record, index }: DishonestyRecordCardProps) {
return (
<div className="border border-red-200 rounded-lg overflow-hidden">
{/* 卡片头部 */}
<div className="bg-red-50 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="bg-red-500 text-white text-xs px-2 py-0.5 rounded">
#{index}
</span>
<span className="font-medium text-gray-800">{record.Anno || '案号未知'}</span>
</div>
<span className={`px-2 py-0.5 text-xs rounded-full border ${getExecuteStatusColor(record.Executestatus)}`}>
{record.Executestatus || '状态未知'}
</span>
</div>
{/* 卡片内容 */}
<div className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<RecordItem
icon="ri-bank-line"
label="执行法院"
value={record.Executegov}
/>
<RecordItem
icon="ri-calendar-line"
label="立案日期"
value={formatDate(record.Liandate)}
/>
<RecordItem
icon="ri-calendar-check-line"
label="发布日期"
value={formatDate(record.Publicdate)}
/>
<RecordItem
icon="ri-money-cny-circle-line"
label="涉案金额"
value={formatAmount(record.Amount)}
valueClassName="text-red-600 font-medium"
/>
<RecordItem
icon="ri-file-text-line"
label="执行依据文号"
value={record.Executeno}
className="md:col-span-2"
/>
</div>
{/* 失信行为 */}
{record.ActionRemark && (
<div className="mt-4">
<div className="flex items-start gap-2">
<i className="ri-error-warning-line text-red-500 mt-0.5"></i>
<div className="flex-1">
<span className="text-xs text-gray-500 block mb-1"></span>
<p className="text-sm text-gray-700 bg-red-50 p-3 rounded-lg border border-red-100">
{record.ActionRemark}
</p>
</div>
</div>
</div>
)}
</div>
</div>
);
}
/** 记录项组件 */
interface RecordItemProps {
icon: string;
label: string;
value: string | null | undefined;
className?: string;
valueClassName?: string;
}
function RecordItem({ icon, label, value, className = '', valueClassName = 'text-gray-800' }: RecordItemProps) {
return (
<div className={`flex items-start gap-2 ${className}`}>
<i className={`${icon} text-gray-400 mt-0.5`}></i>
<div className="flex-1 min-w-0">
<span className="text-xs text-gray-500 block">{label}</span>
<span className={`text-sm ${valueClassName} break-words`}>{value || '-'}</span>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
/**
* 企业信息组件导出文件
*/
// 主组件
export { CorporateInformation } from './CorporateInformation';
export { BusinessInfo } from './BusinessInfo';
export { DishonestyInfo } from './DishonestyInfo';
export { CorporateInfoModal } from './CorporateInfoModal';
export type { CorporateInfoModalProps } from './CorporateInfoModal';
// 类型导出
export type {
// 企业工商信息类型
DesignatedRepresentative,
OriginalName,
RevokeInfo,
Area,
BusinessInfoResult,
BusinessInfoResponse,
BusinessInfoProps,
// 失信核查类型
DishonestyRecord,
Paging,
DishonestyResult,
DishonestyResponse,
DishonestyInfoProps,
// 综合组件类型
CorporateInformationProps,
} from './types';
// 枚举和常量导出
export { EntTypeEnum, EntTypeLabel, ExecuteStatusEnum } from './types';
@@ -0,0 +1,273 @@
/**
* 企业工商信息和失信核查相关类型定义
*/
// ==================== 企业工商信息类型 ====================
/** 委派代表 */
export interface DesignatedRepresentative {
/** 合伙人名称 */
PartnerName: string;
/** 委派代表名称 */
DelegatedName: string;
}
/** 曾用名 */
export interface OriginalName {
/** 曾用名 */
Name: string;
/** 变更日期 */
ChangeDate: string;
}
/** 注销吊销信息 */
export interface RevokeInfo {
/** 注销日期 */
CancelDate: string;
/** 注销原因 */
CancelReason: string;
/** 吊销日期 */
RevokeDate: string;
/** 吊销原因 */
RevokeReason: string;
}
/** 行政区域 */
export interface Area {
/** 省份 */
Province: string;
/** 城市 */
City: string;
/** 区域 */
County: string;
}
/** 企业性质枚举 */
export enum EntTypeEnum {
/** 大陆企业 */
MainlandEnterprise = '0',
/** 社会组织 */
SocialOrganization = '1',
/** 事业单位 */
PublicInstitution = '4',
/** 医院 */
Hospital = '7',
/** 律师事务所 */
LawFirm = '9',
/** 学校 */
School = '10',
/** 机关单位 */
GovernmentOrgan = '11',
/** 其他 */
Other = '-1',
}
/** 企业性质显示映射 */
export const EntTypeLabel: Record<string, string> = {
'0': '大陆企业',
'1': '社会组织',
'4': '事业单位',
'7': '医院',
'9': '律师事务所',
'10': '学校',
'11': '机关单位',
'-1': '其他',
};
/** 企业工商信息结果 */
export interface BusinessInfoResult {
/** 主键 */
KeyNo: string;
/** 企业名称 */
Name: string;
/** 工商注册号/企业编号 */
No: string;
/** 登记机关 */
BelongOrg: string;
/** 法定代表人ID */
OperId: string;
/** 法定代表人名称 */
OperName: string;
/** 委派代表列表 */
DesignatedRepresentativeList: DesignatedRepresentative[];
/** 成立日期 */
StartDate: string;
/** 吊销日期(保留字段) */
EndDate: string;
/** 登记状态 */
Status: string;
/** 省份(缩写) */
Province: string;
/** 更新日期 */
UpdatedDate: string;
/** 统一社会信用代码/商业登记号码 */
CreditCode: string;
/** 注册资本(含单位) */
RegistCapi: string;
/** 注册资本数额 */
RegisteredCapital: string;
/** 注册资本单位 */
RegisteredCapitalUnit: string;
/** 注册资本币种 */
RegisteredCapitalCCY: string;
/** 实缴资本(含单位) */
RecCap: string;
/** 实缴出资额数额 */
PaidUpCapital: string;
/** 实缴出资额单位 */
PaidUpCapitalUnit: string;
/** 实缴出资额币种 */
PaidUpCapitalCCY: string;
/** 企业类型 */
EconKind: string;
/** 注册地址 */
Address: string;
/** 经营范围 */
Scope: string;
/** 营业期限始 */
TermStart: string;
/** 营业期限至 */
TermEnd: string;
/** 核准日期 */
CheckDate: string;
/** 组织机构代码 */
OrgNo: string;
/** 是否上市(0-未上市,1-上市) */
IsOnStock: string;
/** 股票代码 */
StockNumber: string | null;
/** 上市类型 */
StockType: string | null;
/** 曾用名列表 */
OriginalName: OriginalName[];
/** 企业Logo地址 */
ImageUrl: string;
/** 企业性质 */
EntType: string;
/** 注销吊销信息 */
RevokeInfo: RevokeInfo | null;
/** 行政区域 */
Area: Area | null;
/** 行政区划代码 */
AreaCode: string;
}
/** 企业工商信息响应 */
export interface BusinessInfoResponse {
/** 查询结果数据 */
Result: BusinessInfoResult | null;
/** 状态码(200 表示成功) */
Status: string;
/** 响应消息 */
Message: string;
/** 订单编号 */
OrderNumber: string;
}
// ==================== 失信核查类型 ====================
/** 失信记录数据 */
export interface DishonestyRecord {
/** 主键 */
Id: string;
/** 立案日期 */
Liandate: string;
/** 案号 */
Anno: string;
/** 执行法院 */
Executegov: string;
/** 被执行人的履行情况 */
Executestatus: string;
/** 发布日期 */
Publicdate: string;
/** 执行依据文号 */
Executeno: string;
/** 失信行为 */
ActionRemark: string;
/** 涉案金额(元) */
Amount: string;
}
/** 分页信息 */
export interface Paging {
/** 每页记录数 */
PageSize: number;
/** 当前页码 */
PageIndex: number;
/** 总记录数 */
TotalRecords: number;
}
/** 失信核查结果 */
export interface DishonestyResult {
/** 数据是否存在(1-存在,0-不存在) */
VerifyResult: number;
/** 数据信息列表 */
Data: DishonestyRecord[];
}
/** 失信核查响应 */
export interface DishonestyResponse {
/** 分页信息 */
Paging: Paging;
/** 查询结果数据 */
Result: DishonestyResult | null;
/** 状态码(200 表示成功) */
Status: string;
/** 响应消息 */
Message: string;
/** 订单编号 */
OrderNumber: string;
}
/** 履行情况枚举 */
export enum ExecuteStatusEnum {
/** 全部未履行 */
NotExecuted = '全部未履行',
/** 部分履行 */
PartiallyExecuted = '部分履行',
/** 全部履行 */
FullyExecuted = '全部履行',
}
// ==================== 组件Props类型 ====================
/** 企业工商信息组件Props */
export interface BusinessInfoProps {
/** 企业工商信息数据 */
data: BusinessInfoResult | null;
/** 是否加载中 */
loading?: boolean;
/** 错误信息 */
error?: string | null;
}
/** 失信核查组件Props */
export interface DishonestyInfoProps {
/** 失信核查数据 */
data: DishonestyResult | null;
/** 分页信息 */
paging?: Paging | null;
/** 是否加载中 */
loading?: boolean;
/** 错误信息 */
error?: string | null;
}
/** 企业综合信息组件Props */
export interface CorporateInformationProps {
/** 企业工商信息 */
businessInfo: BusinessInfoResult | null;
/** 失信核查数据 */
dishonestyInfo: DishonestyResult | null;
/** 失信核查分页信息 */
dishonestyPaging?: Paging | null;
/** 企业工商信息加载中 */
businessLoading?: boolean;
/** 失信核查加载中 */
dishonestyLoading?: boolean;
/** 企业工商信息错误 */
businessError?: string | null;
/** 失信核查错误 */
dishonestyError?: string | null;
}
+138 -2
View File
@@ -21,6 +21,9 @@ import { useState, useEffect, useRef } from 'react';
import { toastService } from '../ui/Toast';
import { createPortal } from 'react-dom'; // 导入React Portal API,用于将组件渲染到DOM树的不同位置
import { Tooltip } from '../ui/Tooltip';
import { CorporateInfoModal } from '../corporate-information';
import type { BusinessInfoResult, DishonestyResult } from '../corporate-information';
import { queryCompanyInfo } from '~/api/corporate-information/qichacha';
// import '../../styles/components/TooltipStyles.css';
/**
@@ -423,6 +426,15 @@ export function ReviewPointsList({
// 存放评查点ID与有效页码的映射
const [effectivePages, setEffectivePages] = useState<Record<string, number>>({});
// 企业信息模态框状态
const [corporateModalVisible, setCorporateModalVisible] = useState(false);
const [corporateCompanyName, setCorporateCompanyName] = useState('');
const [corporateBusinessInfo, setCorporateBusinessInfo] = useState<BusinessInfoResult | null>(null);
const [corporateDishonestyInfo, setCorporateDishonestyInfo] = useState<DishonestyResult | null>(null);
const [corporateLoading, setCorporateLoading] = useState(false);
const [corporateError, setCorporateError] = useState<string | null>(null);
const [corporateUpdatedAt, setCorporateUpdatedAt] = useState<string | null>(null);
// 初始化建议文本
useEffect(() => {
// 使用函数式更新,不再需要外部 manualReviewNotes 变量
@@ -483,6 +495,71 @@ export function ReviewPointsList({
setEditingReviewPoint(null);
};
/**
* 处理企业信息按钮点击
* @param companyName 企业名称(乙方名称)
* @param forceRefresh 是否强制刷新(对接企查查重新查询)
*/
const handleCorporateInfoClick = async (companyName: string, forceRefresh = false) => {
if (!companyName) {
toastService.warning('企业名称为空,无法查询');
return;
}
// 打开模态框并设置加载状态
setCorporateModalVisible(true);
setCorporateCompanyName(companyName);
setCorporateLoading(true);
setCorporateError(null);
setCorporateBusinessInfo(null);
setCorporateDishonestyInfo(null);
setCorporateUpdatedAt(null);
try {
const response = await queryCompanyInfo({ keyword: companyName, force_refresh: forceRefresh });
if (response.success && response.data) {
setCorporateBusinessInfo(response.data.enterprise);
setCorporateUpdatedAt(response.data.updated_at);
// 转换失信数据格式
if (response.data.dishonesty) {
setCorporateDishonestyInfo({
VerifyResult: response.data.dishonesty.VerifyResult,
Data: response.data.dishonesty.Data || [],
});
}
} else {
setCorporateError(response.message || '查询失败');
}
} catch (error) {
console.error('查询企业信息失败:', error);
setCorporateError(error instanceof Error ? error.message : '查询失败');
} finally {
setCorporateLoading(false);
}
};
/**
* 处理强制刷新(对接企查查重新查询)
*/
const handleCorporateForceRefresh = async () => {
if (corporateCompanyName) {
await handleCorporateInfoClick(corporateCompanyName, true);
}
};
/**
* 关闭企业信息模态框
*/
const handleCloseCorporateModal = () => {
setCorporateModalVisible(false);
setCorporateCompanyName('');
setCorporateBusinessInfo(null);
setCorporateDishonestyInfo(null);
setCorporateError(null);
setCorporateUpdatedAt(null);
};
/**
* 过滤评查点
* 根据搜索文本和状态过滤条件筛选评查点
@@ -2406,7 +2483,7 @@ export function ReviewPointsList({
tabIndex={0}
style={{ userSelect: 'text' }}
onClick={() => {
// console.log('reviewPoint', reviewPoint);
console.log('reviewPoint', reviewPoint);
handleReviewPointClick(reviewPoint.id);
}}
onKeyDown={(e) => {
@@ -2419,7 +2496,51 @@ export function ReviewPointsList({
{/* 评查点名称 pointName*/}
<div className="flex justify-between items-center mb-2">
{/* <div className='flex flex-col'> */}
<div className="review-point-title text-left text-blue-500 max-w-[75%] break-all">{reviewPoint.pointName}</div>
<div className="flex items-center gap-2 max-w-[75%]">
<div className="review-point-title text-left text-blue-500 break-all">{reviewPoint.pointName}</div>
{ reviewPoint.pointName === '签署乙方详细信息校验' && (
<button
className="enterprise-info-btn"
style={{
padding: '2px 8px',
fontSize: '12px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
flexShrink: 0,
transition: 'all 0.2s',
border: 'none',
cursor: reviewPoint.content?.['合同主体信息-乙方-名称']?.value ? 'pointer' : 'not-allowed',
backgroundColor: reviewPoint.content?.['合同主体信息-乙方-名称']?.value ? '#00684a' : '#e5e7eb',
color: reviewPoint.content?.['合同主体信息-乙方-名称']?.value ? '#ffffff' : '#9ca3af',
}}
disabled={!reviewPoint.content?.['合同主体信息-乙方-名称']?.value}
onClick={(e) => {
e.stopPropagation();
const companyNameValue = reviewPoint.content?.['合同主体信息-乙方-名称']?.value;
const companyName = typeof companyNameValue === 'string' ? companyNameValue : String(companyNameValue || '');
if (companyName) {
handleCorporateInfoClick(companyName);
}
}}
onMouseEnter={(e) => {
if (reviewPoint.content?.['合同主体信息-乙方-名称']?.value) {
e.currentTarget.style.backgroundColor = '#005a3f';
}
}}
onMouseLeave={(e) => {
if (reviewPoint.content?.['合同主体信息-乙方-名称']?.value) {
e.currentTarget.style.backgroundColor = '#00684a';
}
}}
>
<i className="ri-eye-line"></i>
</button>
)}
</div>
{/* <div className="review-point-header flex justify-between items-start">
<div className="flex-1 text-left min-w-[25%] font-medium text-[13px]">{reviewPoint.title}</div>
//评查点分组显示
@@ -2450,6 +2571,21 @@ export function ReviewPointsList({
renderEmptyState()
)}
</div>
{/* 企业信息模态框 */}
<CorporateInfoModal
visible={corporateModalVisible}
onClose={handleCloseCorporateModal}
companyName={corporateCompanyName}
businessInfo={corporateBusinessInfo}
dishonestyInfo={corporateDishonestyInfo}
businessLoading={corporateLoading}
dishonestyLoading={corporateLoading}
businessError={corporateError}
dishonestyError={corporateError}
updatedAt={corporateUpdatedAt}
onForceRefresh={handleCorporateForceRefresh}
/>
</div>
);
}
+17 -1
View File
@@ -450,4 +450,20 @@ if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'testing'
}
},
});
}
}
/**
* 交叉评查专属模式配置
* 当为 true 且端口为 51707 时,首页只显示交叉评查入口
*/
export const CROSS_CHECKING_ONLY_MODE = process.env.CROSS_CHECKING_ONLY_MODE === 'true';
/**
* 交叉评查专属模式的目标端口
*/
export const CROSS_CHECKING_ONLY_PORT = '51707';
/**
* 获取当前端口(服务端使用)
*/
export { getCurrentPort };
+4
View File
@@ -443,7 +443,11 @@ export function ErrorBoundary() {
statusText={statusText}
message={message}
/>
{/* 添加 RouteChangeLoader 确保导航状态变化时能正确隐藏加载条 */}
<RouteChangeLoader />
<Scripts />
{/* 添加 LoadingBarContainer 确保加载条能正确显示和隐藏 */}
<LoadingBarContainer />
</body>
</html>
);
+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"