添加交叉评查菜单页面,添加单点登录相关逻辑(待完善)

This commit is contained in:
2025-07-15 20:17:51 +08:00
20 changed files with 14402 additions and 306 deletions
@@ -0,0 +1,295 @@
import { UPLOAD_URL } from '../../config/api-config';
/**
* 从不同格式的 API 响应中提取数据
* @param responseData API 响应数据
* @returns 提取后的数据或 null
*/
function extractApiData<T>(responseData: unknown): T | null {
if (!responseData) return null;
// 格式1: { code: number, msg: string, data: T }
if (typeof responseData === 'object' && responseData !== null &&
'code' in responseData &&
'data' in responseData &&
(responseData as { data: unknown }).data) {
return (responseData as { data: T }).data;
}
// 格式2: 直接是数据对象
return responseData as T;
}
// 案卷类型枚举
export enum CaseType {
ADMINISTRATIVE_PENALTY = "administrative_penalty",
ADMINISTRATIVE_PERMIT = "administrative_permit"
}
// 案卷类型到type_id的映射
export const CASE_TYPE_TO_TYPE_ID: Record<CaseType, number> = {
[CaseType.ADMINISTRATIVE_PENALTY]: 3, // 行政处罚
[CaseType.ADMINISTRATIVE_PERMIT]: 2, // 行政许可
};
// 文件上传响应接口
export interface CrossCheckingFileUploadResponse {
success: boolean;
result?: {
id: number;
file_name: string;
file_size: number;
file_url: string;
type_id: number;
type_description: string;
document_number: string | null;
storage_type: string;
is_test_document: boolean;
remark: string | null;
background_processing: boolean;
evaluation_level: string;
};
error: string | null;
}
// 上传的文件接口
export interface CrossCheckingUploadedFile {
id: string; // 本地生成的唯一ID
file: File;
name: string;
size: number;
type: string;
uploadType: 'single' | 'multiple';
}
/**
* 将文件转换为二进制数据
*/
export async function uploadFileToBinary(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (reader.result instanceof ArrayBuffer) {
resolve(reader.result);
} else {
reject(new Error('文件读取失败'));
}
};
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsArrayBuffer(file);
});
}
/**
* 上传交叉评查文件到服务器
* @param binaryData 文件的二进制数据
* @param fileName 文件名
* @param fileType 文件类型
* @param typeId 文档类型ID
* @param priority 优先级
* @param documentNumber 文档编号(可选)
* @param remark 备注信息(可选)
* @param isTestDocument 是否为测试文档
* @param documentId 关联的文档ID(用于附件上传)
* @param isReupload 是否为重新上传
* @returns 上传结果
*/
export async function uploadCrossCheckingDocument(
binaryData: ArrayBuffer,
fileName: string,
fileType: string,
typeId: number,
priority: string = 'normal',
documentNumber: string = '',
remark: string = '',
isTestDocument: boolean = false,
documentId: number | null = null,
isReupload: boolean = false
): Promise<{data: CrossCheckingFileUploadResponse; error?: never} | {data?: never; error: string; status?: number}> {
try {
console.log('【交叉评查上传】开始上传文档:', { fileName, fileSize: binaryData.byteLength, typeId });
// 创建FormData对象
const formData = new FormData();
// 将二进制数据转换为Blob并添加到FormData
const blob = new Blob([binaryData], { type: fileType });
formData.append('file', blob, fileName);
console.log('【交叉评查上传】Blob已创建,文件大小:', blob.size);
// 将信息添加到一个JSON对象中
const uploadInfo = {
type_id: typeId,
evaluation_level: priority,
document_number: documentNumber || null,
remark: remark || null,
is_test_document: isTestDocument,
document_id: documentId || null,
is_reupload: isReupload
};
// 添加JSON字符串到FormData
formData.append('upload_info', JSON.stringify(uploadInfo));
console.log('【交叉评查上传】FormData准备完成:', JSON.stringify(uploadInfo));
// 根据是否有documentId决定使用哪个接口
const uploadEndpoint = '/batch_upload';
const uploadUrl = UPLOAD_URL + uploadEndpoint;
console.log('【交叉评查上传】准备发送请求到服务器:', uploadUrl);
// 发送请求
try {
console.log('【交叉评查上传】开始fetch请求...');
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-File-Name': encodeURIComponent(fileName)
},
body: formData
});
console.log('【交叉评查上传】收到服务器响应:', { status: response.status, statusText: response.statusText });
if (!response.ok) {
const errorText = await response.text();
console.error(`【交叉评查上传】上传失败 (${response.status}): ${errorText}`);
return {
error: `上传失败: ${response.status} ${response.statusText} - ${errorText}`,
status: response.status
};
}
console.log('【交叉评查上传】开始解析JSON响应');
let responseData;
try {
responseData = await response.json();
console.log('【交叉评查上传】JSON响应解析成功:', responseData);
} catch (jsonError) {
console.error('【交叉评查上传】JSON解析失败:', jsonError);
return {
error: `解析响应JSON失败: ${jsonError instanceof Error ? jsonError.message : '未知错误'}`,
status: 500
};
}
const extractedData = extractApiData<CrossCheckingFileUploadResponse>(responseData);
console.log('【交叉评查上传】提取的数据:', extractedData);
if (!extractedData) {
console.error('【交叉评查上传】无法提取数据');
return { error: '处理上传响应失败', status: 500 };
}
console.log('【交叉评查上传】上传成功,返回数据');
return { data: extractedData as CrossCheckingFileUploadResponse };
} catch (fetchError) {
console.error('【交叉评查上传】fetch请求失败:', fetchError);
return {
error: `fetch请求错误: ${fetchError instanceof Error ? fetchError.message : '未知错误'}`,
status: 500
};
}
} catch (error) {
console.error('【交叉评查上传】上传过程中发生错误:', error);
return {
error: error instanceof Error ? error.message : '上传失败',
status: 500
};
}
}
/**
* 批量上传交叉评查文件
* @param files 文件列表
* @param typeId 文档类型ID
* @param priority 优先级
* @param documentNumber 文档编号
* @param remark 备注信息
* @param isTestDocument 是否为测试文档
* @returns 上传结果列表
*/
export async function batchUploadCrossCheckingFiles(
files: CrossCheckingUploadedFile[],
typeId: number,
priority: string = 'normal',
documentNumber: string = '',
remark: string = '',
isTestDocument: boolean = false
): Promise<{
successes: Array<{file: CrossCheckingUploadedFile; result: CrossCheckingFileUploadResponse}>;
failures: Array<{file: CrossCheckingUploadedFile; error: string}>;
}> {
const successes: Array<{file: CrossCheckingUploadedFile; result: CrossCheckingFileUploadResponse}> = [];
const failures: Array<{file: CrossCheckingUploadedFile; error: string}> = [];
console.log('【交叉评查批量上传】开始批量上传文件,文件数量:', files.length);
for (const fileInfo of files) {
try {
console.log('【交叉评查批量上传】上传文件:', fileInfo.name);
// 转换文件为二进制格式
const binaryData = await uploadFileToBinary(fileInfo.file);
// 上传文件
const result = await uploadCrossCheckingDocument(
binaryData,
fileInfo.file.name,
fileInfo.file.type,
typeId,
priority,
documentNumber,
remark,
isTestDocument,
null, // 交叉评查文件通常没有关联的文档ID
false
);
if (result.error) {
console.error('【交叉评查批量上传】文件上传失败:', fileInfo.name, result.error);
failures.push({
file: fileInfo,
error: result.error
});
} else if (result.data) {
console.log('【交叉评查批量上传】文件上传成功:', fileInfo.name);
successes.push({
file: fileInfo,
result: result.data
});
}
} catch (error) {
console.error('【交叉评查批量上传】处理文件时发生错误:', fileInfo.name, error);
failures.push({
file: fileInfo,
error: error instanceof Error ? error.message : '上传失败'
});
}
}
console.log('【交叉评查批量上传】批量上传完成,成功:', successes.length, '失败:', failures.length);
return { successes, failures };
}
/**
* 生成唯一文件ID
* @returns 唯一ID字符串
*/
export function generateFileId(): string {
return `cross_checking_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
/**
* 格式化文件大小显示
* @param bytes 字节数
* @returns 格式化后的文件大小字符串
*/
export function formatFileSize(bytes: number): string {
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];
}
+1
View File
@@ -132,6 +132,7 @@ export async function uploadFileToBinary(file: File): Promise<ArrayBuffer> {
* @param remark 备注信息(可选)
* @param isTestDocument 是否为测试文档
* @param documentId 关联的文档ID(用于合同附件上传)
* @param isReupload 是否为重新上传
* @returns 上传结果
*/
export async function uploadDocumentToServer(
+17 -17
View File
@@ -39,16 +39,16 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
const handleBack = () => {
// 防抖处理 - 如果已经在导航中,不重复触发
if (isNavigating) return;
// 设置导航状态为true
setIsNavigating(true);
loadingBarService.show();
// 根据来源页面返回
const previousRoute = fileInfo.previousRoute || 'documents';
const returnTo = previousRoute === 'documents'
? "/documents"
: previousRoute === 'filesUpload'
const returnTo = previousRoute === 'documents'
? "/documents"
: previousRoute === 'filesUpload'
? "/files/upload"
: "/rules-files";
// 立即导航返回
@@ -61,19 +61,19 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
const handleDownloadFile = async () => {
try {
const downloadUrl = `${DOCUMENT_URL}${fileInfo.path}`;
// 使用fetch获取文件内容
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`下载失败: ${response.status} ${response.statusText}`);
}
// 将响应转换为Blob
const blob = await response.blob();
// 创建Blob URL
const blobUrl = URL.createObjectURL(blob);
// 创建一个隐藏的a标签并点击它
const a = document.createElement('a');
a.style.display = 'none';
@@ -83,7 +83,7 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
a.download = decodeURIComponent(fileName);
document.body.appendChild(a);
a.click();
// 清理
setTimeout(() => {
document.body.removeChild(a);
@@ -222,7 +222,7 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
<div className="tab-nav w-full flex justify-between">
{/* 评查结果、AI智能分析、文件信息 */}
<div className="flex">
<button
<button
className={`tab-nav-item ${activeTab === 'preview' ? 'active' : ''}`}
onClick={() => onTabChange('preview')}
type="button"
@@ -244,11 +244,11 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
onClick={() => onTabChange('filecompare')}
type="button"
aria-pressed={activeTab === 'filecompare'}
>
>
<i className="ri-flip-horizontal-line"></i>
</button>
)}
<button
<button
className={`tab-nav-item ${activeTab === 'fileinfo' ? 'active' : ''}`}
onClick={() => onTabChange('fileinfo')}
type="button"
@@ -269,14 +269,14 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
</button>
)}
{/* 返回上一级 */}
<button
<button
className="ant-btn ant-btn-default flex items-center my-2"
onClick={() => handleBack()}
disabled={isNavigating}
>
<i className="ri-arrow-left-line mr-1"></i> {isNavigating ? '返回中...' : '返回'}
</button>
<button
<button
className="ant-btn ant-btn-default flex items-center my-2"
onClick={handleDownloadFile}
>
@@ -288,7 +288,7 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
>
<i className="ri-file-copy-line mr-1"></i> 导出评查报告
</button> */}
<button
<button
className={`ant-btn ant-btn-primary my-2 flex items-center ${fileInfo.auditStatus === 1 ? 'hidden' : ''}`}
onClick={onConfirmResults}
>
@@ -296,7 +296,7 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
</button>
</div>
</div>
<div className="tab-content w-full">
{children}
</div>
+12 -11
View File
@@ -32,18 +32,19 @@ interface ApiConfig {
const configs: Record<string, ApiConfig> = {
// 开发环境
development: {
// baseUrl: 'http://172.16.0.55:8008',
baseUrl: 'http://172.16.0.55:8008',
// baseUrl: 'http://172.16.0.81:3000',
baseUrl: 'http://nas.7bm.co:3000',
documentUrl: 'http://172.16.0.81:9000/docauditai/',
// baseUrl: 'http://nas.7bm.co:3000',
// documentUrl: 'http://172.16.0.81:9000/docauditai/',
documentUrl: 'http://172.16.0.55:8008/docauditai/',
uploadUrl: 'http://172.16.0.55:8008/admin/documents',
// uploadUrl: 'http://172.16.0.58:8008/admin/documents',
// uploadUrl: 'http://172.16.0.58:8008/admin/documents',
oauth: {
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO', // 需要替换为实际的Client ID
clientSecret: 'your_client_secret', // 需要替换为实际的Client Secret
redirectUri: 'http://localhost:3000/callback', // 回调地址
clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO',
clientSecret: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb', // 需要替换为实际的Client Secret
redirectUri: 'http://10.79.97.17/', // 回调地址
appId: 'idaasoauth2' // 应用ID,用于登出
}
},
@@ -65,16 +66,16 @@ const configs: Record<string, ApiConfig> = {
// 生产环境
production: {
// postgrest
baseUrl: 'http://10.79.97.16:8000',
baseUrl: 'http://10.79.97.17:8000',
// minio
documentUrl: 'http://10.76.244.156:9000/docauditai/',
// 文件上传
uploadUrl: 'http://10.79.97.16:8000/admin/documents/upload',
uploadUrl: 'http://10.79.97.17:8000/admin/documents',
oauth: {
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO', // 需要替换为实际的Client ID
clientSecret: 'your_client_secret', // 需要替换为实际的Client Secret
redirectUri: 'http://10.79.97.17/callback', // 回调地址
clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO',
clientSecret: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb', // 需要替换为实际的Client Secret
redirectUri: 'http://10.79.97.17/', // 回调地址
appId: 'idaasoauth2' // 应用ID,用于登出
}
},
+23 -19
View File
@@ -28,16 +28,9 @@ const extractAppId = (appUrl: string): string => {
return appUrl;
};
// 获取配置值并添加调试日志
const getApiUrl = () => {
// 在Remix中,我们使用本地API路由作为代理,而不是直接访问Dify API
if (typeof window !== 'undefined') {
// 客户端:使用相对路径访问本地API
return '/api';
} else {
// 服务端:也使用相对路径
return '/api';
}
// 获取Dify API配置
const getDifyApiUrl = () => {
return getEnvVar('NEXT_PUBLIC_API_URL', 'https://api.dify.ai/v1');
};
const getAppId = () => {
@@ -46,22 +39,36 @@ const getAppId = () => {
// console.log('🔧 Chat Config Debug:', {
// rawAppId,
// extractedAppId,
// apiUrl: getApiUrl(),
// difyApiUrl: getDifyApiUrl(),
// hasApiKey: !!getEnvVar('NEXT_PUBLIC_APP_KEY', ''),
// difyApiUrl: getEnvVar('NEXT_PUBLIC_API_URL', ''),
// });
return extractedAppId;
};
// 生成用户ID (模拟服务端逻辑)
const generateUserId = () => {
const appId = getAppId();
// 生成或获取会话ID (可以使用localStorage或其他方式)
let sessionId = '';
if (typeof window !== 'undefined') {
sessionId = localStorage.getItem('dify_session_id') || '';
if (!sessionId) {
sessionId = 'sess_' + Math.random().toString(36).substring(2, 15);
localStorage.setItem('dify_session_id', sessionId);
}
}
return `user_${appId}:${sessionId}`;
};
// 聊天应用配置
export const CHAT_CONFIG = {
// API相关配置 - 使用本地API路由作为代理
API_URL: getApiUrl(),
// API相关配置 - 直接使用Dify API
API_URL: getDifyApiUrl(),
APP_ID: getAppId(),
API_KEY: getEnvVar('NEXT_PUBLIC_APP_KEY', ''),
// Dify API 配置(用于服务端)
DIFY_API_URL: getEnvVar('NEXT_PUBLIC_API_URL', 'https://api.dify.ai/v1'),
// 用户生成函数
generateUserId,
// 应用信息
APP_INFO: {
@@ -76,9 +83,6 @@ export const CHAT_CONFIG = {
isShowPrompt: false,
promptTemplate: 'I want you to act as a javascript console.',
// API相关
API_PREFIX: '/api',
// 本地化
LOCALE_COOKIE_NAME: 'locale',
+1 -11
View File
@@ -113,8 +113,6 @@ export default function useConversation() {
isSetToLocalStorage = true,
newConversationName = ''
) => {
// console.log('🔄 设置当前会话ID:', { id, appId, isSetToLocalStorage });
doSetCurrConversationId(id);
if (isSetToLocalStorage && id !== '-1') {
@@ -130,19 +128,11 @@ export default function useConversation() {
globalThis.localStorage?.setItem(storageConversationIdKey, JSON.stringify(conversationIdInfo));
// console.log('💾 会话ID已保存到localStorage:', {
// appUrlKey,
// conversationId: id,
// fullStorage: conversationIdInfo
// });
} catch (error) {
console.error('保存会话ID到本地存储失败:', error);
}
}
// 不进行URL导航,保持单页面应用模式
// console.log('✅ 会话切换完成,当前会话ID:', id);
};
};
/**
* 重置新会话输入
+7
View File
@@ -7,6 +7,8 @@ import chatInputStyles from "~/styles/components/chat-with-llm/chat-input.css?ur
import chatSidebarStyles from "~/styles/components/chat-with-llm/sidebar.css?url";
import chatThoughtProcessStyles from "~/styles/components/chat-with-llm/thought-process.css?url";
import chatMarkdownStyles from "~/styles/components/chat-with-llm/markdown.css?url";
// 导入测试文件用于调试
import "~/utils/dify-test.client";
export function links() {
return [
@@ -32,6 +34,11 @@ export const meta: MetaFunction = () => {
/**
* 聊天主页面
* 实现单页面应用模式,所有会话切换都在同一页面内完成
*
* 调试说明:
* - 打开浏览器开发者工具的控制台
* - 输入 window.testDify() 可以测试Dify API连接
* - 查看网络选项卡可以监控API请求
*/
export default function ChatWithLLMIndex() {
return (
+523
View File
@@ -0,0 +1,523 @@
import { useState, useRef } from "react";
import { type MetaFunction, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useNavigation } from "@remix-run/react";
import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea";
import { Button } from "~/components/ui/Button";
import { messageService } from "~/components/ui/MessageModal";
import { toastService } from "~/components/ui/Toast";
import crossCheckingUploadStyles from "~/styles/pages/cross-checking-upload.css?url";
import {
CaseType,
CASE_TYPE_TO_TYPE_ID,
type CrossCheckingUploadedFile,
generateFileId,
formatFileSize,
batchUploadCrossCheckingFiles
} from "~/api/cross-checking/cross-files-upload";
export const meta: MetaFunction = () => {
return [
{ title: "交叉评查上传 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "交叉评查案卷上传和任务创建" }
];
};
export const handle = {
breadcrumb: "交叉评查上传"
};
export function links() {
return [
{ rel: "stylesheet", href: crossCheckingUploadStyles }
];
}
// 步骤枚举
const STEPS = [
{ id: 1, label: "创建任务" },
{ id: 2, label: "创建评查小组" },
{ id: 3, label: "选择卷宗" }
];
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const caseType = formData.get("caseType") as string;
const uploadType = formData.get("uploadType") as string;
console.log("交叉评查上传:", { caseType, uploadType });
// 这里可以处理上传后的业务逻辑
// 例如创建任务记录等
return Response.json({ success: true, message: "文件上传成功" });
};
export default function CrossCheckingUpload() {
// 基础状态
const [caseType, setCaseType] = useState<CaseType>(CaseType.ADMINISTRATIVE_PENALTY);
const [currentStep] = useState(1);
const navigation = useNavigation();
// 上传配置状态 - 设置默认值
const [priority] = useState<string>("normal");
const [documentNumber] = useState<string>("");
const [remark] = useState<string>("");
const [isTestDocument] = useState<boolean>(false);
// 文件管理状态
const [singleFiles, setSingleFiles] = useState<CrossCheckingUploadedFile[]>([]);
const [multipleFiles, setMultipleFiles] = useState<CrossCheckingUploadedFile[]>([]);
const [uploadType, setUploadType] = useState<'none' | 'single' | 'multiple'>('none');
const [isUploading, setIsUploading] = useState(false);
// 引用
const singleUploadRef = useRef<UploadAreaRef>(null);
const multipleUploadRef = useRef<UploadAreaRef>(null);
// 获取当前typeId
const currentTypeId = CASE_TYPE_TO_TYPE_ID[caseType];
// 处理案卷类型切换
const handleCaseTypeChange = (type: CaseType) => {
if (isUploading) {
toastService.warning("上传进行中,无法切换案卷类型");
return;
}
setCaseType(type);
// 清空已选择的文件和重置上传方式
clearAllFiles();
console.log("案卷类型切换为:", type, "typeId:", CASE_TYPE_TO_TYPE_ID[type]);
};
// 清空所有文件
const clearAllFiles = () => {
setSingleFiles([]);
setMultipleFiles([]);
setUploadType('none');
// 重置文件输入框
singleUploadRef.current?.resetFileInput();
multipleUploadRef.current?.resetFileInput();
};
// 处理单案件文件选择
const handleSingleFilesSelected = (files: FileList) => {
if (uploadType === 'multiple') {
toastService.warning("已选择多案件导入方式,无法选择单案件文件");
return;
}
const validFiles: CrossCheckingUploadedFile[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
validFiles.push({
id: generateFileId(),
file,
name: file.name,
size: file.size,
type: file.type,
uploadType: 'single'
});
} else {
hasInvalidFiles = true;
}
});
if (hasInvalidFiles) {
messageService.error('只能上传PDF格式的文件', {
title: '文件类型错误',
confirmText: '确定',
});
}
if (validFiles.length > 0) {
setSingleFiles(prev => [...prev, ...validFiles]);
setUploadType('single');
console.log("选择单案件文件:", validFiles.length, "个");
}
};
// 处理多案件文件选择
const handleMultipleFilesSelected = (files: FileList) => {
if (uploadType === 'single') {
toastService.warning("已选择单案件导入方式,无法选择多案件文件");
return;
}
const validFiles: CrossCheckingUploadedFile[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
const isZip = file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed' ||
file.name.toLowerCase().endsWith('.zip');
const isRar = file.type === 'application/x-rar-compressed' ||
file.name.toLowerCase().endsWith('.rar');
const is7z = file.type === 'application/x-7z-compressed' ||
file.name.toLowerCase().endsWith('.7z');
const isTar = file.type === 'application/x-tar' ||
file.name.toLowerCase().endsWith('.tar');
if (isZip || isRar || is7z || isTar) {
validFiles.push({
id: generateFileId(),
file,
name: file.name,
size: file.size,
type: file.type,
uploadType: 'multiple'
});
} else {
hasInvalidFiles = true;
}
});
if (hasInvalidFiles) {
messageService.error('只能上传ZIP或RAR格式的压缩文件', {
title: '文件类型错误',
confirmText: '确定',
});
}
if (validFiles.length > 0) {
setMultipleFiles(prev => [...prev, ...validFiles]);
setUploadType('multiple');
console.log("选择多案件文件:", validFiles.length, "个");
}
};
// 删除单个文件
const handleRemoveFile = (fileId: string, type: 'single' | 'multiple') => {
if (isUploading) {
toastService.warning("上传进行中,无法删除文件");
return;
}
if (type === 'single') {
setSingleFiles(prev => {
const newFiles = prev.filter(f => f.id !== fileId);
if (newFiles.length === 0) {
setUploadType('none');
singleUploadRef.current?.resetFileInput();
}
return newFiles;
});
} else {
setMultipleFiles(prev => {
const newFiles = prev.filter(f => f.id !== fileId);
if (newFiles.length === 0) {
setUploadType('none');
multipleUploadRef.current?.resetFileInput();
}
return newFiles;
});
}
};
// 清空文件列表
const handleClearFiles = (type: 'single' | 'multiple') => {
if (isUploading) {
toastService.warning("上传进行中,无法清空文件");
return;
}
if (type === 'single') {
setSingleFiles([]);
singleUploadRef.current?.resetFileInput();
} else {
setMultipleFiles([]);
multipleUploadRef.current?.resetFileInput();
}
setUploadType('none');
};
// 处理完成上传
const handleCompleteUpload = async () => {
const filesToUpload = uploadType === 'single' ? singleFiles : multipleFiles;
if (filesToUpload.length === 0) {
toastService.error("请先选择要上传的文件");
return;
}
setIsUploading(true);
try {
console.log("开始批量上传文件:", filesToUpload.length, "个,案卷类型:", caseType, "typeId:", currentTypeId);
const result = await batchUploadCrossCheckingFiles(
filesToUpload,
currentTypeId,
priority,
documentNumber,
remark,
isTestDocument
);
const { successes, failures } = result;
if (failures.length === 0) {
// 全部成功
toastService.success(`成功上传 ${successes.length} 个文件`);
messageService.success(`文件上传完成!成功上传 ${successes.length} 个文件,现在可以进行下一步操作。`, {
title: '上传成功',
confirmText: '确定',
onConfirm: () => {
// 清空文件列表
clearAllFiles();
}
});
} else if (successes.length === 0) {
// 全部失败
toastService.error(`文件上传失败,共 ${failures.length} 个文件上传失败`);
messageService.error(`所有文件上传失败。失败原因:${failures[0].error}`, {
title: '上传失败',
confirmText: '确定',
});
} else {
// 部分成功
toastService.warning(`部分文件上传成功:成功 ${successes.length} 个,失败 ${failures.length}`);
messageService.warning(
`部分文件上传完成:\n成功:${successes.length} 个文件\n失败:${failures.length} 个文件\n\n失败文件:\n${failures.map(f => `${f.file.name}: ${f.error}`).join('\n')}`,
{
title: '部分上传成功',
confirmText: '确定',
}
);
}
} catch (error) {
console.error("批量上传失败:", error);
toastService.error("文件上传过程中发生错误");
messageService.error(`文件上传失败:${error instanceof Error ? error.message : '未知错误'}`, {
title: '上传失败',
confirmText: '确定',
});
} finally {
setIsUploading(false);
}
};
// 检查是否可以完成
const canComplete = (singleFiles.length > 0 || multipleFiles.length > 0) && !isUploading;
const isSubmitting = navigation.state === "submitting";
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
{/* 步骤指示器 */}
<div className="steps-indicator">
{STEPS.map((step) => (
<div
key={step.id}
className={`step-item ${step.id < currentStep ? 'completed' : ''}`}
>
<div className={`step-circle ${step.id <= currentStep ? 'active' : 'inactive'}`}>
{step.id}
</div>
<div className="step-label">{step.label}</div>
</div>
))}
</div>
{/* 案卷类型选择器 */}
<div className="case-type-selector">
<div className="case-type-options">
<button
type="button"
className={`case-type-option ${caseType === CaseType.ADMINISTRATIVE_PENALTY ? 'active' : 'inactive'}`}
onClick={() => handleCaseTypeChange(CaseType.ADMINISTRATIVE_PENALTY)}
disabled={isUploading}
>
</button>
<button
type="button"
className={`case-type-option ${caseType === CaseType.ADMINISTRATIVE_PERMIT ? 'active' : 'inactive'}`}
onClick={() => handleCaseTypeChange(CaseType.ADMINISTRATIVE_PERMIT)}
disabled={isUploading}
>
</button>
</div>
</div>
{/* 文件上传区域 */}
<Form method="post" encType="multipart/form-data">
<input type="hidden" name="caseType" value={caseType} />
<input type="hidden" name="uploadType" value={uploadType} />
<div className="upload-section">
{/* 单案件导入 */}
<div className="upload-item">
<div className="upload-item-header">
<i className="upload-item-icon ri-file-text-line"></i>
<span></span>
{uploadType === 'single' && singleFiles.length > 0 && (
<Button
type="default"
size="small"
icon="ri-delete-bin-line"
onClick={() => handleClearFiles('single')}
disabled={isUploading}
>
</Button>
)}
</div>
<UploadArea
ref={singleUploadRef}
onFilesSelected={handleSingleFilesSelected}
className="custom-upload-area"
accept=".pdf"
multiple={true}
icon="ri-file-upload-line"
buttonText="选择文件"
mainText="点击或拖拽文件到此区域上传"
tipText={
<div className="upload-tip-error">
PDF文件
</div>
}
disabled={uploadType === 'multiple' || isUploading}
/>
{/* 单案件文件列表 */}
{singleFiles.length > 0 && (
<div className="mt-4 space-y-2">
<div className="text-sm font-medium text-gray-700">
{singleFiles.length} :
</div>
<div className="max-h-32 overflow-y-auto space-y-1">
{singleFiles.map((file) => (
<div key={file.id} className="flex items-center justify-between bg-gray-50 p-2 rounded">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<i className="ri-file-pdf-line text-red-500"></i>
<span className="text-sm truncate">{file.name}</span>
<span className="text-xs text-gray-500">{formatFileSize(file.size)}</span>
</div>
<button
type="button"
onClick={() => handleRemoveFile(file.id, 'single')}
className="text-red-500 hover:text-red-700 p-1"
disabled={isUploading}
>
<i className="ri-close-line"></i>
</button>
</div>
))}
</div>
</div>
)}
</div>
{/* 多案件导入 */}
<div className="upload-item">
<div className="upload-item-header">
<i className="upload-item-icon ri-file-list-line"></i>
<span></span>
{uploadType === 'multiple' && multipleFiles.length > 0 && (
<Button
type="default"
size="small"
icon="ri-delete-bin-line"
onClick={() => handleClearFiles('multiple')}
disabled={isUploading}
>
</Button>
)}
</div>
<UploadArea
ref={multipleUploadRef}
onFilesSelected={handleMultipleFilesSelected}
className="custom-upload-area"
accept=".zip,.rar,.7z,.tar"
multiple={false}
icon="ri-folder-zip-line"
buttonText="选择文件"
mainText="点击或拖拽文件到此区域上传"
tipText={
<div className="upload-tip-error">
ziprar7ztar文件
</div>
}
disabled={uploadType === 'single' || isUploading}
/>
{/* 多案件文件列表 */}
{multipleFiles.length > 0 && (
<div className="mt-4 space-y-2">
<div className="text-sm font-medium text-gray-700">
{multipleFiles.length} :
</div>
<div className="max-h-32 overflow-y-auto space-y-1">
{multipleFiles.map((file) => (
<div key={file.id} className="flex items-center justify-between bg-gray-50 p-2 rounded">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<i className="ri-folder-zip-line text-orange-500"></i>
<span className="text-sm truncate">{file.name}</span>
<span className="text-xs text-gray-500">{formatFileSize(file.size)}</span>
</div>
<button
type="button"
onClick={() => handleRemoveFile(file.id, 'multiple')}
className="text-red-500 hover:text-red-700 p-1"
disabled={isUploading}
>
<i className="ri-close-line"></i>
</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* 完成按钮 */}
<div className="complete-button-container">
<button
type="button"
className="complete-button"
disabled={!canComplete}
onClick={handleCompleteUpload}
>
{isUploading || isSubmitting ? (
<>
<i className="ri-loader-4-line animate-spin mr-2"></i>
...
</>
) : (
"完成"
)}
</button>
</div>
</Form>
{/* 文件选择状态提示 */}
{!canComplete && !isUploading && (
<div className="text-center mt-4 text-gray-500 text-sm">
</div>
)}
{/* 上传进度提示 */}
{isUploading && (
<div className="text-center mt-4">
<div className="bg-blue-50 p-4 rounded-md border border-blue-100">
<div className="flex items-center justify-center text-blue-800 mb-2">
<i className="ri-loader-4-line animate-spin text-xl mr-2"></i>
<span className="font-medium">...</span>
</div>
<p className="text-sm text-blue-700">
{uploadType === 'single' ? singleFiles.length : multipleFiles.length}
</p>
</div>
</div>
)}
</div>
</div>
);
}
+5 -5
View File
@@ -1,16 +1,16 @@
import { Outlet } from "react-router-dom";
import {type MetaFunction} from "@remix-run/node";
import { Outlet } from "@remix-run/react";
import { type MetaFunction } from "@remix-run/node";
export const meta: MetaFunction = () => {
return [
{title: "文档列表 - 中国烟草AI合同及卷宗审核系统"},
{name: "documents", content: "文档列表,新增,修改"}
{ title: "文档列表 - 中国烟草AI合同及卷宗审核系统" },
{ name: "documents", content: "文档列表,新增,修改" }
]
}
export const handle = {
breadcrumb: "文档列表"
}
}
/**
* 文档列表路由布局
+1 -1
View File
@@ -306,7 +306,7 @@ export default function Home() {
</div>
<div className="ml-1">
<p className="text-sm font-medium mb-0">{userRole === 'developer' ? '系统管理员' : '普通用户'}</p>
<p className="text-xs text-gray-500 mb-0">{userRole === 'developer' ? '超级管理员' : '标准权限'}</p>
{/* <p className="text-xs text-gray-500 mb-0">{userRole === 'developer' ? '超级管理员' : '标准权限'}</p> */}
</div>
</div>
{/* 登出操作 */}
+45 -4
View File
@@ -1,9 +1,9 @@
import { useEffect } from "react";
import { useSearchParams } from "@remix-run/react";
import { type MetaFunction, type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { useSearchParams, Form } from "@remix-run/react";
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs, redirect } from "@remix-run/node";
import { OAuthClient } from "~/utils/oauth-client";
import { OAUTH_CONFIG } from "~/config/api-config";
import { getUserSession, getSession } from "~/root";
import { getUserSession, getSession, createUserSession } from "~/root";
import styles from "~/styles/pages/login.css?url";
export const links = () => [
@@ -39,6 +39,23 @@ export async function loader({ request }: LoaderFunctionArgs) {
});
}
// 处理表单提交的action函数
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "temp_admin_login") {
// 获取重定向目标
const session = await getSession(request);
const redirectTo = session.get("redirectTo") || "/";
// 创建管理员会话
return createUserSession(true, 'developer', redirectTo);
}
return null;
}
export default function Login() {
const [searchParams] = useSearchParams();
const error = searchParams.get("error");
@@ -131,10 +148,34 @@ export default function Login() {
</p>
</div>
</div>
{/* 临时管理员登录区域 */}
<div className="temp-login-section">
<div className="section-divider">
<span></span>
</div>
<Form method="post" className="temp-login-form">
<input type="hidden" name="intent" value="temp_admin_login" />
<button
type="submit"
className="temp-admin-login-button"
>
<i className="ri-admin-line"></i>
</button>
<div className="temp-login-tips">
<p>
<i className="ri-alert-line"></i>
使
</p>
</div>
</Form>
</div>
</div>
<div className="login-footer">
<p>© 2024 </p>
<p>© 2025 </p>
</div>
</div>
</div>
+660 -236
View File
File diff suppressed because it is too large Load Diff
+275
View File
@@ -0,0 +1,275 @@
/* 交叉评查上传页面样式 */
/* 步骤指示器样式 */
.steps-indicator {
display: flex;
justify-content: center;
align-items: center;
margin: 2rem 0 3rem 0;
position: relative;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
flex: 1;
}
.step-item:not(:last-child)::after {
content: '';
position: absolute;
top: 25px;
left: 75px;
width: calc(100% - 50px);
height: 2px;
background-color: #e5e7eb;
z-index: 1;
}
.step-item.completed:not(:last-child)::after {
background-color: #059669;
}
.step-circle {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.75rem;
transition: all 0.3s ease;
position: relative;
z-index: 3;
}
.step-circle.active {
background-color: #059669;
color: white;
box-shadow: 0 0 0 4px rgba(5, 150, 105, 0.2);
}
.step-circle.inactive {
background-color: #e5e7eb;
color: #6b7280;
}
.step-label {
font-size: 0.9rem;
color: #374151;
font-weight: 500;
text-align: center;
white-space: nowrap;
}
/* 案卷类型选择器样式 */
.case-type-selector {
display: flex;
justify-content: center;
margin-bottom: 3rem;
}
.case-type-options {
display: flex;
background-color: #f3f4f6;
border-radius: 8px;
padding: 4px;
gap: 4px;
}
.case-type-option {
padding: 0.75rem 2rem;
border-radius: 6px;
font-weight: 500;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
background: transparent;
}
.case-type-option.active {
background-color: #059669;
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.case-type-option.inactive {
color: #6b7280;
background-color: transparent;
}
.case-type-option:hover:not(.active) {
background-color: #e5e7eb;
color: #374151;
}
/* 上传区域容器样式 */
.upload-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
margin-bottom: 3rem;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
}
.upload-item {
display: flex;
flex-direction: column;
align-items: center;
}
.upload-item-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
color: #374151;
font-weight: 500;
font-size: 1rem;
}
.upload-item-icon {
font-size: 1.5rem;
color: #3b82f6;
}
/* 覆盖UploadArea组件的默认样式 */
.custom-upload-area.upload-area {
width: 100%;
max-width: 350px;
min-height: 200px;
border: 2px dashed #d1d5db !important;
border-radius: 12px !important;
background-color: #fafafa !important;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
padding: 2rem 1.5rem !important;
}
.custom-upload-area.upload-area:hover {
border-color: #059669 !important;
background-color: #f0fdf4 !important;
}
.custom-upload-area.upload-area.dragover {
border-color: #059669 !important;
background-color: #f0fdf4 !important;
transform: scale(1.02);
}
.custom-upload-area .upload-icon {
font-size: 2.5rem !important;
color: #9ca3af;
margin-bottom: 1rem;
}
.custom-upload-area .upload-text {
color: #6b7280;
font-size: 0.95rem;
margin-bottom: 1rem;
text-align: center;
}
.upload-tip-error {
color: #dc2626;
font-size: 0.85rem;
text-align: center;
margin-top: 1rem;
font-weight: 500;
}
/* 完成按钮样式 */
.complete-button-container {
display: flex;
justify-content: center;
margin-top: 2rem;
}
.complete-button {
background-color: #059669;
color: white;
border: none;
border-radius: 8px;
padding: 0.875rem 3rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.complete-button:hover {
background-color: #047857;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.complete-button:active {
transform: translateY(0);
}
.complete-button:disabled {
background-color: #9ca3af;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* 响应式设计 */
@media (max-width: 768px) {
.upload-section {
grid-template-columns: 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.steps-indicator {
margin: 1.5rem 0 2rem 0;
}
.step-circle {
width: 40px;
height: 40px;
font-size: 1rem;
}
.step-label {
font-size: 0.8rem;
}
.case-type-option {
padding: 0.625rem 1.5rem;
font-size: 0.9rem;
}
.custom-upload-area.upload-area {
max-width: 100%;
min-height: 180px;
padding: 1.5rem 1rem !important;
}
.complete-button {
padding: 0.75rem 2.5rem;
font-size: 0.95rem;
}
.step-item:not(:last-child)::after {
left: 60px;
width: calc(100% - 20px);
}
}
+110
View File
@@ -207,4 +207,114 @@
.login-footer {
color: #6b7280;
}
}
/* 临时管理员登录样式 */
.temp-login-section {
margin-top: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.section-divider {
position: relative;
width: 100%;
text-align: center;
margin: 1rem 0;
}
.section-divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background-color: #e5e7eb;
z-index: 1;
}
.section-divider span {
background-color: white;
color: #9ca3af;
padding: 0 1rem;
font-size: 0.875rem;
position: relative;
z-index: 2;
}
.temp-login-form {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.temp-admin-login-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
padding: 0.875rem 1.5rem;
background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.temp-admin-login-button:hover {
background: linear-gradient(135deg, #f97316 0%, #f59e0b 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
}
.temp-admin-login-button:active {
transform: translateY(0);
}
.temp-admin-login-button i {
font-size: 1.25rem;
opacity: 0.9;
}
.temp-login-tips {
text-align: center;
color: #f59e0b;
font-size: 0.8rem;
font-weight: 500;
}
.temp-login-tips p {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin: 0;
}
.temp-login-tips i {
font-size: 0.95rem;
color: #f59e0b;
}
/* 暗色主题下的临时登录样式 */
@media (prefers-color-scheme: dark) {
.section-divider::before {
background-color: #374151;
}
.section-divider span {
background-color: #1f2937;
color: #6b7280;
}
}
+85
View File
@@ -0,0 +1,85 @@
import { CHAT_CONFIG } from '../config/chat';
/**
* 测试Dify API连接
* 这个文件可以用来调试和测试前端直接调用Dify API的功能
*/
export const testDifyConnection = async () => {
console.log('🔧 Dify Configuration:', {
apiUrl: CHAT_CONFIG.API_URL,
appId: CHAT_CONFIG.APP_ID,
hasApiKey: !!CHAT_CONFIG.API_KEY,
apiKeyPreview: CHAT_CONFIG.API_KEY ? `${CHAT_CONFIG.API_KEY.substring(0, 10)}...` : 'No API Key',
});
if (!CHAT_CONFIG.API_URL || !CHAT_CONFIG.APP_ID || !CHAT_CONFIG.API_KEY) {
console.error('❌ Dify配置不完整,请检查环境变量');
return false;
}
const user = CHAT_CONFIG.generateUserId();
console.log('👤 Generated User ID:', user);
try {
// 测试获取应用参数
console.log('📋 测试获取应用参数...');
const response = await fetch(`${CHAT_CONFIG.API_URL}/parameters`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${CHAT_CONFIG.API_KEY}`,
},
});
if (!response.ok) {
console.error('❌ 获取应用参数失败:', response.status, response.statusText);
const errorText = await response.text();
console.error('错误详情:', errorText);
return false;
}
const data = await response.json();
console.log('✅ 成功获取应用参数:', data);
// 测试获取会话列表
console.log('💬 测试获取会话列表...');
const params = new URLSearchParams({
user,
limit: '10',
first_id: '',
});
const conversationsResponse = await fetch(`${CHAT_CONFIG.API_URL}/conversations?${params}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${CHAT_CONFIG.API_KEY}`,
},
});
if (!conversationsResponse.ok) {
console.error('❌ 获取会话列表失败:', conversationsResponse.status, conversationsResponse.statusText);
const errorText = await conversationsResponse.text();
console.error('错误详情:', errorText);
return false;
}
const conversationsData = await conversationsResponse.json();
console.log('✅ 成功获取会话列表:', conversationsData);
return true;
} catch (error) {
console.error('❌ 测试Dify连接时发生错误:', error);
return false;
}
};
/**
* 在浏览器控制台中可以调用这个函数进行测试
* 使用方法:
* 1. 打开浏览器控制台
* 2. 输入: window.testDify()
* 3. 查看输出结果
*/
if (typeof window !== 'undefined') {
(window as any).testDify = testDifyConnection;
}