fix: исправлена проблема с label for атрибутом в createCheckbox

- Добавлена генерация безопасного id (убираются специальные символы)
- Добавлено экранирование id при использовании в атрибуте for
- Убеждаемся, что id и for всегда совпадают

Исправляет предупреждение: 'Incorrect use of <label for=FORM_ELEMENT>'
This commit is contained in:
Fedor
2025-12-04 09:45:40 +03:00
parent 003210dcfc
commit 1f88e156b7
3 changed files with 9 additions and 709 deletions

View File

@@ -359,367 +359,3 @@ export default function StepDocumentsNew({
</div>
);
}
* StepDocumentsNew.tsx
*
* Поэкранная загрузка документов.
* Один документ на экран с возможностью пропуска.
*
* @version 1.0
* @date 2025-11-26
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import {
Button,
Card,
Upload,
Progress,
Alert,
Typography,
Space,
Spin,
message,
Result
} from 'antd';
import {
UploadOutlined,
FileTextOutlined,
ExclamationCircleOutlined,
CheckCircleOutlined,
LoadingOutlined,
InboxOutlined
} from '@ant-design/icons';
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
const { Title, Text, Paragraph } = Typography;
const { Dragger } = Upload;
// === Типы ===
export interface DocumentConfig {
type: string; // Идентификатор: contract, payment, correspondence
name: string; // Название: "Договор или оферта"
critical: boolean; // Обязательный документ?
hints?: string; // Подсказка: "Скриншот или PDF договора"
accept?: string[]; // Допустимые форматы: ['pdf', 'jpg', 'png']
}
interface Props {
formData: any;
updateFormData: (data: any) => void;
documents: DocumentConfig[];
currentIndex: number;
onDocumentUploaded: (docType: string, fileData: any) => void;
onDocumentSkipped: (docType: string) => void;
onAllDocumentsComplete: () => void;
onPrev: () => void;
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
}
// === Компонент ===
export default function StepDocumentsNew({
formData,
updateFormData,
documents,
currentIndex,
onDocumentUploaded,
onDocumentSkipped,
onAllDocumentsComplete,
onPrev,
addDebugEvent,
}: Props) {
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
// Текущий документ
const currentDoc = documents[currentIndex];
const isLastDocument = currentIndex === documents.length - 1;
const totalDocs = documents.length;
// Сбрасываем файлы при смене документа
useEffect(() => {
setFileList([]);
setUploadProgress(0);
}, [currentIndex]);
// === Handlers ===
const handleUpload = useCallback(async () => {
if (fileList.length === 0) {
message.error('Выберите файл для загрузки');
return;
}
const file = fileList[0];
if (!file.originFileObj) {
message.error('Ошибка: файл не найден');
return;
}
setUploading(true);
setUploadProgress(0);
try {
addDebugEvent?.('documents', 'info', `📤 Загрузка документа: ${currentDoc.name}`, {
document_type: currentDoc.type,
file_name: file.name,
file_size: file.size,
});
const formDataToSend = new FormData();
formDataToSend.append('claim_id', formData.claim_id || '');
formDataToSend.append('session_id', formData.session_id || '');
formDataToSend.append('document_type', currentDoc.type);
formDataToSend.append('file', file.originFileObj, file.name);
// Симуляция прогресса (реальный прогресс будет через XHR)
const progressInterval = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 10, 90));
}, 200);
const response = await fetch('/api/v1/documents/upload', {
method: 'POST',
body: formDataToSend,
});
clearInterval(progressInterval);
setUploadProgress(100);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ошибка загрузки: ${response.status} ${errorText}`);
}
const result = await response.json();
addDebugEvent?.('documents', 'success', `✅ Документ загружен: ${currentDoc.name}`, {
document_type: currentDoc.type,
file_id: result.file_id,
});
message.success(`${currentDoc.name} загружен!`);
// Сохраняем в formData
const uploadedDocs = formData.documents_uploaded || [];
uploadedDocs.push({
type: currentDoc.type,
file_id: result.file_id,
file_name: file.name,
ocr_status: 'processing',
});
updateFormData({
documents_uploaded: uploadedDocs,
current_doc_index: currentIndex + 1,
});
// Callback
onDocumentUploaded(currentDoc.type, result);
// Переходим к следующему или завершаем
if (isLastDocument) {
onAllDocumentsComplete();
}
} catch (error) {
console.error('❌ Upload error:', error);
message.error('Ошибка загрузки файла. Попробуйте ещё раз.');
addDebugEvent?.('documents', 'error', `❌ Ошибка загрузки: ${currentDoc.name}`, {
error: String(error),
});
} finally {
setUploading(false);
}
}, [fileList, currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentUploaded, onAllDocumentsComplete, addDebugEvent]);
const handleSkip = useCallback(() => {
if (currentDoc.critical) {
// Показываем предупреждение, но всё равно разрешаем пропустить
message.warning(`⚠️ Документ "${currentDoc.name}" важен для рассмотрения заявки`);
}
addDebugEvent?.('documents', 'info', `⏭️ Документ пропущен: ${currentDoc.name}`, {
document_type: currentDoc.type,
was_critical: currentDoc.critical,
});
// Сохраняем в список пропущенных
const skippedDocs = formData.documents_skipped || [];
if (!skippedDocs.includes(currentDoc.type)) {
skippedDocs.push(currentDoc.type);
}
updateFormData({
documents_skipped: skippedDocs,
current_doc_index: currentIndex + 1,
});
// Callback
onDocumentSkipped(currentDoc.type);
// Переходим к следующему или завершаем
if (isLastDocument) {
onAllDocumentsComplete();
}
}, [currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentSkipped, onAllDocumentsComplete, addDebugEvent]);
// === Upload Props ===
const uploadProps: UploadProps = {
fileList,
onChange: ({ fileList: newFileList }) => setFileList(newFileList.slice(-1)), // Только один файл
beforeUpload: () => false, // Не загружаем автоматически
maxCount: 1,
accept: currentDoc?.accept
? currentDoc.accept.map(ext => `.${ext}`).join(',')
: '.pdf,.jpg,.jpeg,.png,.heic,.doc,.docx',
disabled: uploading,
};
// === Render ===
if (!currentDoc) {
return (
<Result
status="success"
title="Все документы обработаны"
subTitle="Переходим к формированию заявления..."
extra={<Spin size="large" />}
/>
);
}
return (
<div style={{ maxWidth: 700, margin: '0 auto' }}>
<Card>
{/* === Прогресс === */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text type="secondary">
Документ {currentIndex + 1} из {totalDocs}
</Text>
<Text type="secondary">
{Math.round((currentIndex / totalDocs) * 100)}% завершено
</Text>
</div>
<Progress
percent={Math.round((currentIndex / totalDocs) * 100)}
showInfo={false}
strokeColor="#595959"
/>
</div>
{/* === Заголовок === */}
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<FileTextOutlined style={{ color: '#595959' }} />
{currentDoc.name}
{currentDoc.critical && (
<ExclamationCircleOutlined
style={{ color: '#fa8c16', fontSize: 20 }}
title="Важный документ"
/>
)}
</Title>
{currentDoc.hints && (
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
{currentDoc.hints}
</Paragraph>
)}
</div>
{/* === Алерт для критичных документов === */}
{currentDoc.critical && (
<Alert
message="Важный документ"
description="Этот документ значительно повысит шансы на успешное рассмотрение заявки. Если документа нет — можно пропустить, но мы рекомендуем загрузить."
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
style={{ marginBottom: 24 }}
/>
)}
{/* === Загрузка файла === */}
<Dragger {...uploadProps} style={{ marginBottom: 24 }}>
<p className="ant-upload-drag-icon">
{uploading ? (
<LoadingOutlined style={{ fontSize: 48, color: '#595959' }} spin />
) : (
<InboxOutlined style={{ fontSize: 48, color: '#595959' }} />
)}
</p>
<p className="ant-upload-text">
{uploading
? 'Загружаем документ...'
: 'Перетащите файл сюда или нажмите для выбора'
}
</p>
<p className="ant-upload-hint">
Поддерживаются: PDF, JPG, PNG, HEIC, DOC (до 20 МБ)
</p>
</Dragger>
{/* === Прогресс загрузки === */}
{uploading && (
<Progress
percent={uploadProgress}
status="active"
style={{ marginBottom: 24 }}
/>
)}
{/* === Кнопки === */}
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
onClick={onPrev}
disabled={uploading}
size="large"
>
Назад
</Button>
<Space>
<Button
onClick={handleSkip}
disabled={uploading}
size="large"
>
Пропустить
</Button>
<Button
type="primary"
onClick={handleUpload}
loading={uploading}
disabled={fileList.length === 0}
size="large"
icon={<UploadOutlined />}
>
{isLastDocument ? 'Загрузить и продолжить' : 'Загрузить'}
</Button>
</Space>
</Space>
{/* === Уже загруженные документы === */}
{formData.documents_uploaded && formData.documents_uploaded.length > 0 && (
<div style={{ marginTop: 24, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
<Text strong>Загруженные документы:</Text>
<ul style={{ margin: '8px 0 0 0', paddingLeft: 20 }}>
{formData.documents_uploaded.map((doc: any, idx: number) => (
<li key={idx}>
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 8 }} />
{documents.find(d => d.type === doc.type)?.name || doc.type}
</li>
))}
</ul>
</div>
)}
</Card>
</div>
);
}

View File

@@ -336,344 +336,3 @@ export default function StepWaitingClaim({
</div>
);
}
* StepWaitingClaim.tsx
*
* Экран ожидания формирования заявления.
* Показывает прогресс: OCR Анализ Формирование заявления.
* Подписывается на SSE для получения claim_ready.
*
* @version 1.0
* @date 2025-11-26
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { Card, Typography, Progress, Space, Button, Spin, Result, Steps } from 'antd';
import {
LoadingOutlined,
CheckCircleOutlined,
FileSearchOutlined,
RobotOutlined,
FileTextOutlined,
ClockCircleOutlined
} from '@ant-design/icons';
import AiWorkingIllustration from '../../assets/ai-working.svg';
const { Title, Paragraph, Text } = Typography;
const { Step } = Steps;
interface Props {
sessionId: string;
claimId?: string;
documentsCount: number;
onClaimReady: (claimData: any) => void;
onTimeout: () => void;
onError: (error: string) => void;
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
}
type ProcessingStep = 'ocr' | 'analysis' | 'generation' | 'ready';
interface ProcessingState {
currentStep: ProcessingStep;
ocrCompleted: number;
ocrTotal: number;
message: string;
}
export default function StepWaitingClaim({
sessionId,
claimId,
documentsCount,
onClaimReady,
onTimeout,
onError,
addDebugEvent,
}: Props) {
const eventSourceRef = useRef<EventSource | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [state, setState] = useState<ProcessingState>({
currentStep: 'ocr',
ocrCompleted: 0,
ocrTotal: documentsCount,
message: 'Распознаём документы...',
});
const [elapsedTime, setElapsedTime] = useState(0);
const [error, setError] = useState<string | null>(null);
// Таймер для отображения времени
useEffect(() => {
const interval = setInterval(() => {
setElapsedTime(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
// SSE подписка
useEffect(() => {
if (!sessionId) {
setError('Отсутствует session_id');
return;
}
console.log('🔌 StepWaitingClaim: подписываемся на SSE', { sessionId, claimId });
const eventSource = new EventSource(`/api/v1/events/${sessionId}`);
eventSourceRef.current = eventSource;
addDebugEvent?.('waiting', 'info', '🔌 Подписка на SSE для ожидания заявления', {
session_id: sessionId,
claim_id: claimId,
});
// Таймаут 5 минут
timeoutRef.current = setTimeout(() => {
console.warn('⏰ Timeout ожидания заявления');
setError('Превышено время ожидания. Попробуйте обновить страницу.');
addDebugEvent?.('waiting', 'warning', '⏰ Таймаут ожидания заявления');
eventSource.close();
onTimeout();
}, 300000); // 5 минут
eventSource.onopen = () => {
console.log('✅ SSE соединение открыто (waiting)');
addDebugEvent?.('waiting', 'info', '✅ SSE соединение открыто');
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('📥 SSE event (waiting):', data);
const eventType = data.event_type || data.type;
// OCR документа завершён
if (eventType === 'document_ocr_completed') {
setState(prev => ({
...prev,
ocrCompleted: prev.ocrCompleted + 1,
message: `Распознано ${prev.ocrCompleted + 1} из ${prev.ocrTotal} документов`,
}));
addDebugEvent?.('waiting', 'info', `📄 OCR завершён: ${data.document_type}`);
}
// Все документы распознаны, начинаем анализ
if (eventType === 'ocr_all_completed' || eventType === 'analysis_started') {
setState(prev => ({
...prev,
currentStep: 'analysis',
message: 'Анализируем данные...',
}));
addDebugEvent?.('waiting', 'info', '🔍 Начат анализ данных');
}
// Генерация заявления
if (eventType === 'claim_generation_started') {
setState(prev => ({
...prev,
currentStep: 'generation',
message: 'Формируем заявление...',
}));
addDebugEvent?.('waiting', 'info', '📝 Начато формирование заявления');
}
// Заявление готово!
if (eventType === 'claim_ready' || eventType === 'claim_plan_ready') {
console.log('🎉 Заявление готово!', data);
// Очищаем таймаут
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setState(prev => ({
...prev,
currentStep: 'ready',
message: 'Заявление готово!',
}));
addDebugEvent?.('waiting', 'success', '✅ Заявление готово');
// Закрываем SSE
eventSource.close();
eventSourceRef.current = null;
// Callback с данными
setTimeout(() => {
onClaimReady(data.data || data.claim_data || data);
}, 500);
}
// Ошибка
if (eventType === 'claim_error' || data.status === 'error') {
setError(data.message || 'Произошла ошибка при формировании заявления');
addDebugEvent?.('waiting', 'error', `❌ Ошибка: ${data.message}`);
eventSource.close();
onError(data.message);
}
} catch (err) {
console.error('❌ Ошибка парсинга SSE:', err);
}
};
eventSource.onerror = (err) => {
console.error('❌ SSE error (waiting):', err);
// Не показываем ошибку сразу — SSE может переподключиться
};
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [sessionId, claimId, onClaimReady, onTimeout, onError, addDebugEvent]);
// Форматирование времени
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Вычисляем процент прогресса
const getProgress = (): number => {
switch (state.currentStep) {
case 'ocr':
// OCR: 0-50%
return state.ocrTotal > 0
? Math.round((state.ocrCompleted / state.ocrTotal) * 50)
: 25;
case 'analysis':
return 60;
case 'generation':
return 85;
case 'ready':
return 100;
default:
return 0;
}
};
// Индекс текущего шага для Steps
const getStepIndex = (): number => {
switch (state.currentStep) {
case 'ocr': return 0;
case 'analysis': return 1;
case 'generation': return 2;
case 'ready': return 3;
default: return 0;
}
};
// === Render ===
if (error) {
return (
<Result
status="error"
title="Ошибка"
subTitle={error}
extra={
<Button type="primary" onClick={() => window.location.reload()}>
Обновить страницу
</Button>
}
/>
);
}
if (state.currentStep === 'ready') {
return (
<Result
status="success"
title="Заявление готово!"
subTitle="Переходим к просмотру..."
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
extra={<Spin size="large" />}
/>
);
}
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<Card style={{ textAlign: 'center' }}>
{/* === Иллюстрация === */}
<img
src={AiWorkingIllustration}
alt="AI работает"
style={{ maxWidth: 280, width: '100%', marginBottom: 24 }}
/>
{/* === Заголовок === */}
<Title level={3}>{state.message}</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
Наш AI-ассистент обрабатывает ваши документы и формирует заявление.
Это займёт 1-2 минуты.
</Paragraph>
{/* === Прогресс === */}
<Progress
percent={getProgress()}
status="active"
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
style={{ marginBottom: 24 }}
/>
{/* === Шаги обработки === */}
<Steps
current={getStepIndex()}
size="small"
style={{ marginBottom: 24 }}
>
<Step
title="OCR"
description={state.ocrTotal > 0 ? `${state.ocrCompleted}/${state.ocrTotal}` : ''}
icon={state.currentStep === 'ocr' ? <LoadingOutlined /> : <FileSearchOutlined />}
/>
<Step
title="Анализ"
icon={state.currentStep === 'analysis' ? <LoadingOutlined /> : <RobotOutlined />}
/>
<Step
title="Заявление"
icon={state.currentStep === 'generation' ? <LoadingOutlined /> : <FileTextOutlined />}
/>
<Step
title="Готово"
icon={<CheckCircleOutlined />}
/>
</Steps>
{/* === Таймер === */}
<Space>
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
<Text type="secondary">
Время ожидания: {formatTime(elapsedTime)}
</Text>
</Space>
{/* === Подсказка === */}
<Paragraph type="secondary" style={{ marginTop: 16, fontSize: 12 }}>
Не закрывайте эту страницу. Обработка происходит на сервере.
</Paragraph>
</Card>
</div>
);
}

View File

@@ -887,15 +887,20 @@ export function generateConfirmationFormHTML(data: any, contact_data_confirmed:
}
function createCheckbox(root, key, checked, labelText, required) {
var id = 'field_' + root + '_' + key + '_' + Math.random().toString(36).slice(2);
// ✅ Генерируем безопасный id (только буквы, цифры, подчёркивание, дефис)
var safeRoot = String(root || '').replace(/[^a-zA-Z0-9_-]/g, '_');
var safeKey = String(key || '').replace(/[^a-zA-Z0-9_-]/g, '_');
var randomPart = Math.random().toString(36).slice(2);
var id = 'field_' + safeRoot + '_' + safeKey + '_' + randomPart;
var checkedAttr = checked ? ' checked' : '';
var requiredClass = required ? ' required-checkbox' : '';
// ✅ Добавляем name атрибут для правильной работы форм
var nameAttr = 'name="' + esc(root) + '_' + esc(key) + '"';
// ✅ Label правильно связан с input через for/id
var checkboxHtml = '<label class="checkbox-container' + requiredClass + '" for="' + id + '">';
checkboxHtml += '<input type="checkbox" class="checkbox-field bind" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '" ' + nameAttr + checkedAttr + ' />';
// ✅ Label правильно связан с input через for/id (экранируем id для безопасности)
var escapedId = esc(id);
var checkboxHtml = '<label class="checkbox-container' + requiredClass + '" for="' + escapedId + '">';
checkboxHtml += '<input type="checkbox" class="checkbox-field bind" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + escapedId + '" ' + nameAttr + checkedAttr + ' />';
checkboxHtml += '<span class="checkmark"></span>';
checkboxHtml += '<span class="checkbox-label">' + labelText + '</span>';
checkboxHtml += '</label>';