250 lines
9.2 KiB
TypeScript
250 lines
9.2 KiB
TypeScript
/**
|
|
* 占位符表单组件
|
|
* 用于合同起草时填写占位符值
|
|
*/
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { messageService } from '~/components/ui/MessageModal';
|
|
import type { PlaceholderSchema } from '~/types/contract-draft';
|
|
|
|
interface PlaceholderFormProps {
|
|
schema: PlaceholderSchema | null;
|
|
values: Record<string, string>;
|
|
onChange: (values: Record<string, string>) => void;
|
|
onComplete: () => void;
|
|
isDeleting: boolean; // 是否正在删除
|
|
onSingleReplace?: (key: string, value: string) => void; // 单个替换
|
|
onFieldFocus?: (key: string) => void; // 字段聚焦(高亮)
|
|
}
|
|
|
|
export function PlaceholderForm({
|
|
schema,
|
|
values,
|
|
onChange,
|
|
onComplete,
|
|
isDeleting,
|
|
onSingleReplace,
|
|
onFieldFocus
|
|
}: PlaceholderFormProps) {
|
|
const [localValues, setLocalValues] = useState<Record<string, string>>(values);
|
|
const [replacingFields, setReplacingFields] = useState<Set<string>>(new Set());
|
|
// 【新增】记录当前高亮的字段(只存一个值),避免重复高亮导致焦点被抢
|
|
const [currentHighlightedField, setCurrentHighlightedField] = useState<string | null>(null);
|
|
|
|
// 同步外部 values 到本地状态
|
|
useEffect(() => {
|
|
setLocalValues(values);
|
|
}, [values]);
|
|
|
|
// 处理字段变化
|
|
const handleFieldChange = (key: string, value: string) => {
|
|
const newValues = { ...localValues, [key]: value };
|
|
setLocalValues(newValues);
|
|
onChange(newValues);
|
|
};
|
|
|
|
// 处理字段点击(高亮文档中的占位符)
|
|
const handleFieldClick = async (e: React.MouseEvent<HTMLInputElement | HTMLTextAreaElement>, key: string) => {
|
|
// 1. 检查是否已经高亮当前字段
|
|
if (currentHighlightedField === key) {
|
|
console.log(`[PlaceholderForm] 字段 "${key}" 已高亮,跳过高亮操作`);
|
|
return;
|
|
}
|
|
|
|
// 2. 捕获当前输入框 DOM 元素
|
|
const inputElement = e.currentTarget;
|
|
|
|
// 3. 更新当前高亮字段(只保存一个)
|
|
setCurrentHighlightedField(key);
|
|
console.log(`[PlaceholderForm] 切换高亮字段到 "${key}"`);
|
|
|
|
// 4. 调用父组件的高亮回调
|
|
if (onFieldFocus) {
|
|
onFieldFocus(key);
|
|
}
|
|
|
|
// 5. 【核心】延迟后强制夺回焦点(UNO 命令会让 iframe 抢焦点)
|
|
// 分多次确保焦点回到输入框,防止被 iframe 再次抢走
|
|
const refocusWithRetry = () => {
|
|
console.log(`[PlaceholderForm] 夺回焦点到输入框 "${key}"`);
|
|
inputElement.focus();
|
|
|
|
// 保持光标在文字最后
|
|
const len = inputElement.value.length;
|
|
if ('setSelectionRange' in inputElement) {
|
|
inputElement.setSelectionRange(len, len);
|
|
}
|
|
};
|
|
|
|
// 第一次夺回焦点:150ms(高亮操作完成时)
|
|
setTimeout(refocusWithRetry, 150);
|
|
|
|
// 第二次确认焦点:300ms(确保没有被再次抢走)
|
|
setTimeout(refocusWithRetry, 300);
|
|
};
|
|
|
|
// 处理单个字段替换
|
|
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) {
|
|
messageService.show({
|
|
type: 'warning',
|
|
title: '字段校验失败',
|
|
message: '请填写以下必填字段:',
|
|
children: (
|
|
<ul className="list-disc list-inside mt-2 space-y-1">
|
|
{missing.map((field, index) => (
|
|
<li key={index} className="text-gray-700">{field}</li>
|
|
))}
|
|
</ul>
|
|
),
|
|
confirmText: '确定'
|
|
});
|
|
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 justify-between">
|
|
<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>
|
|
|
|
{/* 完成按钮 */}
|
|
<button
|
|
onClick={handleCompleteClick}
|
|
disabled={isDeleting}
|
|
className={`flex items-center justify-center gap-1.5 px-6 py-2 text-sm font-medium rounded-lg transition-all duration-150 ${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>
|
|
|
|
{/* 表单内容区域 */}
|
|
<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)}
|
|
onClick={(e) => handleFieldClick(e, 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-[#004d38]/20 focus:border-[#004d38] 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)}
|
|
onClick={(e) => handleFieldClick(e, 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-[#004d38]/20 focus:border-[#004d38]
|
|
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>
|
|
);
|
|
}
|