all in
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* DOCX 文档解析工具
|
||||
* 使用 docxtemplater 从 docx 文件中提取占位符
|
||||
*/
|
||||
|
||||
import PizZip from 'pizzip';
|
||||
import type { PlaceholderField, PlaceholderSchema } from '~/types/contract-draft';
|
||||
import { DOCUMENT_URL } from '../axios-client';
|
||||
|
||||
/**
|
||||
* 从 docx 文件中提取占位符
|
||||
* @param filePath MinIO 文件路径(相对路径,如 contract-template/买卖/买卖合同范本.docx)
|
||||
* @returns 占位符列表
|
||||
*/
|
||||
export async function extractPlaceholdersFromDocx(
|
||||
filePath: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
// 构建完整的 MinIO URL
|
||||
const fileUrl = `${DOCUMENT_URL}${filePath}`;
|
||||
|
||||
// 从 MinIO 下载文件
|
||||
const response = await fetch(fileUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载文件失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 获取文件内容(ArrayBuffer)
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
// 转换为 Buffer(PizZip 需要)
|
||||
const content = Buffer.from(arrayBuffer);
|
||||
|
||||
// 使用 PizZip 解压
|
||||
const zip = new PizZip(content);
|
||||
|
||||
// 读取 document.xml 文件内容(不使用 docxtemplater,避免格式化文本的标签分割问题)
|
||||
const documentXml = zip.file('word/document.xml');
|
||||
if (!documentXml) {
|
||||
throw new Error('无法找到 word/document.xml 文件');
|
||||
}
|
||||
|
||||
// 获取 XML 文本内容
|
||||
const xmlContent = documentXml.asText();
|
||||
// console.log('[DOCX Parser] 文档 XML 长度:', xmlContent.length);
|
||||
|
||||
// 移除所有 XML 标签,只保留纯文本
|
||||
const fullText = xmlContent.replace(/<[^>]+>/g, '');
|
||||
// console.log('[DOCX Parser] 文档文本长度:', fullText.length);
|
||||
|
||||
// 使用正则表达式提取所有 {{...}} 占位符
|
||||
const placeholderRegex = /\{\{([^}]+)\}\}/g;
|
||||
const matches = fullText.matchAll(placeholderRegex);
|
||||
|
||||
// 去重并返回
|
||||
const placeholders = new Set<string>();
|
||||
for (const match of matches) {
|
||||
const placeholder = match[1].trim();
|
||||
if (placeholder) {
|
||||
placeholders.add(placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
const placeholderList = Array.from(placeholders);
|
||||
// console.log('[DOCX Parser] 提取到的占位符:', placeholderList);
|
||||
|
||||
return placeholderList;
|
||||
} catch (error) {
|
||||
console.error('[DOCX Parser] 解析文档失败:', error);
|
||||
throw new Error(`解析文档失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从占位符列表生成默认的 PlaceholderSchema
|
||||
* @param placeholders 占位符列表
|
||||
* @returns PlaceholderSchema
|
||||
*/
|
||||
export function generateDefaultSchema(
|
||||
placeholders: string[]
|
||||
): PlaceholderSchema {
|
||||
// 按名称自动分组
|
||||
const fields: PlaceholderField[] = placeholders.map(placeholder => {
|
||||
// 根据占位符名称推测分组
|
||||
let group = '基本信息';
|
||||
if (placeholder.includes('甲方') || placeholder.includes('partyA')) {
|
||||
group = '甲方信息';
|
||||
} else if (placeholder.includes('乙方') || placeholder.includes('partyB')) {
|
||||
group = '乙方信息';
|
||||
} else if (
|
||||
placeholder.includes('金额') ||
|
||||
placeholder.includes('价格') ||
|
||||
placeholder.includes('数量') ||
|
||||
placeholder.includes('amount')
|
||||
) {
|
||||
group = '合同条款';
|
||||
} else if (
|
||||
placeholder.includes('日期') ||
|
||||
placeholder.includes('时间') ||
|
||||
placeholder.includes('date')
|
||||
) {
|
||||
group = '日期信息';
|
||||
}
|
||||
|
||||
// 根据名称推测字段类型
|
||||
let type: 'text' | 'number' | 'date' | 'textarea' = 'text';
|
||||
if (
|
||||
placeholder.includes('金额') ||
|
||||
placeholder.includes('数量') ||
|
||||
placeholder.includes('价格') ||
|
||||
placeholder.includes('amount') ||
|
||||
placeholder.includes('price') ||
|
||||
placeholder.includes('quantity')
|
||||
) {
|
||||
type = 'number';
|
||||
} else if (
|
||||
placeholder.includes('日期') ||
|
||||
placeholder.includes('时间') ||
|
||||
placeholder.includes('date') ||
|
||||
placeholder.includes('time')
|
||||
) {
|
||||
type = 'date';
|
||||
} else if (
|
||||
placeholder.includes('地址') ||
|
||||
placeholder.includes('说明') ||
|
||||
placeholder.includes('备注') ||
|
||||
placeholder.includes('address') ||
|
||||
placeholder.includes('description') ||
|
||||
placeholder.includes('remark')
|
||||
) {
|
||||
type = 'textarea';
|
||||
}
|
||||
|
||||
// 根据名称推测是否必填
|
||||
const required = !placeholder.includes('可选') && !placeholder.includes('optional');
|
||||
|
||||
return {
|
||||
key: placeholder,
|
||||
label: placeholder, // 使用占位符本身作为标签
|
||||
type,
|
||||
required,
|
||||
group
|
||||
};
|
||||
});
|
||||
|
||||
return { fields };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 合同起草服务
|
||||
* 处理草稿创建、更新等业务逻辑
|
||||
* 包含文件复制功能(预留 MinIO 实现)
|
||||
*/
|
||||
|
||||
import { postgrestGet, postgrestPost, postgrestPut, postgrestDelete } from '~/api/postgrest-client';
|
||||
import type { DraftedContract, CreateDraftRequest } from '~/types/contract-draft';
|
||||
|
||||
/**
|
||||
* 生成草稿文件路径
|
||||
* @param templateFilePath 模板文件路径
|
||||
* @param userId 用户ID
|
||||
* @param templateId 模板ID
|
||||
* @returns 草稿文件路径
|
||||
*/
|
||||
export function generateDraftFilePath(
|
||||
templateFilePath: string,
|
||||
userId: number,
|
||||
templateId: number
|
||||
): string {
|
||||
const timestamp = Date.now();
|
||||
const fileExtension = templateFilePath.split('.').pop() || 'docx';
|
||||
const newFileName = `contract_${templateId}_${userId}_${timestamp}.${fileExtension}`;
|
||||
return `drafts/${newFileName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制 MinIO 文件(预留实现)
|
||||
* @param sourceFilePath 源文件路径
|
||||
* @param targetFilePath 目标文件路径
|
||||
* @param bucket Bucket 名称
|
||||
* @returns 是否成功
|
||||
*
|
||||
* TODO: 实现 MinIO 文件复制逻辑
|
||||
* 1. 安装 minio SDK: npm install minio
|
||||
* 2. 创建 MinIO 客户端实例
|
||||
* 3. 调用 copyObject 方法复制文件
|
||||
*
|
||||
* 参考实现:
|
||||
* ```typescript
|
||||
* import { Client } from 'minio';
|
||||
*
|
||||
* const minioClient = new Client({
|
||||
* endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
* port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
* useSSL: false,
|
||||
* accessKey: process.env.MINIO_ACCESS_KEY || '',
|
||||
* secretKey: process.env.MINIO_SECRET_KEY || ''
|
||||
* });
|
||||
*
|
||||
* await minioClient.copyObject(
|
||||
* bucket,
|
||||
* targetFilePath,
|
||||
* `/${bucket}/${sourceFilePath}`,
|
||||
* null
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export async function copyMinioFile(
|
||||
sourceFilePath: string,
|
||||
targetFilePath: string,
|
||||
bucket: string = 'docauditai'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
console.log('[Draft Service] 复制文件(预留实现):', {
|
||||
sourceFilePath,
|
||||
targetFilePath,
|
||||
bucket
|
||||
});
|
||||
|
||||
// TODO: 实现 MinIO 文件复制
|
||||
// 当前为临时实现,直接返回成功
|
||||
console.warn('[Draft Service] ⚠️ 文件复制功能尚未实现,请实施 MinIO 集成');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Draft Service] 文件复制失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建起草合同记录
|
||||
* @param request 创建请求
|
||||
* @param userId 用户ID
|
||||
* @param draftFilePath 可选:草稿文件路径(如果不提供,使用模板路径)
|
||||
* @returns 创建的记录
|
||||
*
|
||||
* 使用场景:
|
||||
* 1. 不传 draftFilePath:直接使用模板文件路径,在原模板上编辑
|
||||
* 2. 传 draftFilePath:使用复制后的文件路径(由文件复制接口提供)
|
||||
*/
|
||||
export async function createDraftContract(
|
||||
request: CreateDraftRequest,
|
||||
userId: number,
|
||||
draftFilePath?: string,
|
||||
jwt?: string
|
||||
): Promise<DraftedContract> {
|
||||
try {
|
||||
// 1. 查询模板信息
|
||||
const templateResponse = await postgrestGet('contract_templates', {
|
||||
select: 'id,file_path',
|
||||
filter: { id: `eq.${request.templateId}` },
|
||||
token: jwt
|
||||
});
|
||||
|
||||
if (!templateResponse.data || (Array.isArray(templateResponse.data) && templateResponse.data.length === 0)) {
|
||||
throw new Error('模板不存在');
|
||||
}
|
||||
|
||||
const template = Array.isArray(templateResponse.data) ? templateResponse.data[0] : templateResponse.data;
|
||||
|
||||
// 2. 确定使用的文件路径
|
||||
// 如果没有提供草稿路径,直接使用模板路径(适合直接编辑模板的场景)
|
||||
// 如果提供了草稿路径,使用复制后的文件路径
|
||||
const finalFilePath = draftFilePath || template.file_path;
|
||||
|
||||
console.log('[Draft Service] 创建草稿:', {
|
||||
templateId: request.templateId,
|
||||
templatePath: template.file_path,
|
||||
draftPath: draftFilePath,
|
||||
finalPath: finalFilePath,
|
||||
mode: draftFilePath ? '使用复制文件' : '直接使用模板文件'
|
||||
});
|
||||
|
||||
// 3. 创建草稿记录
|
||||
const insertResponse = await postgrestPost('drafted_contracts', {
|
||||
body: {
|
||||
template_id: request.templateId,
|
||||
file_path: finalFilePath,
|
||||
title: request.title,
|
||||
placeholder_values: {},
|
||||
status: 'draft',
|
||||
created_by: userId
|
||||
},
|
||||
select: '*',
|
||||
token: jwt
|
||||
});
|
||||
|
||||
if (!insertResponse.data) {
|
||||
throw new Error('创建草稿记录失败');
|
||||
}
|
||||
|
||||
const draft = Array.isArray(insertResponse.data) ? insertResponse.data[0] : insertResponse.data;
|
||||
return draft as DraftedContract;
|
||||
} catch (error) {
|
||||
console.error('[Draft Service] 创建草稿失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除草稿记录
|
||||
* @param draftId 草稿ID
|
||||
* @param userId 用户ID
|
||||
* @param jwt JWT token
|
||||
*/
|
||||
export async function deleteDraft(
|
||||
draftId: number,
|
||||
userId: number,
|
||||
jwt?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await postgrestDelete('drafted_contracts', {
|
||||
filter: {
|
||||
id: `eq.${draftId}`,
|
||||
created_by: `eq.${userId}` // 确保只能删除自己的草稿
|
||||
},
|
||||
token: jwt
|
||||
});
|
||||
|
||||
console.log('[Draft Service] 草稿已删除:', draftId);
|
||||
} catch (error) {
|
||||
console.error('[Draft Service] 删除草稿失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取草稿详情
|
||||
* @param draftId 草稿ID
|
||||
* @param userId 用户ID
|
||||
* @returns 草稿记录
|
||||
*/
|
||||
export async function getDraftById(
|
||||
draftId: number,
|
||||
userId: number,
|
||||
jwt?: string
|
||||
): Promise<DraftedContract | null> {
|
||||
try {
|
||||
const response = await postgrestGet('drafted_contracts', {
|
||||
select: '*',
|
||||
filter: {
|
||||
id: `eq.${draftId}`,
|
||||
created_by: `eq.${userId}`
|
||||
},
|
||||
token: jwt
|
||||
});
|
||||
|
||||
if (!response.data || (Array.isArray(response.data) && response.data.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const draft = Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
return draft as DraftedContract;
|
||||
} catch (error) {
|
||||
console.error('[Draft Service] 获取草稿失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 占位符表单组件
|
||||
* 用于合同起草时填写占位符值
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { PlaceholderSchema } from '~/types/contract-draft';
|
||||
|
||||
interface PlaceholderFormProps {
|
||||
schema: PlaceholderSchema | null;
|
||||
values: Record<string, string>;
|
||||
onChange: (values: Record<string, string>) => void;
|
||||
onBatchReplace: () => void;
|
||||
onExportDocument: () => void; // 改名:导出文档
|
||||
onComplete: () => void;
|
||||
isReplacing: boolean;
|
||||
isDeleting: boolean; // 改名:是否正在删除
|
||||
onSingleReplace?: (key: string, value: string) => void; // 单个替换
|
||||
onFieldFocus?: (key: string) => void; // 字段聚焦(高亮)
|
||||
}
|
||||
|
||||
export function PlaceholderForm({
|
||||
schema,
|
||||
values,
|
||||
onChange,
|
||||
onBatchReplace,
|
||||
onExportDocument,
|
||||
onComplete,
|
||||
isReplacing,
|
||||
isDeleting,
|
||||
onSingleReplace,
|
||||
onFieldFocus
|
||||
}: PlaceholderFormProps) {
|
||||
const [localValues, setLocalValues] = useState<Record<string, string>>(values);
|
||||
const [replacingFields, setReplacingFields] = useState<Set<string>>(new Set());
|
||||
|
||||
// 同步外部 values 到本地状态
|
||||
useEffect(() => {
|
||||
setLocalValues(values);
|
||||
}, [values]);
|
||||
|
||||
// 处理字段变化
|
||||
const handleFieldChange = (key: string, value: string) => {
|
||||
const newValues = { ...localValues, [key]: value };
|
||||
setLocalValues(newValues);
|
||||
onChange(newValues);
|
||||
};
|
||||
|
||||
// 处理字段聚焦(高亮文档中的占位符)
|
||||
const handleFieldFocus = (key: string) => {
|
||||
if (onFieldFocus) {
|
||||
onFieldFocus(key);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理单个字段替换
|
||||
const handleSingleReplace = async (key: string) => {
|
||||
const value = localValues[key];
|
||||
if (!value || !onSingleReplace) return;
|
||||
|
||||
setReplacingFields(prev => new Set(prev).add(key));
|
||||
try {
|
||||
await onSingleReplace(key, value);
|
||||
} finally {
|
||||
setReplacingFields(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否有未填写的必填字段
|
||||
const getMissingRequiredFields = () => {
|
||||
if (!schema) return [];
|
||||
return schema.fields
|
||||
.filter(f => f.required && !localValues[f.key])
|
||||
.map(f => f.label);
|
||||
};
|
||||
|
||||
const handleCompleteClick = () => {
|
||||
const missing = getMissingRequiredFields();
|
||||
if (missing.length > 0) {
|
||||
alert(`请填写以下必填字段:\n${missing.join('\n')}`);
|
||||
return;
|
||||
}
|
||||
onComplete();
|
||||
};
|
||||
|
||||
if (!schema || !schema.fields || schema.fields.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<i className="ri-information-line text-3xl text-gray-400"></i>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">暂无占位符配置</h3>
|
||||
<p className="text-sm text-gray-500">该模板尚未配置占位符字段</p>
|
||||
<p className="text-xs text-gray-400 mt-2">请联系管理员配置模板占位符</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
{/* 表单头部 */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gradient-to-r from-blue-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-[#004d38] flex items-center justify-center shadow-sm">
|
||||
<i className="ri-file-edit-line text-white text-base"></i>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-900">填写合同信息</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表单内容区域 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
{schema?.fields.map((field) => (
|
||||
<div key={field.key} className="form-field">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={localValues[field.key] || ''}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
onFocus={() => handleFieldFocus(field.key)}
|
||||
placeholder={field.placeholder || `请输入${field.label}`}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all duration-150 resize-none bg-white text-gray-900 placeholder-gray-400 text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={field.type}
|
||||
value={localValues[field.key] || ''}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
onFocus={() => handleFieldFocus(field.key)}
|
||||
placeholder={field.placeholder || `请输入${field.label}`}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all duration-150 bg-white text-gray-900 placeholder-gray-400 text-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 单独的替换按钮 */}
|
||||
<button
|
||||
onClick={() => handleSingleReplace(field.key)}
|
||||
disabled={!localValues[field.key] || replacingFields.has(field.key)}
|
||||
className={`px-3 py-2 rounded-lg transition-all duration-150 flex items-center gap-1.5 text-sm font-medium whitespace-nowrap ${
|
||||
!localValues[field.key] || replacingFields.has(field.key)
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-primary text-white hover:bg-primary-hover shadow-sm hover:shadow'
|
||||
}`}
|
||||
title="替换此占位符"
|
||||
>
|
||||
{replacingFields.has(field.key) ? (
|
||||
<>
|
||||
<i className="ri-loader-4-line animate-spin text-base"></i>
|
||||
<span>替换中</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="ri-refresh-line text-base"></i>
|
||||
<span>替换</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域(固定在底部) */}
|
||||
<div className="border-t border-gray-200 px-6 py-3 bg-gray-50 flex gap-2">
|
||||
<button
|
||||
onClick={onBatchReplace}
|
||||
disabled={isReplacing || isDeleting}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-4 py-2 text-white text-sm font-medium rounded-lg transition-all duration-150 ${
|
||||
isReplacing || isDeleting
|
||||
? 'bg-gray-300 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-primary to-[#004d38] hover:shadow-md active:scale-[0.98]'
|
||||
}`}
|
||||
>
|
||||
{isReplacing ? (
|
||||
<>
|
||||
<i className="ri-loader-4-line animate-spin"></i>
|
||||
<span>替换中</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="ri-refresh-line"></i>
|
||||
<span>全部替换</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCompleteClick}
|
||||
disabled={isReplacing || isDeleting}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-150 ${
|
||||
isReplacing || isDeleting
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-600 text-white hover:bg-green-700 hover:shadow-md active:scale-[0.98]'
|
||||
}`}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<i className="ri-loader-4-line animate-spin"></i>
|
||||
<span>处理中</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="ri-check-line"></i>
|
||||
<span>完成</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* 合同起草页面
|
||||
* 路由: /contract-draft/:draftId
|
||||
*/
|
||||
|
||||
import type { LoaderFunctionArgs, MetaFunction, ActionFunctionArgs } from '@remix-run/node';
|
||||
import { redirect } from '@remix-run/node';
|
||||
import { useLoaderData, useNavigate, useFetcher } from '@remix-run/react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { FilePreview, type FilePreviewHandle } from '~/components/reviews/FilePreview';
|
||||
import { PlaceholderForm } from '~/components/contracts/PlaceholderForm';
|
||||
import { getDraftById, deleteDraft } from '~/api/contracts/draft-service.server';
|
||||
import { getUserSession } from '~/api/login/auth.server';
|
||||
import { toastService } from '~/components/ui/Toast';
|
||||
import { downloadFile } from '~/api/axios-client';
|
||||
import { extractPlaceholdersFromDocx, generateDefaultSchema } from '~/api/contracts/docx-parser.server';
|
||||
import path from 'path';
|
||||
import type { DraftedContract, PlaceholderSchema } from '~/types/contract-draft';
|
||||
import type { ContractTemplate } from '~/api/contract-template/templates';
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||
return [
|
||||
{ title: `${data?.draft.title || '合同起草'} - 智慧法务` },
|
||||
{ name: 'description', content: '起草合同文档' }
|
||||
];
|
||||
};
|
||||
|
||||
interface LoaderData {
|
||||
draft: DraftedContract;
|
||||
template: ContractTemplate;
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const draftId = parseInt(params.draftId || '0');
|
||||
|
||||
if (!draftId) {
|
||||
throw new Response('草稿ID无效', { status: 400 });
|
||||
}
|
||||
|
||||
// 获取用户信息和JWT
|
||||
const { userInfo, frontendJWT } = await getUserSession(request);
|
||||
if (!userInfo?.sub) {
|
||||
throw new Response('未登录', { status: 401 });
|
||||
}
|
||||
|
||||
const jwt = frontendJWT || undefined;
|
||||
|
||||
// 【临时测试】使用测试文档和模拟数据
|
||||
// const testDocPath = path.join(process.cwd(), 'public', 'testWork', '买卖合同 (1).docx');
|
||||
const testDocPath = 'contract-template/买卖/买卖合同范本.docx';
|
||||
|
||||
// 创建临时的草稿对象(用于测试)
|
||||
const draft: DraftedContract = {
|
||||
id: draftId,
|
||||
template_id: 1,
|
||||
file_path: testDocPath,
|
||||
title: '买卖合同-测试草稿',
|
||||
placeholder_values: {},
|
||||
status: 'draft',
|
||||
created_by: parseInt(userInfo.sub),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 从 docx 文件中提取占位符
|
||||
let placeholderSchema: PlaceholderSchema | null = null;
|
||||
|
||||
try {
|
||||
console.log('[Loader] 使用测试文档:', testDocPath);
|
||||
|
||||
// 提取占位符
|
||||
const placeholders = await extractPlaceholdersFromDocx(testDocPath);
|
||||
console.log('[Loader] 提取到的占位符:', placeholders);
|
||||
|
||||
// 生成默认 schema
|
||||
placeholderSchema = generateDefaultSchema(placeholders);
|
||||
// console.log('[Loader] 生成的 schema:', JSON.stringify(placeholderSchema, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[Loader] 提取占位符失败:', error);
|
||||
placeholderSchema = null;
|
||||
}
|
||||
|
||||
// 创建临时的模板对象(用于测试)
|
||||
const template: ContractTemplate = {
|
||||
id: 1,
|
||||
title: '买卖合同模板',
|
||||
template_code: 'TEST-001',
|
||||
category_id: 1,
|
||||
file_path: testDocPath,
|
||||
file_format: 'docx',
|
||||
description: '测试用买卖合同模板',
|
||||
is_featured: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
placeholder_schema: placeholderSchema as any
|
||||
};
|
||||
|
||||
return Response.json({
|
||||
draft,
|
||||
template
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Action 函数:处理删除草稿
|
||||
*/
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const draftId = parseInt(params.draftId || '0');
|
||||
|
||||
if (!draftId) {
|
||||
return Response.json({ error: '草稿ID无效' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取用户信息和JWT
|
||||
const { userInfo, frontendJWT } = await getUserSession(request);
|
||||
if (!userInfo?.sub) {
|
||||
return Response.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = parseInt(userInfo.sub);
|
||||
const jwt = frontendJWT || undefined;
|
||||
|
||||
try {
|
||||
// 解析表单数据
|
||||
const formData = await request.formData();
|
||||
const actionType = formData.get('_action') as string;
|
||||
|
||||
if (actionType === 'delete') {
|
||||
// 删除草稿记录
|
||||
await deleteDraft(draftId, userId, jwt);
|
||||
|
||||
return Response.json({ success: true, message: '草稿已删除' });
|
||||
}
|
||||
|
||||
return Response.json({ error: '无效的操作类型' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('[Action] 操作失败:', error);
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : '操作失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContractDraftPage() {
|
||||
const { draft, template } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
const fetcher = useFetcher<ActionData>();
|
||||
|
||||
const [placeholderValues, setPlaceholderValues] = useState<Record<string, string>>(
|
||||
draft.placeholder_values || {}
|
||||
);
|
||||
const [isReplacing, setIsReplacing] = useState(false);
|
||||
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
|
||||
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{
|
||||
searchText: string;
|
||||
replaceText: string;
|
||||
pageNumber: number;
|
||||
} | undefined>(undefined);
|
||||
|
||||
const filePreviewRef = useRef<FilePreviewHandle>(null);
|
||||
|
||||
// 从 fetcher.state 判断是否正在操作
|
||||
const isDeleting = fetcher.state !== 'idle';
|
||||
|
||||
// 处理 fetcher 响应(删除草稿)
|
||||
useEffect(() => {
|
||||
if (fetcher.data?.success && fetcher.data.message === '草稿已删除') {
|
||||
// 删除成功,跳转到模板列表
|
||||
navigate('/contract-template');
|
||||
} else if (fetcher.data?.error) {
|
||||
toastService.error(fetcher.data.error);
|
||||
}
|
||||
}, [fetcher.data, navigate]);
|
||||
|
||||
// 监听页面关闭事件 - 自动删除草稿
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
// 发送删除请求(使用 sendBeacon 确保请求发送)
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'delete');
|
||||
|
||||
navigator.sendBeacon(
|
||||
`/contract-draft/${draft.id}`,
|
||||
formData
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [draft.id]);
|
||||
|
||||
// 组件卸载时删除草稿(处理路由跳转的情况)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 组件卸载时删除草稿记录
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'delete');
|
||||
|
||||
fetch(`/contract-draft/${draft.id}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
keepalive: true // 确保请求在页面关闭后仍然发送
|
||||
}).catch(err => {
|
||||
console.error('[Draft] 删除草稿失败:', err);
|
||||
});
|
||||
};
|
||||
}, [draft.id]);
|
||||
|
||||
// 单个替换占位符
|
||||
const handleSingleReplace = async (key: string, value: string) => {
|
||||
const placeholder = `{{${key}}}`;
|
||||
console.log(`[Draft] 单个替换: ${placeholder} -> ${value}`);
|
||||
|
||||
// 设置 AI 建议替换参数,触发 FilePreview 中的替换
|
||||
setAiSuggestionReplace({
|
||||
searchText: placeholder,
|
||||
replaceText: value,
|
||||
pageNumber: 1 // 从第一页开始搜索
|
||||
});
|
||||
|
||||
// 短暂延迟后清除参数,以便下次可以重新触发
|
||||
setTimeout(() => {
|
||||
setAiSuggestionReplace(undefined);
|
||||
toastService.success(`已替换 ${key}`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 字段聚焦时高亮对应占位符
|
||||
const handleFieldFocus = (key: string) => {
|
||||
const placeholder = `{{${key}}}`;
|
||||
console.log(`[Draft] 高亮占位符: ${placeholder}`);
|
||||
|
||||
// 设置高亮值,触发 FilePreview 中的高亮
|
||||
setHighlightValue(placeholder);
|
||||
|
||||
// 短暂延迟后清除高亮,以便下次点击可以重新触发
|
||||
setTimeout(() => {
|
||||
setHighlightValue(undefined);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 批量替换占位符
|
||||
const handleBatchReplace = async () => {
|
||||
setIsReplacing(true);
|
||||
|
||||
try {
|
||||
// 获取 CollaboraViewer 引用
|
||||
const collaboraRef = filePreviewRef.current?.collaboraViewerRef.current;
|
||||
|
||||
if (!collaboraRef?.isReady) {
|
||||
toastService.warning('文档尚未加载完成,请稍候...');
|
||||
setIsReplacing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Draft] 开始批量替换占位符:', placeholderValues);
|
||||
|
||||
// 批量替换所有占位符
|
||||
let replaceCount = 0;
|
||||
for (const [key, value] of Object.entries(placeholderValues)) {
|
||||
if (value) { // 只替换有值的字段
|
||||
const placeholder = `{{${key}}}`;
|
||||
console.log(`[Draft] 替换: ${placeholder} -> ${value}`);
|
||||
|
||||
// 调用 unoCommands.replaceAll 方法
|
||||
if (collaboraRef.unoCommands?.replaceAll) {
|
||||
await collaboraRef.unoCommands.replaceAll(placeholder, value);
|
||||
replaceCount++;
|
||||
// 添加延迟避免 Collabora 响应不过来
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} else {
|
||||
console.warn('[Draft] unoCommands.replaceAll 方法不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Draft] 替换完成,共替换 ${replaceCount} 个占位符`);
|
||||
toastService.success(`占位符替换完成(${replaceCount}个)`);
|
||||
} catch (error) {
|
||||
console.error('[Draft] 替换失败:', error);
|
||||
toastService.error(`替换失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setIsReplacing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出文档(下载文件)
|
||||
const handleExportDocument = async () => {
|
||||
if (!draft.file_path) {
|
||||
toastService.error('文件路径不存在,无法下载');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toastService.info('正在下载文件...');
|
||||
|
||||
// 使用统一的下载方法
|
||||
const blob = await downloadFile(draft.file_path);
|
||||
|
||||
// 创建Blob URL
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 清理文件名
|
||||
const fileExtension = draft.file_path.split('.').pop() || 'docx';
|
||||
const fileName = `${draft.title}.${fileExtension}`;
|
||||
const cleanFileName = fileName.replace(/[<>:"/\\|?*]/g, '_');
|
||||
|
||||
// 创建隐藏的a标签并点击下载
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = blobUrl;
|
||||
a.download = cleanFileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// 清理
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}, 100);
|
||||
|
||||
toastService.success('文件下载成功');
|
||||
} catch (error) {
|
||||
console.error('[Draft] 下载文件失败:', error);
|
||||
toastService.error(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 完成起草(下载文件 + 删除草稿记录)
|
||||
const handleComplete = async () => {
|
||||
// 1. 先下载文件
|
||||
await handleExportDocument();
|
||||
|
||||
// 2. 延迟后删除草稿记录并跳转
|
||||
setTimeout(() => {
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'delete');
|
||||
|
||||
fetcher.submit(formData, { method: 'post' });
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 返回模板详情页(删除草稿)
|
||||
const handleBack = () => {
|
||||
if (confirm('确定要返回吗?草稿将被删除。')) {
|
||||
// 删除草稿记录
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'delete');
|
||||
|
||||
fetcher.submit(formData, { method: 'post' });
|
||||
// 删除成功后会自动跳转(通过 useEffect)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gradient-to-br from-gray-50 to-gray-100">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex items-center justify-between px-8 py-5 bg-white shadow-sm border-b border-gray-100">
|
||||
<div className="flex items-center gap-6">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:border-gray-400 active:bg-gray-100 transition-all duration-150 shadow-sm hover:shadow"
|
||||
>
|
||||
<i className="ri-arrow-left-line text-lg"></i>
|
||||
<span>返回</span>
|
||||
</button>
|
||||
<div className="border-l border-gray-300 h-10"></div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">{draft.title}</h1>
|
||||
<p className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<i className="ri-file-text-line text-base"></i>
|
||||
<span>基于模板:{template.title}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-gradient-to-r from-blue-50 to-blue-100 text-blue-700 border border-blue-200 shadow-sm">
|
||||
<i className="ri-draft-line text-base"></i>
|
||||
<span>{draft.status === 'draft' ? '草稿' : draft.status === 'completed' ? '已完成' : '已归档'}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容区域:左右分栏 */}
|
||||
<div className="flex-1 flex overflow-hidden gap-4 p-4">
|
||||
{/* 左侧:文档预览(60%) */}
|
||||
<div className="w-[60%] bg-white h-full overflow-hidden rounded-xl shadow-lg border border-gray-200">
|
||||
<FilePreview
|
||||
ref={filePreviewRef}
|
||||
fileContent={{
|
||||
path: draft.file_path,
|
||||
title: draft.title,
|
||||
contractNumber: '',
|
||||
parties: {
|
||||
partyA: { name: '', address: '', representative: '', phone: '' },
|
||||
partyB: { name: '', address: '', representative: '', phone: '' }
|
||||
},
|
||||
sections: []
|
||||
}}
|
||||
activeReviewPointResultId={null}
|
||||
targetPage={undefined}
|
||||
isStructuredView={false}
|
||||
isTemplate={false} // 编辑模式
|
||||
highlightValue={highlightValue}
|
||||
aiSuggestionReplace={aiSuggestionReplace}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧:占位符表单(40%) */}
|
||||
<div className="w-[40%] bg-white h-full overflow-hidden rounded-xl shadow-lg border border-gray-200">
|
||||
<PlaceholderForm
|
||||
schema={template.placeholder_schema as any}
|
||||
values={placeholderValues}
|
||||
onChange={setPlaceholderValues}
|
||||
onBatchReplace={handleBatchReplace}
|
||||
onExportDocument={handleExportDocument}
|
||||
onComplete={handleComplete}
|
||||
isReplacing={isReplacing}
|
||||
isDeleting={isDeleting}
|
||||
onSingleReplace={handleSingleReplace}
|
||||
onFieldFocus={handleFieldFocus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 合同起草相关类型定义
|
||||
*/
|
||||
|
||||
// 占位符字段类型
|
||||
export type FieldType = 'text' | 'number' | 'date' | 'tel' | 'email' | 'textarea';
|
||||
|
||||
// 占位符字段配置
|
||||
export interface PlaceholderField {
|
||||
key: string; // 字段键名(也是占位符名称)
|
||||
label: string; // 显示标签
|
||||
type: FieldType; // 字段类型
|
||||
required: boolean; // 是否必填
|
||||
group: string; // 分组名称
|
||||
placeholder?: string; // 输入提示
|
||||
defaultValue?: string; // 默认值
|
||||
}
|
||||
|
||||
// 占位符配置Schema
|
||||
export interface PlaceholderSchema {
|
||||
fields: PlaceholderField[];
|
||||
}
|
||||
|
||||
// 起草合同状态
|
||||
export type DraftStatus = 'draft' | 'completed' | 'archived';
|
||||
|
||||
// 起草合同记录
|
||||
export interface DraftedContract {
|
||||
id: number;
|
||||
template_id: number;
|
||||
file_path: string;
|
||||
title: string;
|
||||
placeholder_values: Record<string, string>;
|
||||
status: DraftStatus;
|
||||
created_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 创建起草合同请求
|
||||
export interface CreateDraftRequest {
|
||||
templateId: number;
|
||||
title: string;
|
||||
draftFilePath?: string; // 可选:草稿文件路径(如果需要使用复制后的文件)
|
||||
}
|
||||
|
||||
// 创建起草合同响应
|
||||
export interface CreateDraftResponse {
|
||||
id: number;
|
||||
filePath: string;
|
||||
title: string;
|
||||
templateId: number;
|
||||
}
|
||||
|
||||
// 更新占位符值请求
|
||||
export interface UpdatePlaceholdersRequest {
|
||||
placeholders: Record<string, string>;
|
||||
}
|
||||
|
||||
// 完成起草请求
|
||||
export interface CompleteDraftRequest {
|
||||
// 可选的额外参数
|
||||
}
|
||||
Reference in New Issue
Block a user