Files
leaudit-platform-frontend/app/routes/documents.edit.tsx
T

726 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from "react";
import { useLoaderData, useActionData, useNavigate, Form } from "@remix-run/react";
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 documentEditStyles from "~/styles/pages/documents_edit.css?url";
import { getDocument, updateDocument } from "~/api/files/documents";
import { getDocumentTypes } from "~/api/document-types/document-types";
import { FileTag } from "~/components/ui/FileTag";
import { toastService } from "~/components/ui/Toast";
import { Document, Page , pdfjs } from "react-pdf";
import { DOCUMENT_URL } from "~/api/axios-client";
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
export function links() {
return [{ rel: "stylesheet", href: documentEditStyles }];
}
export const meta: MetaFunction = () => {
return [
{ title: "修改文档 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "修改文档信息,包括文档类型、编号、状态和备注信息等" }
];
};
export const handle = {
breadcrumb: "文档编辑"
};
// 定义action返回数据类型
export interface ActionData {
success?: boolean;
error?: string;
fieldErrors?: {
document_number?: string;
audit_status?: string;
remark?: string;
general?: string;
};
values?: Record<string, string>;
}
// 文档审核状态定义
enum DocumentAuditStatus {
FAIL = -1,
PASS = 1,
WAITING = 0,
PROCESSING = 2
}
// 文档状态对应的中文标签
const STATUS_LABELS: Record<DocumentAuditStatus, string> = {
[DocumentAuditStatus.FAIL]: "不通过",
[DocumentAuditStatus.WAITING]: "待审核",
[DocumentAuditStatus.PASS]: "通过",
[DocumentAuditStatus.PROCESSING]: "审核中"
};
// 文档状态样式配置
const STATUS_STYLES: Record<number, { color: string; icon: string }> = {
[-1]: { color: "red", icon: "ri-close-line" },
[0]: { color: "blue", icon: "ri-time-line" },
[1]: { color: "green", icon: "ri-check-line" },
[2]: { color: "yellow", icon: "ri-alert-line" },
[3]: { color: "purple", icon: "ri-search-line" }
};
// 格式化文件大小
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];
}
// Loader函数
export async function loader({ request }: LoaderFunctionArgs) {
try {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
if (!userInfo?.user_id) {
throw new Response("用户身份验证失败", { status: 401 });
}
const userId = userInfo.user_id.toString();
// 从URL查询参数获取文档ID
const url = new URL(request.url);
const id = url.searchParams.get("id");
if (!id) {
throw new Response("缺少文档ID", { status: 400 });
}
// 并行获取文档详情和文档类型列表
const [documentResponse, documentTypesResponse] = await Promise.all([
getDocument(id, userId, frontendJWT),
getDocumentTypes({ pageSize: 500 }, frontendJWT)
]);
if (documentResponse.error) {
throw new Response(documentResponse.error, { status: documentResponse.status || 404 });
}
if (documentTypesResponse.error) {
console.error("获取文档类型列表失败:", documentTypesResponse.error);
}
return Response.json({
document: documentResponse.data,
documentTypes: documentTypesResponse.data?.types || [],
frontendJWT: frontendJWT
});
} catch (error) {
console.error("加载文档数据失败:", error);
throw new Response("加载文档数据失败", { status: 500 });
}
}
// Action函数处理表单提交
export async function action({ request }: ActionFunctionArgs) {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
if (!userInfo?.user_id) {
return Response.json({ error: "用户身份验证失败" }, { status: 401 });
}
const userId = userInfo.user_id.toString();
// 从URL查询参数获取文档ID
const url = new URL(request.url);
const id = url.searchParams.get("id");
if (!id) {
return Response.json({ error: "缺少文档ID" }, { status: 400 });
}
try {
const formData = await request.formData();
// 从表单数据中提取字段
// const type = formData.get("type_id") as string;
const documentNumber = formData.get("document_number") as string;
const auditStatus = parseInt(formData.get("audit_status") as string);
const isTest = formData.get("is_test_document") === "on";
const remark = formData.get("remark") as string;
// 表单验证
const errors: ActionData["fieldErrors"] = {};
if (isNaN(auditStatus)) {
errors.audit_status = "审核状态必须选择";
}
if (Object.keys(errors).length > 0) {
return Response.json({
fieldErrors: errors,
values: Object.fromEntries(formData) as Record<string, string>
}, { status: 400 });
}
// 更新文档
const updateResponse = await updateDocument(id, {
// type,
documentNumber,
auditStatus,
isTest,
remark
}, userId, frontendJWT);
if (updateResponse.error) {
console.error('更新文档失败:', updateResponse.error);
return Response.json({
error: updateResponse.error,
fieldErrors: {
general: "更新文档失败,请检查提交的数据是否正确"
},
values: Object.fromEntries(formData) as Record<string, string>
}, { status: updateResponse.status || 500 });
}
toastService.success('更新文档成功');
return redirect("/documents");
} catch (error) {
console.error("更新文档失败2:", error);
return Response.json({
error: "更新文档失败",
fieldErrors: {
general: error instanceof Error ? error.message : "发生未知错误"
}
}, { status: 500 });
}
}
// 文档编辑页面组件
export default function DocumentEdit() {
const { document: documentData, documentTypes } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigate = useNavigate();
const [numPages, setNumPages] = useState(0);
const [loadError, setLoadError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
// 表单状态管理 - 使用受控组件
const [formValues, setFormValues] = useState({
documentNumber: documentData.documentNumber || "",
auditStatus: documentData.auditStatus,
isTest: documentData.isTest || false,
remark: documentData.remark || ""
});
// 表单验证错误状态
const [formErrors, setFormErrors] = useState<{
documentNumber?: string;
auditStatus?: string;
remark?: string;
general?: string;
}>({});
// 字段是否被触摸过(用于确定何时显示错误)
const [touchedFields, setTouchedFields] = useState({
documentNumber: false,
auditStatus: false,
remark: false
});
// 从 actionData 初始化表单错误
useEffect(() => {
if (actionData?.fieldErrors) {
// general 是loader的时候返回的错误信息
setFormErrors(actionData.fieldErrors);
}
}, [actionData]);
// 验证表单字段
const validateField = (field: string, value: string | number | undefined): string => {
switch (field) {
case 'auditStatus': {
const statusValue = typeof value === 'string' ? parseInt(value) : value;
return statusValue === undefined || isNaN(statusValue as number) ? "审核状态必须选择" : "";
}
default:
return "";
}
};
// 处理字段改变
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
if (type === 'checkbox') {
const checked = (e.target as HTMLInputElement).checked;
setFormValues(prev => ({
...prev,
[name === 'is_test_document' ? 'isTest' : name]: checked
}));
} else {
setFormValues(prev => ({
...prev,
[name === 'document_number' ? 'documentNumber' :
name === 'audit_status' ? 'auditStatus' : name]:
name === 'audit_status' ? (parseInt(value)) : value
}));
}
// 标记字段为已触摸
if (name === 'document_number' || name === 'audit_status' || name === 'remark') {
const fieldName = name === 'document_number' ? 'documentNumber' :
name === 'audit_status' ? 'auditStatus' : name;
setTouchedFields(prev => ({
...prev,
[fieldName]: true
}));
}
// 实时验证
if (name === 'audit_status') {
const statusValue = parseInt(value);
const error = validateField('auditStatus', statusValue);
setFormErrors(prev => ({
...prev,
auditStatus: error
}));
}
};
// 处理表单提交前验证
const handleBeforeSubmit = (e: React.FormEvent) => {
// 标记所有字段为已触摸
setTouchedFields({
documentNumber: true,
auditStatus: true,
remark: true
});
// 验证所有字段
const errors = {
auditStatus: validateField('auditStatus', formValues.auditStatus)
};
setFormErrors(errors);
// 如果有错误,阻止提交
if (errors.auditStatus) {
toastService.error('审核状态不能为空');
e.preventDefault();
}
};
const onDocumentLoadSuccess = ({numPages}: {numPages: number}) => {
setNumPages(numPages);
// console.log('文档加载成功', numPages);
}
const renderDocumentContent = () => {
return (
<div className="preview-content relative overflow-y-auto max-h-[1000px]">
<Document
file={`/api/pdf-proxy?path=${encodeURIComponent(documentData.path)}`}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={(error) => {
console.error("PDF加载错误:", error);
setLoadError("PDF文档加载失败:" + (error.message || "未知错误"));
}}
className="flex flex-col items-center w-full"
error={<div className="text-red-500">PDF文档加载失败</div>}
noData={<div></div>}
loading={<div className="text-center py-10">PDF加载中...</div>}
>
{renderAllPages()}
</Document>
</div>
)
}
const renderAllPages = () => {
// 如果还没有获取到PDF总页数,返回null
if (!numPages) return null;
// 用于存储所有页面组件的数组
const pages = [];
const styles = {
pageContainer: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
width: '100%',
position: 'relative' as const,
}
};
// 遍历每一页,生成对应的页面组件
for (let i = 1; i <= numPages; i++) {
// 为每一页创建组件
pages.push(
<div key={i} id={`page-${i}`} style={styles.pageContainer}>
{/* 页码标识,显示在页面上方 */}
<div className="text-center text-gray-500 text-sm mb-2"> {i} </div>
{/* 页面容器,应用缩放变换,设置相对定位用于放置评查点高亮 */}
<div
className="page-wrapper flex justify-center"
style={{
position: 'relative',
maxWidth: '100%',
}}
>
{/* 渲染PDF页面组件 */}
<Page
pageNumber={i} // 当前页码
renderTextLayer={false} // 停用文本层,使文本可选择
renderAnnotationLayer={false} // 停用注释层,显示PDF内置注释
className="border border-gray-300 shadow-md" // 添加边框和阴影样式
/>
</div>
</div>
);
}
// 返回所有页面组件数组
return pages;
};
// 定义类型
interface DocType {
id: string | number;
name: string;
}
// 获取文档类型名称
const getDocumentTypeName = (typeId: string): string => {
const docType = documentTypes.find((type: DocType) => type.id.toString() === typeId);
return docType ? docType.name : "未知类型";
};
// 渲染状态徽章
const renderStatusBadge = (status: number) => {
const style = STATUS_STYLES[status] || STATUS_STYLES[0];
const label = STATUS_LABELS[status as DocumentAuditStatus] || STATUS_LABELS[DocumentAuditStatus.WAITING];
return (
<span className={`status-badge bg-${style.color}-100 text-${style.color}-800`}>
<i className={`${style.icon} mr-1`}></i>
{label}
</span>
);
};
// 下载文档
const downloadDocument = async () => {
try {
// 使用 PDF 代理路由获取文件,自动添加 JWT 认证(默认行为是下载)
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(documentData.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';
a.href = blobUrl;
// 从路径中获取文件名
const fileName = documentData.path.split('/').pop() || documentData.name;
a.download = decodeURIComponent(fileName);
document.body.appendChild(a);
a.click();
// 清理
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}, 100);
toastService.success('文件下载成功');
} catch (error) {
console.error('下载文件失败:', error);
toastService.error(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
// 在新窗口打开文档预览
const openPreview = () => {
// 使用 PDF 代理路由,自动添加 JWT 认证,添加 preview 参数实现预览
const previewUrl = `/api/pdf-proxy?path=${encodeURIComponent(documentData.path)}&preview=true`;
window.open(previewUrl, '_blank');
};
return (
<div className="document-edit-page">
{/* 页面头部 */}
<div className="page-header">
<h2 className="page-title"></h2>
<div>
<Button
type="default"
icon="ri-arrow-left-line"
onClick={() => navigate("/documents/list")}
className="mr-2"
>
</Button>
<Button
type="primary"
icon="ri-save-line"
form="edit-form"
>
</Button>
</div>
</div>
{/* 文档信息 */}
<Card className="mb-4">
<div className="document-info">
<div className="document-icon">
<FileTag
extension={documentData.fileType}
showIcon={true}
showText={false}
showBackground={false}
size="lg"
/>
</div>
<div className="document-details">
<div className="document-name">{documentData.name}</div>
<div className="document-meta">
<div className="meta-item">
<i className="ri-file-list-line"></i>
<span>{getDocumentTypeName(documentData.type)}</span>
</div>
<div className="meta-item">
<i className="ri-time-line"></i>
<span>{documentData.uploadTime}</span>
</div>
<div className="meta-item">
<i className="ri-hard-drive-line"></i>
<span>{formatFileSize(documentData.size)}</span>
</div>
<div className="meta-item">
{renderStatusBadge(documentData.auditStatus)}
</div>
</div>
</div>
</div>
<div className="alert alert-info mb-4">
<i className="ri-information-line mr-2"></i>
</div>
{/* 错误提示 */}
{formErrors.general && (
<div className="general-error mb-4">
<i className="ri-error-warning-line mr-2"></i>
{formErrors.general}
</div>
)}
<Form id="edit-form" method="post" ref={formRef} onSubmit={handleBeforeSubmit}>
<div className="grid grid-cols-2 gap-6">
<div className="form-group">
<label htmlFor="type-id" className="form-label"> <span className="text-red-500">*</span></label>
<select
id="type-id"
name="type_id"
className="form-select"
value={documentData.type}
disabled={true}
required
>
{documentTypes.map((type: DocType) => (
<option key={type.id} value={type.id}>{type.name}</option>
))}
</select>
<div className="text-sm text-secondary mt-1"></div>
</div>
<div className="form-group">
<label htmlFor="document-number" className="form-label"></label>
<input
type="text"
id="document-number"
name="document_number"
className="form-input"
placeholder="请输入合同编号、许可证号等"
value={formValues.documentNumber}
onChange={handleChange}
/>
<div className="text-sm text-secondary mt-1"></div>
</div>
<div className="form-group">
<label htmlFor="audit-status" className="form-label"> <span className="text-red-500">*</span></label>
<select
id="audit-status"
name="audit_status"
className={`form-select ${touchedFields.auditStatus && formErrors.auditStatus ? 'error' : ''}`}
value={formValues.auditStatus}
onChange={handleChange}
required
>
{/* <option value='全部'>请选择审核状态</option> */}
<option value={DocumentAuditStatus.WAITING}>{STATUS_LABELS[DocumentAuditStatus.WAITING]}</option>
<option value={DocumentAuditStatus.PROCESSING}>{STATUS_LABELS[DocumentAuditStatus.PROCESSING]}</option>
<option value={DocumentAuditStatus.PASS}>{STATUS_LABELS[DocumentAuditStatus.PASS]}</option>
<option value={DocumentAuditStatus.FAIL}>{STATUS_LABELS[DocumentAuditStatus.FAIL]}</option>
</select>
<div className="text-sm text-secondary mt-1"></div>
{touchedFields.auditStatus && formErrors.auditStatus && (
<div className="text-red-500 text-sm mt-1">{formErrors.auditStatus}</div>
)}
</div>
<div className="form-group">
<div className="form-label"></div>
<div className="flex items-center mt-2">
<label className="switch mr-2" htmlFor="is-test-document">
<input
type="checkbox"
id="is-test-document"
name="is_test_document"
checked={formValues.isTest}
onChange={handleChange}
/>
<span className="slider"></span>
<span className="sr-only"></span>
</label>
<span></span>
</div>
</div>
<div className="form-group col-span-2">
<label htmlFor="remark" className="form-label"></label>
<textarea
id="remark"
name="remark"
className="form-textarea"
placeholder="可输入文档的相关描述或备注信息"
rows={3}
value={formValues.remark}
onChange={handleChange}
></textarea>
</div>
</div>
</Form>
</Card>
{/* 文档预览 */}
{ false && <Card
title="文档预览"
className="mb-4"
>
<div className="document-preview">
<div className="preview-toolbar">
<div className="flex items-center">
<i className={`ri-file-${documentData.fileType}-line text-${documentData.fileType === 'pdf' ? 'red' : 'blue'}-500 mr-1`}></i>
<span>{documentData.name}</span>
</div>
<div>
<Button
type="default"
size="small"
icon="ri-download-line"
className="mr-2"
onClick={downloadDocument}
>
</Button>
<Button
type="primary"
size="small"
icon="ri-external-link-line"
onClick={openPreview}
>
</Button>
</div>
</div>
{/* 预览窗口 */}
{loadError ? (
<div className="text-red-500">
{loadError}
</div>
) : (
renderDocumentContent()
)}
</div>
</Card>
}
{/* 修改历史 */}
<Card title="修改历史" className="hidden">
<div className="history-timeline">
{[
{
time: "2023-10-15 15:30",
user: "系统",
action: "创建了此文档",
details: `首次上传文档,文档类型:${getDocumentTypeName(documentData.type)},状态:待审核`
},
{
time: "2023-10-15 16:45",
user: "张三",
action: "启动了文档审核",
details: "状态由'待审核'变更为'审核中'"
},
{
time: "2023-10-15 17:20",
user: "系统",
action: "完成了文档审核",
details: "状态由'审核中'变更为'通过',未发现问题"
},
{
time: "2023-10-16 09:10",
user: "李四",
action: "修改了文档属性",
details: "添加了备注信息,完善了文档编号"
}
].map((item, index) => (
<div className="timeline-item" key={`${item.time}-${index}`}>
<div className="timeline-time">{item.time}</div>
<div className="timeline-content">
<div><strong>{item.user}</strong> {item.action}</div>
<div className="text-xs text-secondary mt-1">{item.details}</div>
</div>
</div>
))}
</div>
</Card>
</div>
);
}
// 错误边界
export function ErrorBoundary() {
return (
<div className="error-container">
<h1 className="text-xl font-bold text-red-500 mb-4"></h1>
<p className="mb-4">ID是否正确</p>
<Button
type="primary"
to="/documents/list"
>
</Button>
</div>
);
}