блокировка ячейки веб сокеты

This commit is contained in:
PotapovaA 2026-06-22 18:35:16 +03:00
parent 596ec13943
commit c8165e8ca9
5 changed files with 65 additions and 248 deletions

View File

@ -12,11 +12,28 @@ const ContainerForText = styled.span`
white-space: pre-wrap; white-space: pre-wrap;
`; `;
// Стилизованный компонент для плашки блокировки
const LockBadge = styled.div`
position: absolute;
top: 2px;
right: 2px;
background: rgba(0, 0, 0, 0.7);
color: white;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
pointer-events: none;
z-index: 10;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
letter-spacing: 0.3px;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1);
`;
// Функция для подсветки текста // Функция для подсветки текста
const highlightText = (text, searchQuery) => { const highlightText = (text, searchQuery) => {
if (!searchQuery || !text) return text; if (!searchQuery || !text) return text;
// Экранируем спецсимволы в поисковом запросе
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi'); const regex = new RegExp(`(${escapedQuery})`, 'gi');
@ -49,7 +66,6 @@ const formatNumber = (value) => {
const num = Number(value); const num = Number(value);
if (isNaN(num)) return String(value); if (isNaN(num)) return String(value);
// Форматируем число с 1 знаком после запятой и разделением по 3 цифры
return num.toLocaleString('ru-RU', { return num.toLocaleString('ru-RU', {
minimumFractionDigits: 1, minimumFractionDigits: 1,
maximumFractionDigits: 1, maximumFractionDigits: 1,
@ -58,21 +74,34 @@ const formatNumber = (value) => {
}; };
const Cell = React.memo(({ cell, row, column, onClick, globalFilter, backgroundColor, color, isEditable }) => { const Cell = React.memo(({ cell, row, column, onClick, globalFilter, backgroundColor, color, isEditable }) => {
const { lockedCells } = useRealtime();
const cellKey = `${row.id}_${column.id}`;
const isLocked = lockedCells.includes(cellKey);
const cellValue = cell.getValue(); const cellValue = cell.getValue();
let textValue = String(cellValue || ''); let textValue = String(cellValue || '');
// Проверяем, является ли значение числом, и форматируем его
const isNumeric = !isNaN(Number(cellValue)) && cellValue !== null && cellValue !== undefined && cellValue !== ''; const isNumeric = !isNaN(Number(cellValue)) && cellValue !== null && cellValue !== undefined && cellValue !== '';
if (isNumeric) { if (isNumeric) {
textValue = formatNumber(cellValue); textValue = formatNumber(cellValue);
} }
// Подсвечиваем текст с учетом поискового запроса
const highlightedContent = useMemo(() => { const highlightedContent = useMemo(() => {
return highlightText(textValue, globalFilter); return highlightText(textValue, globalFilter);
}, [textValue, globalFilter]); }, [textValue, globalFilter]);
const lockedStyles = !isEditable ? { // Базовые стили для заблокированной ячейки
const lockedStyles = isLocked ? {
border: '2px solid #e0e0e0',
backgroundColor: '#f5f5f5',
color: '#999999',
cursor: 'not-allowed',
opacity: 0.85,
} : {};
// Если isEditable false, добавляем дополнительные стили
const editableStyles = !isEditable ? {
border: '2px solid #d3d3d3', border: '2px solid #d3d3d3',
color: '#525252', color: '#525252',
cursor: 'not-allowed', cursor: 'not-allowed',
@ -95,13 +124,19 @@ const Cell = React.memo(({ cell, row, column, onClick, globalFilter, backgroundC
bottom: 0, bottom: 0,
border: '2px solid transparent', border: '2px solid transparent',
padding: '8px', padding: '8px',
backgroundColor: backgroundColor, backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
color: color, color: isLocked ? '#999999' : color,
...lockedStyles ...lockedStyles,
...editableStyles,
}} }}
onClick={onClick} onClick={isLocked ? undefined : onClick}
> >
<ContainerForText>{highlightedContent}</ContainerForText> <ContainerForText>{highlightedContent}</ContainerForText>
{isLocked && (
<LockBadge>
🔒
</LockBadge>
)}
</div> </div>
); );
}); });

View File

@ -147,8 +147,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
scrollPaddingEnd: 0, scrollPaddingEnd: 0,
}, },
onEditingCellChange: (cell) => { onEditingCellChange: (cell) => {
console.log(cell, 'CELL')
if (cell) { if (cell) {
if (editingCell) return;
contextStartEditing?.(cell.row, cell.column); contextStartEditing?.(cell.row, cell.column);
} else { } else {
contextEndEditing?.(editingCell.row, editingCell.column); contextEndEditing?.(editingCell.row, editingCell.column);
@ -226,7 +226,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
const table = useMaterialReactTable(tableConfig); const table = useMaterialReactTable(tableConfig);
useEffect(() => { useEffect(() => {
if (!dataEditingCells.line_id) { if (!dataEditingCells || !dataEditingCells.line_id) {
setEditingCell(null); setEditingCell(null);
return; return;
} }

View File

@ -7,7 +7,6 @@ import React, {
useEffect, useEffect,
} from "react"; } from "react";
import { useWebSocket } from "../hooks/useWebSocket"; import { useWebSocket } from "../hooks/useWebSocket";
import { useCellLocks } from "../hooks/useCellLocks";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
const RealtimeContext = createContext(null); const RealtimeContext = createContext(null);
@ -29,7 +28,8 @@ export const RealtimeProvider = ({
}) => { }) => {
// Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH) // Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH)
const wsUrl = `${(process.env.REACT_APP_API_URL || process.env.REACT_APP_API_URL || process.env.REACT_APP_ROOT_PATH || "ws://localhost:8000").replace(/^http/, "ws")}/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ""}`; const wsUrl = `${(process.env.REACT_APP_API_URL || process.env.REACT_APP_API_URL || process.env.REACT_APP_ROOT_PATH || "ws://localhost:8000").replace(/^http/, "ws")}/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ""}`;
//для ячеек которые редактируются другими пользователями
const [lockedCells, setLockedCells] = useState([]);
const handleMessage = useCallback((data) => { const handleMessage = useCallback((data) => {
// Обработка ошибок // Обработка ошибок
if (data.error) { if (data.error) {
@ -83,13 +83,6 @@ export const RealtimeProvider = ({
wsUrl, wsUrl,
handleMessage, handleMessage,
); );
const {
isCellLocked,
tryLockCell,
unlockCell,
getCellLockInfo,
releaseAllLocks,
} = useCellLocks(null);
const onCellUpdateRef = useRef(null); const onCellUpdateRef = useRef(null);
const onRowAddRef = useRef(null); const onRowAddRef = useRef(null);
@ -350,18 +343,16 @@ export const RealtimeProvider = ({
updateCell, updateCell,
addRow, addRow,
deleteRow, deleteRow,
isCellLocked: (rowId, columnId) =>
isCellLocked(rowId, columnId, userId),
getCellLockInfo: (rowId, columnId) => getCellLockInfo(rowId, columnId),
tryLockCell: (rowId, columnId) => tryLockCell(rowId, columnId, userId),
subscribeToCellUpdates,
subscribeToRowAdds, subscribeToRowAdds,
subscribeToRowDeletes, subscribeToRowDeletes,
subscribeToErrors, subscribeToErrors,
subscribeToAuthSuccess, subscribeToAuthSuccess,
subscribeToCellEditStart, subscribeToCellEditStart,
subscribeToCellEditEnd, subscribeToCellEditEnd,
subscribeToCellUpdates,
retryLogin, retryLogin,
lockedCells,
setLockedCells,
releaseAllLocks: () => releaseAllLocks(userId), releaseAllLocks: () => releaseAllLocks(userId),
}} }}
> >

View File

@ -1,122 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react';
export const useCellLocks = (userId, lockTimeout = 30000) => {
const [lockedCells, setLockedCells] = useState(new Map());
const lockTimersRef = useRef(new Map());
const getCellKey = (rowId, columnId) => `${rowId}_${columnId}`;
const lockCell = useCallback((rowId, columnId, lockingUserId) => {
const cellKey = getCellKey(rowId, columnId);
setLockedCells(prev => {
const newLocks = new Map(prev);
const existingLock = newLocks.get(cellKey);
if (existingLock && existingLock.userId !== lockingUserId) {
return prev; // Cell already locked by another user
}
newLocks.set(cellKey, {
userId: lockingUserId,
lockedAt: Date.now(),
rowId,
columnId
});
return newLocks;
});
return true;
}, []);
const unlockCell = useCallback((rowId, columnId, unlockingUserId) => {
const cellKey = getCellKey(rowId, columnId);
setLockedCells(prev => {
const newLocks = new Map(prev);
const lock = newLocks.get(cellKey);
if (lock && lock.userId === unlockingUserId) {
newLocks.delete(cellKey);
}
return newLocks;
});
// Clear timer if exists
if (lockTimersRef.current.has(cellKey)) {
clearTimeout(lockTimersRef.current.get(cellKey));
lockTimersRef.current.delete(cellKey);
}
}, []);
const tryLockCell = useCallback((rowId, columnId, currentUserId) => {
const cellKey = getCellKey(rowId, columnId);
const existingLock = lockedCells.get(cellKey);
if (existingLock && existingLock.userId !== currentUserId) {
return false; // Cell is locked by another user
}
if (!existingLock) {
lockCell(rowId, columnId, currentUserId);
// Auto-unlock after timeout
const timer = setTimeout(() => {
unlockCell(rowId, columnId, currentUserId);
}, lockTimeout);
lockTimersRef.current.set(cellKey, timer);
}
return true;
}, [lockedCells, lockCell, unlockCell, lockTimeout]);
const releaseAllLocks = useCallback((userId) => {
setLockedCells(prev => {
const newLocks = new Map(prev);
for (const [key, lock] of newLocks.entries()) {
if (lock.userId === userId) {
newLocks.delete(key);
if (lockTimersRef.current.has(key)) {
clearTimeout(lockTimersRef.current.get(key));
lockTimersRef.current.delete(key);
}
}
}
return newLocks;
});
}, []);
const isCellLocked = useCallback((rowId, columnId, currentUserId) => {
const cellKey = getCellKey(rowId, columnId);
const lock = lockedCells.get(cellKey);
return lock && lock.userId !== currentUserId;
}, [lockedCells]);
const getCellLockInfo = useCallback((rowId, columnId) => {
const cellKey = getCellKey(rowId, columnId);
return lockedCells.get(cellKey);
}, [lockedCells]);
useEffect(() => {
return () => {
// Clear all timers on unmount
for (const timer of lockTimersRef.current.values()) {
clearTimeout(timer);
}
lockTimersRef.current.clear();
};
}, []);
return {
lockedCells,
lockCell,
unlockCell,
tryLockCell,
isCellLocked,
getCellLockInfo,
releaseAllLocks
};
};

View File

@ -20,6 +20,7 @@ const useRealtimeData = (formId, sheetName, direction) => {
subscribeToErrors, subscribeToErrors,
subscribeToCellEditStart, subscribeToCellEditStart,
subscribeToCellEditEnd, subscribeToCellEditEnd,
setLockedCells,
} = useRealtime(); } = useRealtime();
const formatedToSubRows = useCallback((depth, oldData) => { const formatedToSubRows = useCallback((depth, oldData) => {
@ -208,96 +209,28 @@ const useRealtimeData = (formId, sheetName, direction) => {
// Обработчик начала редактирования ячейки // Обработчик начала редактирования ячейки
const unsubscribeCellEditStart = subscribeToCellEditStart((data) => { const unsubscribeCellEditStart = subscribeToCellEditStart((data) => {
console.log(data);
console.log('START EDIT');
const dataCell = data.data; const dataCell = data.data;
dataCell.column = 'data.'+ dataCell.column; dataCell.column = "data." + dataCell.column;
setEditingCells(data.data) if (data.is_self) {
setEditingCells(data.data);
return;
}
// const { line_id, column } = data.data; const { line_id, column } = dataCell;
// const cellKey = `${line_id}_${column}`; const cellKey = `${line_id}_${column}`;
// // Сохраняем информацию о том, кто начал редактирование // Сохраняем информацию о том, кто начал редактирование
// setEditingCells((prev) => ({ setLockedCells((prev) => [...prev, cellKey]);
// ...prev,
// [cellKey]: {
// userId: data.user_id || "unknown",
// timestamp: Date.now(),
// },
// }));
// // Можно также добавить визуальную индикацию в данных
// setData((prevData) => {
// // Функция для обновления статуса редактирования в данных
// const updateCellEditingStatus = (items) => {
// return items.map((item) => {
// if (item.line_id === line_id) {
// // Находим нужную колонку и добавляем флаг редактирования
// const updatedItem = { ...item };
// if (updatedItem.columns && updatedItem.columns[column]) {
// updatedItem.columns[column] = {
// ...updatedItem.columns[column],
// isEditing: true,
// editingBy: data.user_id,
// };
// }
// return updatedItem;
// }
// if (item.subRows) {
// return {
// ...item,
// subRows: updateCellEditingStatus(item.subRows),
// };
// }
// return item;
// });
// };
// return updateCellEditingStatus(prevData);
// });
// console.log(`Cell ${cellKey} editing started by user ${data.user_id}`);
}); });
// Обработчик завершения редактирования ячейки // Обработчик завершения редактирования ячейки
const unsubscribeCellEditEnd = subscribeToCellEditEnd((data) => { const unsubscribeCellEditEnd = subscribeToCellEditEnd((data) => {
setEditingCells(null); setEditingCells(null);
const { line_id, column } = data.data; const dataCell = data.data;
dataCell.column = "data." + dataCell.column;
const { line_id, column } = dataCell;
const cellKey = `${line_id}_${column}`; const cellKey = `${line_id}_${column}`;
setLockedCells((prev) => prev.filter((c) => c !== cellKey));
// Удаляем информацию о редактировании
setEditingCells((prev) => {
const newState = { ...prev };
delete newState[cellKey];
return newState;
});
// Убираем визуальную индикацию в данных
setData((prevData) => {
const updateCellEditingStatus = (items) => {
return items.map((item) => {
if (item.line_id === line_id) {
const updatedItem = { ...item };
if (updatedItem.columns && updatedItem.columns[column]) {
// Удаляем флаги редактирования
const { isEditing, editingBy, ...restColumns } =
updatedItem.columns[column];
updatedItem.columns[column] = restColumns;
}
return updatedItem;
}
if (item.subRows) {
return {
...item,
subRows: updateCellEditingStatus(item.subRows),
};
}
return item;
});
};
return updateCellEditingStatus(prevData);
});
console.log(`Cell ${cellKey} editing ended`);
}); });
const unsubscribeErrors = subscribeToErrors((err) => { const unsubscribeErrors = subscribeToErrors((err) => {
@ -344,24 +277,6 @@ const useRealtimeData = (formId, sheetName, direction) => {
[formatedToSubRows], [formatedToSubRows],
); );
// Функция для проверки, редактируется ли ячейка
const isCellEditing = useCallback(
(lineId, column) => {
const cellKey = `${lineId}_${column}`;
return !!editingCells[cellKey];
},
[editingCells],
);
// Функция для получения информации о том, кто редактирует ячейку
const getCellEditingInfo = useCallback(
(lineId, column) => {
const cellKey = `${lineId}_${column}`;
return editingCells[cellKey] || null;
},
[editingCells],
);
return { return {
data, data,
setData, setData,
@ -369,8 +284,6 @@ const useRealtimeData = (formId, sheetName, direction) => {
isLoading, isLoading,
error, error,
editingCells, editingCells,
isCellEditing,
getCellEditingInfo,
}; };
}; };