From 1658bb1c6f72a8f0802fecd1efdac6b3cb67826c Mon Sep 17 00:00:00 2001 From: yorn <1057707203@qq.com> Date: Sat, 13 Dec 2025 07:18:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=201.=20=E9=87=8D=E6=9E=84=E4=BA=A4?= =?UTF-8?q?=E5=8F=89=E8=AF=84=E6=9F=A5=E4=BB=BB=E5=8A=A1=E7=9A=84=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=88=97=E8=A1=A8=E7=9A=84=E6=98=BE=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5=E6=8E=A5=E5=8F=A3=E6=9F=A5=E8=AF=A2=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E4=BB=BB=E5=8A=A1=E7=9A=84=E6=96=87=E6=A1=A3=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E4=BF=A1=E6=81=AF=E3=80=82=202.=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=80=9A=E8=BF=87=E6=8E=A5=E5=8F=A3=E5=8E=BB?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=98=AF=E5=90=A6=E5=AD=98=E5=9C=A8=E5=90=8C?= =?UTF-8?q?=E5=90=8D=E7=9A=84=E6=96=87=E4=BB=B6=EF=BC=8C=E5=81=9A=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=89=8D=E6=8B=A6=E6=88=AA=E6=8F=90=E7=A4=BA=E3=80=82?= =?UTF-8?q?=203.=E4=BA=A4=E5=8F=89=E8=AF=84=E6=9F=A5=E7=9A=84=E8=AF=84?= =?UTF-8?q?=E6=9F=A5=E7=BB=93=E6=9E=9C=E4=B9=9F=E5=90=8C=E6=AD=A5=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=BC=81=E6=9F=A5=E6=9F=A5=E7=9A=84=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=9F=A5=E8=AF=A2=E6=A8=A1=E5=9D=97=E3=80=82?= =?UTF-8?q?=204.=20=E5=B0=81=E8=A3=85=E4=B8=8A=E4=BC=A0=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E5=92=8C=E4=B8=8A=E4=BC=A0=E6=A8=A1=E6=9D=BF=E7=9A=84=E6=A8=A1?= =?UTF-8?q?=E6=80=81=E6=A1=86=E7=9A=84=E7=BB=84=E4=BB=B6=EF=BC=8C=E5=9C=A8?= =?UTF-8?q?=E4=BA=A4=E5=8F=89=E8=AF=84=E6=9F=A5=E7=9A=84=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E4=B8=AD=E5=BC=95=E5=85=A5=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E3=80=82=205.=20=E4=BA=A4=E5=8F=89=E8=AF=84=E6=9F=A5=E7=9A=84?= =?UTF-8?q?=E8=AF=84=E6=9F=A5=E7=BB=93=E6=9E=9C=E4=B8=AD=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E5=90=88=E5=90=8C=E7=B1=BB=E5=9E=8B=E7=9A=84=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=8A=A0=E5=85=A5=E7=BB=93=E6=9E=84=E6=AF=94?= =?UTF-8?q?=E5=AF=B9=E7=9A=84=E5=8A=9F=E8=83=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/cross-checking/cross-file-result.ts | 2 +- app/api/cross-checking/cross-files.ts | 470 ++++++- app/api/files/files-upload.ts | 59 +- .../cross-checking/DocumentListModal.tsx | 879 +++++++++--- .../cross-checking/ReviewPointsList.tsx | 137 +- app/components/ui/AttachmentUploadModal.tsx | 323 +++++ app/components/ui/TemplateUploadModal.tsx | 228 +++ app/routes/cross-checking._index.tsx | 147 +- app/routes/cross-checking.result.tsx | 126 +- app/routes/files.upload.tsx | 119 +- auth_doc/交叉评查新增功能设计(2).md | 1241 +++++++++++++++++ 11 files changed, 3368 insertions(+), 363 deletions(-) create mode 100644 app/components/ui/AttachmentUploadModal.tsx create mode 100644 app/components/ui/TemplateUploadModal.tsx create mode 100644 auth_doc/交叉评查新增功能设计(2).md diff --git a/app/api/cross-checking/cross-file-result.ts b/app/api/cross-checking/cross-file-result.ts index d355740..a21e2e3 100644 --- a/app/api/cross-checking/cross-file-result.ts +++ b/app/api/cross-checking/cross-file-result.ts @@ -113,7 +113,7 @@ export async function findIsProposer(taskId: string | number, userId: number | u ); const data = response.data; - console.log('[findIsProposer] 检查权限响应:', data); + // console.log('[findIsProposer] 检查权限响应:', data); // 返回 can_confirm 字段,表示是否有权确认完成 // 有权限的用户:任务创建者(assigner_id) 或 主要负责人(principal_user_ids) diff --git a/app/api/cross-checking/cross-files.ts b/app/api/cross-checking/cross-files.ts index d807f03..6cc3137 100644 --- a/app/api/cross-checking/cross-files.ts +++ b/app/api/cross-checking/cross-files.ts @@ -75,7 +75,7 @@ export interface UserTaskApiResponse { items: UserTaskInfo[]; } -// 任务文档接口类型定义(新增) +// 任务文档接口类型定义(旧版,保留兼容) export interface TaskDocument { document_id: number; file_name: string; @@ -103,6 +103,174 @@ export interface TaskDocument { manual_count: number; } +// ==================== 新版接口类型定义(支持版本归纳)==================== + +/** + * 历史版本信息 + * 每个历史版本都有独立的评查统计、消息列表、分数信息 + */ +export interface CrossReviewHistoryVersion { + /** 历史版本的文档ID */ + id: number; + /** 版本号(从1开始,数字越小越旧) */ + version_number: number; + /** 创建时间(ISO 8601格式) */ + created_at: string; + /** 文件大小(字节) */ + file_size: number; + /** 文件存储路径 */ + path: string; + /** 文档处理状态 */ + status: "Waiting" | "Cutting" | "Extractioning" | "Evaluationing" | "Failed" | "Processed"; + /** 文书号/文档编号(可为null) */ + document_number: string | null; + /** 文档类型ID */ + type_id: number; + /** 文档类型名称 */ + type_name: string; + /** 上传时间(ISO 8601格式) */ + upload_time: string; + /** 任务内评查完成状态:0=未评查, 1=已评查 */ + audit_status: 0 | 1; + /** 总评查点数 */ + total_evaluation_points: number; + /** 通过的评查点数量 */ + pass_count: number; + /** 警告的评查点数量 */ + warning_count: number; + /** 错误的评查点数量 */ + error_count: number; + /** 需人工审核的评查点数量 */ + manual_count: number; + /** 问题总数 */ + issue_count: number; + /** 警告消息列表 */ + warning_messages: string[]; + /** 错误消息列表 */ + error_messages: string[]; + /** 问题消息列表(综合:警告+错误) */ + issue_messages: string[]; + /** 需人工确认的消息列表 */ + manual_messages: string[]; + /** 最终得分 */ + final_score: number; + /** 满分 */ + full_score: number; + /** 得分摘要(如 "85.5/100") */ + score_summary: string; + /** 得分百分比(0-100) */ + score_percent: number; +} + +/** + * 文档信息(含版本和评查统计)- 新版接口 + */ +export interface CrossReviewDocumentWithVersion { + // ========== 基本信息 ========== + /** 当前版本的文档ID */ + id: number; + /** 文档名称 */ + name: string; + /** 文件存储路径 */ + path: string; + /** 当前版本号(最大值,从1开始) */ + version_number: number; + /** 创建时间(ISO 8601格式) */ + created_at: string; + /** 文档处理状态 */ + status: "Waiting" | "Cutting" | "Extractioning" | "Evaluationing" | "Failed" | "Processed"; + /** 文件大小(字节) */ + file_size: number; + /** 文书号/文档编号(可为null) */ + document_number: string | null; + /** 文档类型ID */ + type_id: number; + /** 文档类型名称 */ + type_name: string; + /** 上传时间(ISO 8601格式) */ + upload_time: string; + + // ========== 任务内评查状态 ========== + /** 任务内评查完成状态:0=未评查, 1=已评查 */ + audit_status: 0 | 1; + + // ========== 评查统计 ========== + /** 总评查点数 */ + total_evaluation_points: number; + /** 通过的评查点数量 */ + pass_count: number; + /** 警告的评查点数量 */ + warning_count: number; + /** 错误的评查点数量 */ + error_count: number; + /** 需人工审核的评查点数量 */ + manual_count: number; + /** 问题总数 */ + issue_count: number; + + // ========== 评查消息列表 ========== + /** 警告消息列表 */ + warning_messages: string[]; + /** 错误消息列表 */ + error_messages: string[]; + /** 问题消息列表(综合) */ + issue_messages: string[]; + /** 需人工确认的消息列表 */ + manual_messages: string[]; + + // ========== 交叉评查特有:分数信息 ========== + /** 最终得分 */ + final_score: number; + /** 满分 */ + full_score: number; + /** 得分摘要(如 "85.5/100") */ + score_summary: string; + /** 得分百分比(0-100) */ + score_percent: number; + + // ========== 版本信息 ========== + /** 总版本数 */ + total_versions: number; + /** 历史版本列表(按created_at降序,不包含当前版本) */ + history_versions: CrossReviewHistoryVersion[]; + + // ========== 前端扩展字段 ========== + /** 是否已展开历史版本(前端状态) */ + isExpanded?: boolean; +} + +/** + * 交叉评查任务文档列表响应 + */ +export interface CrossReviewDocumentListResponse { + /** 总文档数(按版本分组后的唯一文档数) */ + total: number; + /** 当前页码 */ + page: number; + /** 每页数量 */ + page_size: number; + /** 总页数 */ + total_pages: number; + /** 文档列表 */ + documents: CrossReviewDocumentWithVersion[]; +} + +/** + * 获取任务文档列表请求参数(支持版本归纳) + */ +export interface GetTaskDocumentsWithVersionsParams { + /** 任务ID */ + taskId: number; + /** 页码(从1开始) */ + page?: number; + /** 每页数量(最大100) */ + pageSize?: number; + /** 模糊搜索关键字(匹配文件名称或文档编号) */ + keyword?: string; + /** JWT token */ + jwtToken?: string; +} + // 任务文档API响应格式(新增) export interface TaskDocumentApiResponse { total: number; @@ -434,7 +602,7 @@ export async function getUserTaskDocuments(page: number = 1, pageSize: number = } /** - * 获取指定任务的文档列表(新增接口) + * 获取指定任务的文档列表(旧版接口,保留兼容) * @param taskId 任务ID * @param page 页码 * @param pageSize 每页大小 @@ -476,6 +644,75 @@ export async function getTaskDocuments(taskId: number, page: number = 1, pageSiz } } +/** + * 获取任务下文档列表(支持版本归纳)- 新版接口 + * + * POST /api/v2/cross_review/tasks/{task_id}/documents + * + * 同一任务内同名且同类型的文档会被归纳为版本组,最新上传的为当前版本,其余为历史版本。 + * + * @param params 请求参数 + * @returns 文档列表响应(含版本信息) + */ +export async function getTaskDocumentsWithVersions( + params: GetTaskDocumentsWithVersionsParams +): Promise> { + const { taskId, page = 1, pageSize = 10, keyword, jwtToken } = params; + + try { + // 拼接绝对路径,去除多余斜杠 + const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL; + const url = `${base}/api/v2/cross_review/tasks/${taskId}/documents`; + + // 构建请求体 + const queryParams: { + page: number; + page_size: number; + keyword?: string; + } = { + page, + page_size: pageSize + }; + + // 只有当 keyword 有值时才添加 + if (keyword && keyword.trim()) { + queryParams.keyword = keyword.trim(); + } + + const response = await axios.get(url, { + params: queryParams, + headers: { + 'Authorization': `Bearer ${jwtToken || ''}` + } + }); + + return { + success: true, + data: response.data + }; + } catch (error) { + if (axios.isAxiosError(error)) { + // 处理特定错误码 + if (error.response?.status === 403) { + return { + success: false, + error: '无权访问任务:您不是该任务的参与者', + status: 403 + }; + } + return { + success: false, + error: `HTTP ${error.response?.status}: ${error.response?.statusText || error.message}`, + status: error.response?.status + }; + } + return { + success: false, + error: error instanceof Error ? error.message : '获取任务文档列表失败' + }; + } +} + /** * 更新文件的审核状态 @@ -563,4 +800,233 @@ export async function getCrossCheckingDocumentTypes(jwtToken?: string): Promise< error: error instanceof Error ? error.message : '获取文档类型失败' }; } +} + + +// ==================== 追加附件 API ==================== + +/** + * 追加附件响应接口 + */ +export interface AppendAttachmentsResponse { + success: boolean; + result?: { + original_document_id: number; + new_document_id: number; + new_version_number: number; + task_id: number; + message: string; + background_processing: boolean; + }; + error?: string; +} + +/** + * 追加附件参数接口 + */ +export interface AppendAttachmentsParams { + /** 任务ID */ + taskId: number; + /** 原文档ID */ + documentId: number; + /** 附件文件列表 */ + files: File[]; + /** 备注说明(可选) */ + remark?: string; + /** Word附件是否使用Markdown处理(可选,默认false) */ + useMarkdown?: boolean; + /** JWT Token */ + jwtToken?: string; +} + +/** + * 为交叉评查任务文档追加附件 + * + * POST /api/v2/cross_review/tasks/{task_id}/documents/{document_id}/append_attachments + * + * 追加附件后创建新文档(新版本),原文档保留。 + * 新文档自动关联到当前任务,audit_status = 0(需重新评查) + * + * @param params 追加附件参数 + * @returns 追加结果 + */ +export async function appendTaskDocumentAttachments( + params: AppendAttachmentsParams +): Promise> { + const { taskId, documentId, files, remark, useMarkdown = false, jwtToken } = params; + + try { + if (!taskId || taskId <= 0) { + return { success: false, error: '任务ID无效' }; + } + if (!documentId || documentId <= 0) { + return { success: false, error: '文档ID无效' }; + } + if (!files || files.length === 0) { + return { success: false, error: '请选择附件文件' }; + } + + // 构建 FormData + const formData = new FormData(); + files.forEach(file => { + formData.append('files', file); + }); + if (remark) { + formData.append('remark', remark); + } + formData.append('use_markdown', useMarkdown.toString()); + + // 构建请求 URL + const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL; + const url = `${base}/api/v2/cross_review/tasks/${taskId}/documents/${documentId}/append_attachments`; + + const response = await axios.post(url, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + 'Authorization': `Bearer ${jwtToken || ''}` + } + }); + + return { + success: true, + data: response.data + }; + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 403) { + return { + success: false, + error: '无权追加附件:您不是任务创建者或负责人', + status: 403 + }; + } + if (error.response?.status === 400) { + return { + success: false, + error: error.response.data?.error || '请求参数错误', + status: 400 + }; + } + return { + success: false, + error: `HTTP ${error.response?.status}: ${error.response?.statusText || error.message}`, + status: error.response?.status + }; + } + return { + success: false, + error: error instanceof Error ? error.message : '追加附件失败' + }; + } +} + + +// ==================== 上传模板 API ==================== + +/** + * 上传模板响应接口(文档级别) + */ +export interface UploadDocumentTemplateResponse { + success: boolean; + result?: { + document_id: number; + comparison_id: number; + template_name: string; + template_path: string; + status: string; + message: string; + }; + error?: string; +} + +/** + * 上传模板参数接口(文档级别) + */ +export interface UploadDocumentTemplateParams { + /** 文档ID */ + documentId: number; + /** 模板文件 */ + file: File; + /** 对比记录ID(可选,用于更新已有模板) */ + comparisonId?: number; + /** JWT Token */ + jwtToken?: string; +} + +/** + * 为交叉评查任务中的文档上传模板(文档级别) + * + * 复用现有的 /upload_contract_template 接口,与 files-upload.ts 中的 uploadContractTemplate 保持一致 + * + * @param params 上传模板参数 + * @returns 上传结果 + */ +export async function uploadCrossReviewDocumentTemplate( + params: UploadDocumentTemplateParams +): Promise> { + const { documentId, file, comparisonId, jwtToken } = params; + + try { + if (!documentId || documentId <= 0) { + return { success: false, error: '文档ID无效' }; + } + if (!file) { + return { success: false, error: '请选择模板文件' }; + } + + // 构建 FormData,与 files-upload.ts 中的 uploadContractTemplate 保持一致 + const formData = new FormData(); + formData.append('file', file); + + // upload_info 作为 JSON 字符串 + const uploadInfo: { document_id: number; comparison_id?: number } = { + document_id: documentId + }; + if (comparisonId) { + uploadInfo.comparison_id = comparisonId; + } + formData.append('upload_info', JSON.stringify(uploadInfo)); + + // 使用与 files-upload.ts 相同的上传接口 + const { UPLOAD_URL } = await import('~/config/api-config'); + const url = `${UPLOAD_URL}/upload_contract_template`; + + const response = await axios.post(url, formData, { + headers: { + 'Accept': 'application/json', + 'Authorization': `Bearer ${jwtToken || ''}` + } + }); + + return { + success: true, + data: response.data + }; + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 403) { + return { + success: false, + error: '无权上传模板', + status: 403 + }; + } + if (error.response?.status === 400) { + return { + success: false, + error: error.response.data?.error || '请求参数错误', + status: 400 + }; + } + return { + success: false, + error: `HTTP ${error.response?.status}: ${error.response?.statusText || error.message}`, + status: error.response?.status + }; + } + return { + success: false, + error: error instanceof Error ? error.message : '上传模板失败' + }; + } } \ No newline at end of file diff --git a/app/api/files/files-upload.ts b/app/api/files/files-upload.ts index 6c94b0c..eb3fac1 100644 --- a/app/api/files/files-upload.ts +++ b/app/api/files/files-upload.ts @@ -1,7 +1,64 @@ import { postgrestGet, type PostgrestParams } from '../postgrest-client'; import dayjs from 'dayjs'; -import { UPLOAD_URL } from '../../config/api-config'; +import { UPLOAD_URL, API_BASE_URL } from '../../config/api-config'; import axios from 'axios'; + +/** + * 检查文档名称是否重复 + * @param name 文档名称 + * @param typeId 文档类型ID + * @returns 重复检查结果 + */ +export async function checkDocumentDuplicate( + name: string, + typeId: number +): Promise<{ is_duplicate: boolean; count: number }> { + try { + // 获取 token + let token: string | null = null; + if (typeof window !== 'undefined') { + token = localStorage.getItem('access_token'); + } + + const headers: Record = { + 'Accept': 'application/json' + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await axios.get( + `${API_BASE_URL}/api/v2/documents/check-duplicate`, + { + params: { name, type_id: typeId }, + headers + } + ); + + // 解析响应数据 + const data = response.data; + if (data && typeof data === 'object') { + // 处理标准响应格式 { code, msg, data } + if ('data' in data && data.data) { + return { + is_duplicate: data.data.is_duplicate ?? false, + count: data.data.count ?? 0 + }; + } + // 直接返回数据格式 + return { + is_duplicate: data.is_duplicate ?? false, + count: data.count ?? 0 + }; + } + + return { is_duplicate: false, count: 0 }; + } catch (error) { + console.error('【文档重名检查】检查失败:', error); + // 检查失败时默认允许上传 + return { is_duplicate: false, count: 0 }; + } +} // import { API_BASE_URL } from '../client'; /** diff --git a/app/components/cross-checking/DocumentListModal.tsx b/app/components/cross-checking/DocumentListModal.tsx index 2b62899..5267eb7 100644 --- a/app/components/cross-checking/DocumentListModal.tsx +++ b/app/components/cross-checking/DocumentListModal.tsx @@ -1,15 +1,21 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import { Link } from "@remix-run/react"; import { Modal } from '../ui/Modal'; -import { Table } from '../ui/Table'; -import { Button } from '../ui/Button'; -import { FileIcon } from '../ui/FileIcon'; +import { FileTag } from '../ui/FileTag'; import { FileTypeTag } from '../ui/FileTypeTag'; -import { StatusBadge } from '../ui/StatusBadge'; import { Pagination } from '../ui/Pagination'; -import { LoadingIndicator } from '../ui/SkeletonScreen'; -import { updateDocumentAuditStatus, type TaskDocument } from '~/api/cross-checking/cross-files'; // 更新导入 +import { LoadingIndicator, NumberSkeleton, TableRowSkeleton } from '../ui/SkeletonScreen'; +import { ResultStats } from '../ui/ResultStats'; import { toastService } from '../ui/Toast'; +import { AttachmentUploadModal } from '../ui/AttachmentUploadModal'; +import { TemplateUploadModal } from '../ui/TemplateUploadModal'; import { formatDate } from '~/utils'; -import {useRef, useState} from "react"; +import { + type CrossReviewDocumentWithVersion, + type CrossReviewHistoryVersion, + appendTaskDocumentAttachments, + uploadCrossReviewDocumentTemplate, +} from '~/api/cross-checking/cross-files'; // 导出样式链接 export const links = () => []; @@ -18,81 +24,283 @@ interface DocumentListModalProps { isOpen: boolean; onClose: () => void; title: string; - files: TaskDocument[]; // 更新类型 + /** 文档列表(新版接口数据) */ + documents: CrossReviewDocumentWithVersion[]; + /** 查看文件回调 */ onViewFile?: (fileId: string) => void; + /** 加载中状态 */ loading?: boolean; - // 分页相关属性 + /** 当前页码 */ currentPage?: number; + /** 每页条数 */ pageSize?: number; + /** 总数 */ total?: number; + /** 页码变更回调 */ onPageChange?: (page: number) => void; + /** 每页条数变更回调 */ onPageSizeChange?: (size: number) => void; - frontendJWT?: string; // 新增JWT参数 + /** 搜索回调 */ + onSearch?: (keyword: string) => void; + /** 任务ID(用于追加附件等操作) */ + taskId?: number; + /** 任务名称 */ + taskName?: string; + /** JWT Token */ + frontendJWT?: string; + /** 是否是负责人(任务创建者或主要负责人) */ + isProposer?: boolean; + /** 负责人状态是否加载中 */ + isProposerLoading?: boolean; } +// 文件处理状态选项 +const fileProcessingStatusOptions = [ + { value: "Waiting", label: "上传中", icon: "ri-loader-line", color: "blue" }, + { value: "Cutting", label: "切分中", icon: "ri-loader-line", color: "purple" }, + { value: "Extractioning", label: "抽取中", icon: "ri-loader-line", color: "cyan" }, + { value: "Evaluationing", label: "评查中", icon: "ri-loader-line", color: "teal" }, + { value: "Failed", label: "抽取异常", icon: "ri-close-circle-line", color: "red" }, + { value: "Processed", label: "已完成", icon: "ri-check-line", color: "green" }, +]; + +// 交叉评查审核状态选项(0=未评查, 1=已评查) +const crossReviewAuditStatusMapping: Record = { + "0": { label: "未评查", color: "blue", icon: "ri-time-line" }, + "1": { label: "已评查", color: "green", icon: "ri-check-line" }, +}; + +// 格式化文件大小 +const formatFileSize = (bytes: number) => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +}; + export function DocumentListModal({ isOpen, onClose, title, - files, + documents, onViewFile, loading = false, - // 分页属性,使用默认值 currentPage = 1, pageSize = 10, total = 0, onPageChange, onPageSizeChange, - frontendJWT + onSearch, + taskId, + taskName, + frontendJWT, + isProposer = false, + isProposerLoading = false }: DocumentListModalProps) { + // 搜索关键词 + const [searchKeyword, setSearchKeyword] = useState(''); + // 防抖定时器 + const searchDebounceRef = useRef(null); + // 查看按钮防抖 - const [isnavigating,setIsnavigating] = useState(false) - const viewDebounceRef = useRef(null) - const handleViewClickDebounced = (fileId: string, auditStatus: number | null) => { - if(viewDebounceRef.current) return; - viewDebounceRef.current = window.setTimeout(()=>{ - viewDebounceRef.current = null; - },1000); - void handleReviewFileClick(fileId, auditStatus); - } - // 查看评查文件 - const handleReviewFileClick = async (fileId: string, auditStatus: number | null) => { - // 检查audit_status是否为0,如果是则更新为2 - if (auditStatus === 0 || auditStatus === null) { - try { - // 更新文档状态,传递JWT - const updatedFile = await updateDocumentAuditStatus(fileId, 2, frontendJWT); - // console.log('更新后的文档状态:', updatedFile); - } catch (error) { - console.error('更新文件审核状态时出错:', error); - toastService.error(`更新文件审核状态时出错:${error instanceof Error ? error.message : '未知错误'}`); - return; - } + const [isNavigating, setIsNavigating] = useState(false); + const viewDebounceRef = useRef(null); + + // 版本展开状态 + const [expandedRows, setExpandedRows] = useState>(new Set()); + + // 本地文档数据(用于管理展开状态) + const [localDocuments, setLocalDocuments] = useState([]); + + // 附件追加模态框状态 + const [showAttachmentUpload, setShowAttachmentUpload] = useState(false); + const [selectedDocumentId, setSelectedDocumentId] = useState(null); + const [selectedDocumentName, setSelectedDocumentName] = useState(null); + const [selectedDocumentVersion, setSelectedDocumentVersion] = useState(null); + const [selectedDocumentPath, setSelectedDocumentPath] = useState(null); + const [attachmentUploading, setAttachmentUploading] = useState(false); + + // 模板上传模态框状态 + const [showTemplateUpload, setShowTemplateUpload] = useState(false); + const [templateUploading, setTemplateUploading] = useState(false); + + // 同步外部文档数据到本地 + useEffect(() => { + setLocalDocuments(documents.map(doc => ({ ...doc, isExpanded: expandedRows.has(doc.id) }))); + }, [documents, expandedRows]); + + // 处理搜索 + const handleSearchChange = useCallback((value: string) => { + setSearchKeyword(value); + + // 清除之前的防抖定时器 + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current); } - // 如果有自定义的查看处理函数,则调用它 + // 设置新的防抖定时器(300ms) + searchDebounceRef.current = window.setTimeout(() => { + onSearch?.(value); + }, 300); + }, [onSearch]); + + // 清理防抖定时器 + useEffect(() => { + return () => { + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current); + } + }; + }, []); + + // 查看文件(带防抖) + const handleViewClickDebounced = (fileId: string) => { + if (viewDebounceRef.current) return; + viewDebounceRef.current = window.setTimeout(() => { + viewDebounceRef.current = null; + }, 1000); + if (onViewFile) { - setIsnavigating(true) + setIsNavigating(true); onViewFile(fileId); } }; - // 审核状态选项及样式 - 与documents._index.tsx保持一致 - const auditStatusMapping: Record = { - "-1": { label: "不通过", color: "red", icon: "ri-close-line" }, - "-2": { label: "警告", color: "yellow", icon: "ri-alert-line" }, - "0": { label: "待审核", color: "blue", icon: "ri-time-line" }, - "1": { label: "通过", color: "green", icon: "ri-check-line" }, - "2": { label: "审核中", color: "purple", icon: "ri-search-line" }, + // 展开/折叠历史版本 + const handleToggleExpand = (doc: CrossReviewDocumentWithVersion) => { + const newExpanded = new Set(expandedRows); + + if (expandedRows.has(doc.id)) { + // 折叠 + newExpanded.delete(doc.id); + } else { + // 检查是否有历史版本 + if (!doc.history_versions || doc.history_versions.length === 0) { + return; + } + // 展开 + newExpanded.add(doc.id); + } + + setExpandedRows(newExpanded); + setLocalDocuments(prevDocs => + prevDocs.map(d => + d.id === doc.id ? { ...d, isExpanded: newExpanded.has(doc.id) } : d + ) + ); }; - // 渲染审核状态 - const renderAuditStatus = (file: TaskDocument) => { - // 处理audit_status为null或undefined的情况,默认为0(待审核) - const auditStatus = file.audit_status != null ? file.audit_status : 0; + // 打开追加附件模态框 + const handleOpenAttachmentUpload = (doc: CrossReviewDocumentWithVersion | CrossReviewHistoryVersion, version?: number) => { + setSelectedDocumentId(doc.id); + setSelectedDocumentName('name' in doc ? doc.name : `v${doc.version_number} 版本`); + setSelectedDocumentVersion(version ?? ('version_number' in doc ? doc.version_number : null)); + setSelectedDocumentPath(doc.path); + setShowAttachmentUpload(true); + }; + + // 打开上传模板模态框 + const handleOpenTemplateUpload = (doc: CrossReviewDocumentWithVersion | CrossReviewHistoryVersion, version?: number) => { + setSelectedDocumentId(doc.id); + setSelectedDocumentName('name' in doc ? doc.name : `v${doc.version_number} 版本`); + setSelectedDocumentVersion(version ?? ('version_number' in doc ? doc.version_number : null)); + setShowTemplateUpload(true); + }; + + // 关闭模态框的通用处理 + const handleCloseModals = () => { + setShowAttachmentUpload(false); + setShowTemplateUpload(false); + setSelectedDocumentId(null); + setSelectedDocumentName(null); + setSelectedDocumentVersion(null); + setSelectedDocumentPath(null); + }; + + // 处理追加附件上传 + const handleAttachmentUpload = async (files: File[], _mergeMode: 'overwrite' | 'new', remark: string) => { + if (!taskId || !selectedDocumentId) { + toastService.error('任务ID或文档ID无效'); + return; + } + + try { + setAttachmentUploading(true); + + const result = await appendTaskDocumentAttachments({ + taskId, + documentId: selectedDocumentId, + files, + remark: remark || undefined, + jwtToken: frontendJWT + }); + + if (!result.success || result.error) { + throw new Error(result.error || '追加附件失败'); + } + + toastService.success('附件追加成功!新版本正在后台处理中'); + handleCloseModals(); + + // 触发重新加载文档列表 + if (onSearch) { + onSearch(searchKeyword); + } + } catch (error) { + console.error('追加附件失败:', error); + toastService.error(error instanceof Error ? error.message : '追加附件失败'); + } finally { + setAttachmentUploading(false); + } + }; + + // 处理模板上传 + const handleTemplateUpload = async (file: File) => { + if (!selectedDocumentId) { + toastService.error('文档ID无效'); + return; + } + + try { + setTemplateUploading(true); + + const result = await uploadCrossReviewDocumentTemplate({ + documentId: selectedDocumentId, + file, + jwtToken: frontendJWT + }); + + if (!result.success || result.error) { + throw new Error(result.error || '上传模板失败'); + } + + toastService.success('合同模板上传成功!'); + handleCloseModals(); + } catch (error) { + console.error('上传模板失败:', error); + toastService.error(error instanceof Error ? error.message : '上传模板失败'); + } finally { + setTemplateUploading(false); + } + }; + + // 渲染文件处理状态 + const renderFileStatus = (status: string) => { + const statusInfo = fileProcessingStatusOptions.find(s => s.value === status) || fileProcessingStatusOptions[0]; + const isSpinning = status !== "Processed" && status !== "Failed"; + return ( +
+ + {statusInfo.label} +
+ ); + }; + + // 渲染审核状态(交叉评查专用:0=未评查, 1=已评查) + const renderAuditStatus = (auditStatus: 0 | 1) => { const statusKey = auditStatus.toString(); - const statusInfo = auditStatusMapping[statusKey] || auditStatusMapping["0"]; - + const statusInfo = crossReviewAuditStatusMapping[statusKey] || crossReviewAuditStatusMapping["0"]; return (
@@ -101,129 +309,211 @@ export function DocumentListModal({ ); }; - - - // 获取文件大小的友好显示 - const formatFileSize = (bytes: number) => { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + // 渲染历史版本行 + const renderHistoryRow = (historyDoc: CrossReviewHistoryVersion, parentDoc: CrossReviewDocumentWithVersion) => { + return ( + + +
+ + + v{historyDoc.version_number} 版本 + + {historyDoc.document_number && ( + + {historyDoc.document_number} + + )} +
+ + + {formatFileSize(historyDoc.file_size)} + + + {renderFileStatus(historyDoc.status)} + + + {renderAuditStatus(historyDoc.audit_status)} + + + + + +
+ {historyDoc.score_percent != null ? ( + = 90 ? 'text-green-600' : + historyDoc.score_percent >= 70 ? 'text-yellow-600' : + historyDoc.score_percent >= 0 ? 'text-red-600' : 'text-gray-400' + }`}> + {historyDoc.score_percent.toFixed(1)}% + + ) : ( + - + )} +
+ + + {formatDate(historyDoc.upload_time).split(' ')[0]} +
+ {formatDate(historyDoc.upload_time).split(' ')[1]} + + +
+ {/* 查看按钮 */} + + + 查看 + + {/* 追加附件按钮 - 仅当 type_name 包含"合同"时显示 */} + {historyDoc.status === 'Processed' && taskId && parentDoc.type_name?.includes('合同') && ( + + )} + {/* 上传模板按钮 - 仅当 type_name 包含"合同"时显示 */} + {historyDoc.status === 'Processed' && parentDoc.type_name?.includes('合同') && ( + + )} +
+ + + ); }; - // 定义表格列配置 + // 表格列定义 const columns = [ { - title: "文件名称", - key: "fileName", - width: "30%", - render: (_: unknown, file: TaskDocument) => ( -
-
- -
-
-
{file.file_name}
-
- 文件编号:{file.file_code} -
-
- 大小:{formatFileSize(file.file_size)} + title: "文档名称", + key: "name", + width: "25%", + render: (_: unknown, record: CrossReviewDocumentWithVersion) => ( +
+ {/* 展开/折叠图标(仅在有历史版本时显示) */} + {record.total_versions > 1 ? ( + { + e.stopPropagation(); + handleToggleExpand(record); + }} + title={expandedRows.has(record.id) ? '折叠历史版本' : '展开历史版本'} + > + ) : ( + + )} + + + +
+ + {record.name} + + {record.document_number && ( + {record.document_number} + )} +
+ + {/* 版本徽章 */} + {record.total_versions > 1 && ( + + + v{record.version_number} (有{record.total_versions - 1}个历史版本) + + )}
) }, { - title: "文件类型", - key: "fileType", + title: "文件大小", + key: "size", width: "8%", - render: (_: unknown, file: TaskDocument) => ( - formatFileSize(record.file_size) + }, + { + title: "文件状态", + key: "status", + width: "8%", + render: (_: unknown, record: CrossReviewDocumentWithVersion) => renderFileStatus(record.status) + }, + { + title: "评查状态", + key: "auditStatus", + width: "8%", + render: (_: unknown, record: CrossReviewDocumentWithVersion) => renderAuditStatus(record.audit_status) + }, + { + title: "结果统计", + key: "resultStats", + width: "15%", + render: (_: unknown, record: CrossReviewDocumentWithVersion) => ( + ) }, { - title: "上传时间", - key: "uploadTime", + title: "评查分数百分比", + key: "scorePercent", width: "8%", - render: (_: unknown, file: TaskDocument) => { - const uploadTime = formatDate(file.upload_time).split(' '); - const date = uploadTime[0]; - const time = uploadTime[1]; - return ( -
- {date} {/* 2025-07-22 */} -
- {time} {/* 10:00:00 */} -
- ); - } - }, - { - title: "评查统计", - key: "reviewStatus", - width: "10%", - render: (_: unknown, file: TaskDocument) => - // 要文件切分处理完之后,再显示评查统计 - file.status === 'Processed' ? ( -
- {file.pass_count > 0 && ( - - )} - {file.warning_count > 0 && ( - - )} - {file.fail_count > 0 && ( - - )} - {/* {file.manual_count > 0 && ( - - )} */} -
- - ) : ( -
- - -
- ) - }, - { - title: "评查分数", - key: "score", - width: "8%", - render: (_: unknown, file: TaskDocument) => ( + render: (_: unknown, record: CrossReviewDocumentWithVersion) => (
- {file.final_score ? ( - - {file.score_summary} + {record.score_percent != null ? ( + = 90 ? 'text-green-600' : + record.score_percent >= 70 ? 'text-yellow-600' : + record.score_percent >= 0 ? 'text-red-600' : 'text-gray-400' + }`}> + {record.score_percent.toFixed(1)}% ) : ( - @@ -232,43 +522,65 @@ export function DocumentListModal({ ) }, { - title: "评查分数百分化", - key: "scorePercent", + title: "上传时间", + key: "uploadTime", width: "10%", - render: (_: unknown, file: TaskDocument) => { - const value: number | null | undefined = file.score_percent as number | null | undefined; - if (value === null || value === undefined || Number.isNaN(value)) { - return -; - } - const numericValue = typeof value === 'string' ? Number(value) : value; - const normalized = numericValue <= 1 ? numericValue * 100 : numericValue; - const display = `${Number(normalized.toFixed(1))}%`; - return {display}; + render: (_: unknown, record: CrossReviewDocumentWithVersion) => { + const uploadTime = formatDate(record.upload_time).split(' '); + const date = uploadTime[0]; + const time = uploadTime[1]; + return ( +
+ {date} +
+ {time} +
+ ); } }, - { - title: '审核状态', - key: 'auditStatus', - width: '8%', - render: (_: unknown, file: TaskDocument) => renderAuditStatus(file) - }, { title: "操作", - key: "operation", - width: "auto", - render: (_: unknown, file: TaskDocument) => ( - <> - - + + {isNavigating ? '跳转中...' : '查看'} + + {/* 追加附件按钮 - 仅当 type_name 包含"合同"时显示 */} + {record.status === 'Processed' && taskId && record.type_name?.includes('合同') && ( + + )} + {/* 上传模板按钮 - 仅当 type_name 包含"合同"时显示 */} + {record.status === 'Processed' && record.type_name?.includes('合同') && ( + + )} +
) } ]; @@ -281,42 +593,156 @@ export function DocumentListModal({ size="full" className="document-list-modal" > -
- {loading ? ( - // 显示loading状态 -
- +
+ {/* 搜索栏和统计信息 */} +
+ {/* 左侧:文档统计 + 负责人标签 */} +
+ {/* 文档数量统计 */} +
+ + {loading ? ( + + ) : ( + <> + + {total || localDocuments.length} + 个文档 + + )} +
+ + {/* 分隔线 */} +
+ + {/* 负责人标签 */} +
+ {isProposerLoading ? ( + + + 加载中... + + ) : isProposer ? ( + + + 我是负责人 + + ) : ( + + + 评查人员 + + )} + {taskName && ( + + 任务:{taskName} + + )} +
- ) : files.length === 0 ? ( - // 无数据状态 + + {/* 右侧:搜索框 */} + {onSearch && ( +
+
+ handleSearchChange(e.target.value)} + className="pl-9 pr-4 py-2 border border-gray-300 rounded-md text-sm w-64 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> + + {searchKeyword && ( + + )} +
+
+ )} +
+ + {loading ? ( + + ) : localDocuments.length === 0 ? (
- 暂无文档数据 + {searchKeyword ? '未找到匹配的文档' : '暂无文档数据'}
) : ( - // 有数据时显示表格和分页 <> -
- - - {total || files.length} - 个文档 +
+ + + + {columns.map((col) => ( + + ))} + + + + {localDocuments.map((doc) => ( + <> + {/* 主文档行 */} + 1 ? 'cursor-pointer' : '' + }`} + onClick={(e) => { + // 只有有历史版本的行才可以点击 + if (doc.total_versions <= 1) return; + + // 检查点击的是否是可交互元素 + const target = e.target as HTMLElement; + const isInteractiveElement = + target.tagName === 'A' || + target.tagName === 'BUTTON' || + target.tagName === 'INPUT' || + target.closest('a') || + target.closest('button') || + target.closest('input') || + target.closest('.result-stats-wrapper') || + target.closest('.result-stat-item'); + + if (isInteractiveElement) return; + + handleToggleExpand(doc); + }} + > + {columns.map((col) => ( + + ))} + + {/* 历史版本行 */} + {expandedRows.has(doc.id) && doc.history_versions && doc.history_versions.length > 0 && ( + doc.history_versions.map((historyDoc) => renderHistoryRow(historyDoc, doc)) + )} + + ))} + +
+ {col.title} +
+ {col.render ? col.render(null, doc) : (doc as any)[col.key]} +
- - - - {/* 分页组件 - 只有在提供了分页回调函数且总数大于每页大小时才显示 */} + + {/* 分页组件 */} {onPageChange && total > 0 && ( {})} + onChange={onPageChange} onPageSizeChange={onPageSizeChange} showTotal={true} showPageSizeChanger={!!onPageSizeChange} @@ -326,6 +752,33 @@ export function DocumentListModal({ )} + + {/* 追加附件模态框 */} + + + {/* 上传模板模态框 */} + ); -} \ No newline at end of file +} diff --git a/app/components/cross-checking/ReviewPointsList.tsx b/app/components/cross-checking/ReviewPointsList.tsx index f70b010..f21f4b3 100644 --- a/app/components/cross-checking/ReviewPointsList.tsx +++ b/app/components/cross-checking/ReviewPointsList.tsx @@ -33,6 +33,9 @@ import { type SubmitOpinionRequest } from '../../api/cross-checking/cross-file-result'; import { useFetcher, useNavigate } from '@remix-run/react'; +import { CorporateInfoModal } from '../corporate-information'; +import type { BusinessInfoResult, DishonestyResult } from '../corporate-information'; +import { queryCompanyInfo } from '~/api/corporate-information/qichacha'; // import '../../styles/components/TooltipStyles.css'; /** @@ -576,6 +579,80 @@ export function ReviewPointsList({ // 存放评查点ID与有效页码的映射 const [effectivePages, setEffectivePages] = useState>({}); + // 企业信息模态框状态 + const [corporateModalVisible, setCorporateModalVisible] = useState(false); + const [corporateCompanyName, setCorporateCompanyName] = useState(''); + const [corporateBusinessInfo, setCorporateBusinessInfo] = useState(null); + const [corporateDishonestyInfo, setCorporateDishonestyInfo] = useState(null); + const [corporateLoading, setCorporateLoading] = useState(false); + const [corporateError, setCorporateError] = useState(null); + const [corporateUpdatedAt, setCorporateUpdatedAt] = useState(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); + }; + /** * 打开提出意见模态框 */ @@ -2610,7 +2687,50 @@ export function ReviewPointsList({ {/* 评查点名称 pointName*/}
{/*
*/} -
{reviewPoint.pointName}
+
+
{reviewPoint.pointName}
+ {reviewPoint.pointName === '签署乙方详细信息校验' && ( + + )} +
{/*
{reviewPoint.title}
//评查点分组显示 @@ -2988,6 +3108,21 @@ export function ReviewPointsList({ )}
+ + {/* 企业信息模态框 */} +
); diff --git a/app/components/ui/AttachmentUploadModal.tsx b/app/components/ui/AttachmentUploadModal.tsx new file mode 100644 index 0000000..0ee3f73 --- /dev/null +++ b/app/components/ui/AttachmentUploadModal.tsx @@ -0,0 +1,323 @@ +import { useState } from "react"; +import { messageService } from "./MessageModal"; + +// 格式化文件大小 +const formatFileSize = (bytes: number) => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +}; + +export interface AttachmentUploadModalProps { + /** 是否显示 */ + isOpen: boolean; + /** 关闭回调 */ + onClose: () => void; + /** 目标文档ID */ + documentId: number | null; + /** 目标文档名称 */ + documentName: string | null; + /** 目标文档版本号(可选) */ + documentVersion?: number | null; + /** 主文件路径(用于判断是否为docx) */ + mainFilePath?: string; + /** 上传回调 */ + onUpload: (files: File[], mergeMode: 'overwrite' | 'new', remark: string) => Promise; + /** 是否正在上传 */ + uploading?: boolean; + /** 标题(可选,默认"追加合同附件") */ + title?: string; + /** 支持的文件格式描述(可选) */ + supportedFormatsDesc?: string; + /** 是否显示合并模式选择(可选,默认false,仅显示新建模式) */ + showMergeMode?: boolean; +} + +export function AttachmentUploadModal({ + isOpen, + onClose, + documentId, + documentName, + documentVersion, + mainFilePath, + onUpload, + uploading = false, + title = "追加合同附件", + supportedFormatsDesc = "支持.pdf、.docx、ZIP、RAR格式。ZIP/RAR内需要保证文件格式一致,否则报错", + showMergeMode = false +}: AttachmentUploadModalProps) { + // 附件文件列表 + const [attachmentFiles, setAttachmentFiles] = useState([]); + // 合并模式 + const [attachmentMergeMode, setAttachmentMergeMode] = useState<'overwrite' | 'new'>('new'); + // 备注 + const [attachmentRemark, setAttachmentRemark] = useState(""); + // 拖拽状态 + const [isDragging, setIsDragging] = useState(false); + + // 重置状态 + const resetState = () => { + setAttachmentFiles([]); + setAttachmentRemark(""); + setIsDragging(false); + }; + + // 关闭处理 + const handleClose = () => { + resetState(); + onClose(); + }; + + // 处理文件选择 + const handleFilesSelected = (files: FileList) => { + try { + if (files.length > 0) { + // 检查主文件类型 + const isMainFileDocx = mainFilePath?.toLowerCase().endsWith('.docx'); + + // 验证文件类型 + const validFiles: File[] = []; + let hasInvalidFiles = false; + let hasPdfForDocx = false; + + Array.from(files).forEach(file => { + const fileName = file.name.toLowerCase(); + const isPdf = file.type === 'application/pdf' || fileName.endsWith('.pdf'); + const isValidType = + isPdf || + file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx') || + file.type === 'application/zip' || fileName.endsWith('.zip') || + file.type === 'application/x-rar-compressed' || fileName.endsWith('.rar'); + + // 如果主文件是docx,不允许上传pdf附件 + if (isMainFileDocx && isPdf) { + hasPdfForDocx = true; + return; + } + + if (isValidType) { + validFiles.push(file); + } else { + hasInvalidFiles = true; + } + }); + + if (hasPdfForDocx) { + messageService.error('主文件为DOCX格式时,附件不可以是PDF格式', { + title: '文件类型限制', + confirmText: '确定', + cancelText: '', + }); + } else if (hasInvalidFiles) { + messageService.error('只支持PDF、Word、ZIP、RAR格式的文件', { + title: '文件类型错误', + confirmText: '确定', + cancelText: '', + }); + } + + if (validFiles.length > 0) { + setAttachmentFiles(validFiles); + } + } + } catch (error) { + console.error('处理文件选择时发生错误:', error); + } + }; + + // 处理上传 + const handleUpload = async () => { + if (!documentId || attachmentFiles.length === 0) { + return; + } + await onUpload(attachmentFiles, attachmentMergeMode, attachmentRemark); + resetState(); + }; + + // 拖拽事件处理 + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const files = e.dataTransfer.files; + if (files && files.length > 0) { + handleFilesSelected(files); + } + }; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+ +
+ {/* 文档信息 */} +
+

+ 目标文档名称: {documentName} + {documentVersion && ( + + v{documentVersion} + + )} +

+

+ + {supportedFormatsDesc} +

+
+ + {/* 文件上传区域 */} +
+ +
+ e.target.files && handleFilesSelected(e.target.files)} + className="hidden" + id="attachment-file-input-modal" + /> + +
+ {attachmentFiles.length > 0 && ( +
+

+ 已选择 {attachmentFiles.length} 个文件 +

+
+ {attachmentFiles.map((file, index) => ( +
+ + {file.name} ({formatFileSize(file.size)}) +
+ ))} +
+
+ )} +
+ + {/* 合并模式选择(可选) */} + {showMergeMode && ( +
+ +
+ + +
+
+ )} + + {/* 备注 */} +
+ +