fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
919
attached_assets/код_1775994765939.json
Normal file
919
attached_assets/код_1775994765939.json
Normal file
@@ -0,0 +1,919 @@
|
||||
const React = ctx.libs.React;
|
||||
const { useState, useEffect, useMemo, useCallback, useRef } = React;
|
||||
const {
|
||||
Card,
|
||||
Typography,
|
||||
Spin,
|
||||
Input,
|
||||
Button,
|
||||
Empty,
|
||||
Dropdown,
|
||||
Modal,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Space,
|
||||
Grid,
|
||||
} = ctx.libs.antd;
|
||||
const { MoreOutlined } = ctx.libs.antdIcons;
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { useBreakpoint } = Grid;
|
||||
const TASK_ID = 1;
|
||||
|
||||
const THEME = {
|
||||
bg: '#17191d',
|
||||
bg2: '#1d2025',
|
||||
panel: '#1f2329',
|
||||
panel2: '#242931',
|
||||
panel3: '#2a3038',
|
||||
border: '#343b46',
|
||||
text: '#eef2f6',
|
||||
muted: '#9aa4b2',
|
||||
faint: '#768191',
|
||||
accent: '#27c2b1',
|
||||
accent2: '#2f7cf6',
|
||||
accentSoft: 'rgba(39,194,177,.12)',
|
||||
msgBg: '#2a2e35',
|
||||
msgBgAlt: '#252a31',
|
||||
quoteBg: '#21262d',
|
||||
danger: '#ff7373',
|
||||
inputBg: '#1b1f25',
|
||||
top: '#1b1e23',
|
||||
};
|
||||
|
||||
const STATUS = {
|
||||
new: { label: 'Новая', color: '#8b949e' },
|
||||
in_progress: { label: 'В работе', color: '#2f81f7' },
|
||||
done: { label: 'Выполнена', color: '#3fb950' },
|
||||
cancelled: { label: 'Отменена', color: '#f85149' },
|
||||
};
|
||||
|
||||
const URGENCY = {
|
||||
today: { label: 'Сегодня', color: '#f85149' },
|
||||
tomorrow: { label: 'Завтра', color: '#d29922' },
|
||||
later: { label: 'Позже', color: '#2f81f7' },
|
||||
};
|
||||
|
||||
function getCurrentUserId() {
|
||||
try {
|
||||
return Number(ctx?.auth?.user?.id || 1);
|
||||
} catch (e) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function getUserLabel(id) {
|
||||
return `User #${id || '?'}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
return new Date(value).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch (e) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatClock(value) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDayLabel(value) {
|
||||
if (!value) return '';
|
||||
const d = new Date(value);
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
const key = (x) => `${x.getFullYear()}-${x.getMonth()}-${x.getDate()}`;
|
||||
if (key(d) === key(today)) return 'Сегодня';
|
||||
if (key(d) === key(yesterday)) return 'Вчера';
|
||||
return d.toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
function sortFlat(list) {
|
||||
return [...(Array.isArray(list) ? list : [])].sort(
|
||||
(a, b) => new Date(a.createdAt) - new Date(b.createdAt)
|
||||
);
|
||||
}
|
||||
|
||||
function avatarColor(id) {
|
||||
const colors = ['#b45309', '#7c3aed', '#0ea5e9', '#15803d', '#db2777', '#ea580c'];
|
||||
return colors[Math.abs(Number(id || 0)) % colors.length];
|
||||
}
|
||||
|
||||
function Avatar({ userId, size = 34 }) {
|
||||
return React.createElement('div', {
|
||||
style: {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 10,
|
||||
background: avatarColor(userId),
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}
|
||||
}, String(userId || '?'));
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }) {
|
||||
return React.createElement('div', {
|
||||
style: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '110px 1fr',
|
||||
gap: 10,
|
||||
padding: '10px 0',
|
||||
borderBottom: `1px solid ${THEME.border}`,
|
||||
alignItems: 'start',
|
||||
}
|
||||
},
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
color: THEME.faint,
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '.04em',
|
||||
fontWeight: 700,
|
||||
}
|
||||
}, label),
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
color: THEME.text,
|
||||
fontSize: 14,
|
||||
minWidth: 0,
|
||||
wordBreak: 'break-word',
|
||||
}
|
||||
}, value || '—')
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.lg;
|
||||
|
||||
const [task, setTask] = useState(null);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [loadingTask, setLoadingTask] = useState(true);
|
||||
const [loadingComments, setLoadingComments] = useState(true);
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const [replyTo, setReplyTo] = useState(null);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [editText, setEditText] = useState('');
|
||||
const [allowedDestroyIds, setAllowedDestroyIds] = useState([]);
|
||||
const [highlightId, setHighlightId] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('info');
|
||||
|
||||
const listRef = useRef(null);
|
||||
const messageRefs = useRef({});
|
||||
const previousCountRef = useRef(0);
|
||||
const currentUserId = getCurrentUserId();
|
||||
|
||||
const loadTask = useCallback(async () => {
|
||||
setLoadingTask(true);
|
||||
try {
|
||||
const resp = await ctx.request({
|
||||
url: 'tasks:get',
|
||||
params: { filterByTk: TASK_ID }
|
||||
});
|
||||
setTask(resp?.data?.data || null);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ctx.message.error('Не удалось загрузить задачу');
|
||||
} finally {
|
||||
setLoadingTask(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadComments = useCallback(async () => {
|
||||
setLoadingComments(true);
|
||||
try {
|
||||
const resp = await ctx.request({
|
||||
url: `tasks/${TASK_ID}/comments:list`,
|
||||
params: {
|
||||
pageSize: 300,
|
||||
sort: ['createdAt']
|
||||
}
|
||||
});
|
||||
const rows = Array.isArray(resp?.data?.data) ? resp.data.data : [];
|
||||
const destroyIds = resp?.data?.meta?.allowedActions?.destroy || [];
|
||||
setComments(rows);
|
||||
setAllowedDestroyIds(Array.isArray(destroyIds) ? destroyIds : []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ctx.message.error('Не удалось загрузить комментарии');
|
||||
} finally {
|
||||
setLoadingComments(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTask();
|
||||
loadComments();
|
||||
}, []);
|
||||
|
||||
const sortedComments = useMemo(() => sortFlat(comments), [comments]);
|
||||
|
||||
const commentsMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
comments.forEach((item) => map.set(item.id, item));
|
||||
return map;
|
||||
}, [comments]);
|
||||
|
||||
const commentBlocks = useMemo(() => {
|
||||
const blocks = [];
|
||||
let lastDay = null;
|
||||
|
||||
sortedComments.forEach((item) => {
|
||||
const day = formatDayLabel(item.createdAt);
|
||||
if (day !== lastDay) {
|
||||
blocks.push({ type: 'day', key: `day-${day}-${item.id}`, label: day });
|
||||
lastDay = day;
|
||||
}
|
||||
blocks.push({ type: 'message', key: `msg-${item.id}`, item });
|
||||
});
|
||||
|
||||
return blocks;
|
||||
}, [sortedComments]);
|
||||
|
||||
useEffect(() => {
|
||||
const prevCount = previousCountRef.current;
|
||||
const hasNewMessages = comments.length > prevCount;
|
||||
previousCountRef.current = comments.length;
|
||||
|
||||
if (!listRef.current || !hasNewMessages) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [comments.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightId) return;
|
||||
const timer = setTimeout(() => setHighlightId(null), 1800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [highlightId]);
|
||||
|
||||
const jumpToMessage = (id) => {
|
||||
const node = messageRefs.current[id];
|
||||
if (node && typeof node.scrollIntoView === 'function') {
|
||||
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setHighlightId(id);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!text.trim()) return;
|
||||
setPosting(true);
|
||||
try {
|
||||
await ctx.request({
|
||||
url: `tasks/${TASK_ID}/comments:create`,
|
||||
method: 'post',
|
||||
data: {
|
||||
content: text.trim(),
|
||||
task_id: TASK_ID,
|
||||
parentId: replyTo?.id ?? null,
|
||||
}
|
||||
});
|
||||
setText('');
|
||||
setReplyTo(null);
|
||||
await loadComments();
|
||||
ctx.message.success('Комментарий отправлен');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ctx.message.error('Не удалось отправить комментарий');
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (item) => {
|
||||
setEditing(item);
|
||||
setEditText(item?.content || '');
|
||||
};
|
||||
|
||||
const updateComment = async () => {
|
||||
if (!editing?.id || !editText.trim()) return;
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await ctx.request({
|
||||
url: `tasks/${TASK_ID}/comments:update`,
|
||||
method: 'post',
|
||||
params: { filterByTk: editing.id },
|
||||
data: { content: editText.trim() }
|
||||
});
|
||||
setEditing(null);
|
||||
setEditText('');
|
||||
await loadComments();
|
||||
ctx.message.success('Комментарий изменён');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ctx.message.error('Не удалось изменить комментарий');
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeComment = async (item) => {
|
||||
try {
|
||||
await ctx.request({
|
||||
url: `tasks/${TASK_ID}/comments:destroy`,
|
||||
method: 'post',
|
||||
params: { filterByTk: item.id }
|
||||
});
|
||||
await loadComments();
|
||||
ctx.message.success('Комментарий удалён');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ctx.message.error('Не удалось удалить комментарий');
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessage = (item, index) => {
|
||||
const parent = item.parentId ? commentsMap.get(item.parentId) : null;
|
||||
const canDelete = allowedDestroyIds.includes(item.id) || allowedDestroyIds.length > 0;
|
||||
const canEdit = Number(item.createdById) === Number(currentUserId);
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'reply', label: 'Ответить' },
|
||||
{ key: 'edit', label: 'Изменить', disabled: !canEdit },
|
||||
{
|
||||
key: 'delete',
|
||||
label: React.createElement('span', { style: { color: THEME.danger } }, 'Удалить'),
|
||||
disabled: !canDelete,
|
||||
},
|
||||
];
|
||||
|
||||
return React.createElement('div', {
|
||||
key: item.id,
|
||||
ref: (el) => { messageRefs.current[item.id] = el; },
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: isMobile ? 10 : 12,
|
||||
padding: isMobile ? '12px' : '14px 16px',
|
||||
background: index % 2 === 0 ? THEME.msgBg : THEME.msgBgAlt,
|
||||
borderTop: `1px solid rgba(255,255,255,.03)`,
|
||||
outline: highlightId === item.id ? '1px solid rgba(39,194,177,.75)' : '1px solid transparent',
|
||||
}
|
||||
},
|
||||
React.createElement(Avatar, { userId: item.createdById, size: isMobile ? 30 : 34 }),
|
||||
|
||||
React.createElement('div', { style: { flex: 1, minWidth: 0 } },
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
marginBottom: 6,
|
||||
}
|
||||
},
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
minWidth: 0,
|
||||
}
|
||||
},
|
||||
React.createElement('span', {
|
||||
style: {
|
||||
color: THEME.text,
|
||||
fontWeight: 700,
|
||||
fontSize: isMobile ? 14 : 15,
|
||||
}
|
||||
}, getUserLabel(item.createdById)),
|
||||
React.createElement('span', {
|
||||
style: {
|
||||
color: THEME.muted,
|
||||
fontSize: 12,
|
||||
},
|
||||
title: formatDateTime(item.createdAt)
|
||||
}, formatClock(item.createdAt))
|
||||
),
|
||||
|
||||
React.createElement(Dropdown, {
|
||||
trigger: ['click'],
|
||||
placement: 'bottomRight',
|
||||
menu: {
|
||||
items: menuItems,
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reply') setReplyTo(item);
|
||||
if (key === 'edit') openEdit(item);
|
||||
if (key === 'delete') removeComment(item);
|
||||
},
|
||||
},
|
||||
},
|
||||
React.createElement(Button, {
|
||||
type: 'text',
|
||||
size: 'small',
|
||||
icon: React.createElement(MoreOutlined),
|
||||
style: {
|
||||
color: THEME.muted,
|
||||
minWidth: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
|
||||
parent && React.createElement('button', {
|
||||
onClick: () => jumpToMessage(parent.id),
|
||||
style: {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
border: 'none',
|
||||
background: THEME.quoteBg,
|
||||
borderLeft: `3px solid ${THEME.accent2}`,
|
||||
padding: '8px 10px',
|
||||
marginBottom: 10,
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
}
|
||||
},
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
color: THEME.accent2,
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
marginBottom: 2,
|
||||
}
|
||||
}, getUserLabel(parent.createdById)),
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
color: THEME.muted,
|
||||
fontSize: 13,
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}
|
||||
}, parent.content || '')
|
||||
),
|
||||
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
color: THEME.text,
|
||||
fontSize: isMobile ? 15 : 16,
|
||||
lineHeight: 1.55,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}
|
||||
}, item.content || '')
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const taskStatus = STATUS[task?.status];
|
||||
const taskUrgency = URGENCY[task?.urgency];
|
||||
|
||||
const renderSideContent = () => {
|
||||
if (loadingTask) return React.createElement(Spin);
|
||||
if (!task) return React.createElement(Empty, { description: 'Задача не найдена' });
|
||||
|
||||
if (activeTab === 'info') {
|
||||
return React.createElement('div', null,
|
||||
React.createElement(InfoRow, { label: 'ID', value: String(task.id || TASK_ID) }),
|
||||
React.createElement(InfoRow, { label: 'Задача', value: task.what_to_do || '—' }),
|
||||
React.createElement(InfoRow, { label: 'Статус', value: taskStatus?.label || task.status || '—' }),
|
||||
React.createElement(InfoRow, { label: 'Срочность', value: taskUrgency?.label || task.urgency || '—' }),
|
||||
React.createElement(InfoRow, { label: 'Срок', value: task.deadline ? formatDateTime(task.deadline) : '—' }),
|
||||
React.createElement(InfoRow, { label: 'Создана', value: task.createdAt ? formatDateTime(task.createdAt) : '—' }),
|
||||
React.createElement(InfoRow, { label: 'Комментариев', value: String(comments.length) })
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 'related') {
|
||||
return React.createElement('div', {
|
||||
style: { color: THEME.muted, fontSize: 14, lineHeight: 1.6 }
|
||||
}, 'Здесь можно будет показать связанные записи, подзадачи, ссылки на объекты и связи задачи. Сейчас вкладка подготовлена как контейнер под дальнейшую интеграцию.');
|
||||
}
|
||||
|
||||
if (activeTab === 'files') {
|
||||
return React.createElement('div', {
|
||||
style: { color: THEME.muted, fontSize: 14, lineHeight: 1.6 }
|
||||
}, 'Здесь можно будет вывести вложения задачи и файловые записи. Сейчас вкладка готова как UX-каркас под файлы и действия с ними.');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const tabButton = (key, label) => React.createElement('button', {
|
||||
onClick: () => setActiveTab(key),
|
||||
style: {
|
||||
minHeight: 42,
|
||||
padding: '10px 12px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: activeTab === key ? THEME.panel3 : 'transparent',
|
||||
color: activeTab === key ? THEME.text : THEME.muted,
|
||||
borderBottom: !isMobile && activeTab === key ? `2px solid ${THEME.accent}` : '2px solid transparent',
|
||||
borderRadius: isMobile ? 10 : 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
}, label);
|
||||
|
||||
return React.createElement('div', {
|
||||
style: {
|
||||
background: THEME.bg,
|
||||
minHeight: '100vh',
|
||||
padding: isMobile ? 8 : 12,
|
||||
}
|
||||
},
|
||||
React.createElement(Card, {
|
||||
style: {
|
||||
background: THEME.panel,
|
||||
borderColor: THEME.border,
|
||||
borderRadius: isMobile ? 12 : 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
bodyStyle: { padding: isMobile ? 10 : 12 }
|
||||
},
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'stretch' : 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
}
|
||||
},
|
||||
React.createElement('div', null,
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
color: THEME.text,
|
||||
fontSize: isMobile ? 20 : 24,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
}
|
||||
}, task?.what_to_do || `Задача #${TASK_ID}`),
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 8,
|
||||
color: THEME.muted,
|
||||
fontSize: 12,
|
||||
}
|
||||
},
|
||||
taskStatus && React.createElement(Tag, { color: taskStatus.color }, taskStatus.label),
|
||||
taskUrgency && React.createElement(Tag, { color: taskUrgency.color }, taskUrgency.label),
|
||||
React.createElement('span', null, `${comments.length} комментариев`)
|
||||
)
|
||||
),
|
||||
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}
|
||||
},
|
||||
React.createElement(Button, {
|
||||
style: {
|
||||
minHeight: 40,
|
||||
borderRadius: 10,
|
||||
background: THEME.panel2,
|
||||
color: THEME.text,
|
||||
borderColor: THEME.border,
|
||||
}
|
||||
}, 'Назад'),
|
||||
React.createElement(Button, {
|
||||
style: {
|
||||
minHeight: 40,
|
||||
borderRadius: 10,
|
||||
background: THEME.panel2,
|
||||
color: THEME.text,
|
||||
borderColor: THEME.border,
|
||||
}
|
||||
}, 'Действия'),
|
||||
React.createElement(Button, {
|
||||
type: 'primary',
|
||||
style: {
|
||||
minHeight: 40,
|
||||
borderRadius: 10,
|
||||
background: THEME.accent,
|
||||
borderColor: THEME.accent,
|
||||
color: '#08201d',
|
||||
boxShadow: 'none',
|
||||
}
|
||||
}, 'Утвердить')
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
{isMobile
|
||||
? React.createElement('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
overflowX: 'auto',
|
||||
paddingBottom: 8,
|
||||
marginBottom: 10,
|
||||
}
|
||||
},
|
||||
tabButton('info', 'Инфо'),
|
||||
tabButton('related', 'Связанные'),
|
||||
tabButton('files', 'Файлы')
|
||||
)
|
||||
: null}
|
||||
|
||||
,React.createElement(Row, { gutter: isMobile ? 0 : 16, align: 'stretch' },
|
||||
React.createElement(Col, { xs: 24, lg: 15 },
|
||||
React.createElement(Card, {
|
||||
style: {
|
||||
background: THEME.panel,
|
||||
borderColor: THEME.border,
|
||||
borderRadius: isMobile ? 12 : 14,
|
||||
overflow: 'hidden',
|
||||
marginBottom: isMobile ? 12 : 0,
|
||||
},
|
||||
bodyStyle: { padding: 0 }
|
||||
},
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
padding: isMobile ? '10px 12px' : '12px 16px',
|
||||
borderBottom: `1px solid ${THEME.border}`,
|
||||
background: THEME.top,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
}
|
||||
},
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
}
|
||||
},
|
||||
React.createElement('div', {
|
||||
style: { color: THEME.text, fontWeight: 700, fontSize: 15 }
|
||||
}, 'Обсуждение'),
|
||||
React.createElement(Text, {
|
||||
style: { color: THEME.muted, fontSize: 12 }
|
||||
}, `${comments.length} сообщений`)
|
||||
)
|
||||
),
|
||||
|
||||
React.createElement('div', {
|
||||
ref: listRef,
|
||||
style: {
|
||||
maxHeight: isMobile ? '55vh' : '62vh',
|
||||
overflowY: 'auto',
|
||||
background: THEME.panel2,
|
||||
}
|
||||
},
|
||||
loadingComments
|
||||
? React.createElement('div', { style: { padding: 16 } }, React.createElement(Spin))
|
||||
: commentBlocks.length === 0
|
||||
? React.createElement('div', { style: { padding: 16 } }, React.createElement(Empty, { description: 'Комментариев пока нет' }))
|
||||
: commentBlocks.map((block, idx) => {
|
||||
if (block.type === 'day') {
|
||||
return React.createElement('div', {
|
||||
key: block.key,
|
||||
style: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '12px 10px 8px',
|
||||
background: THEME.panel2,
|
||||
}
|
||||
},
|
||||
React.createElement('span', {
|
||||
style: {
|
||||
background: THEME.panel3,
|
||||
color: THEME.muted,
|
||||
fontSize: 12,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${THEME.border}`,
|
||||
}
|
||||
}, block.label)
|
||||
);
|
||||
}
|
||||
return renderMessage(block.item, idx);
|
||||
})
|
||||
),
|
||||
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
padding: isMobile ? 12 : 16,
|
||||
borderTop: `1px solid ${THEME.border}`,
|
||||
background: THEME.panel,
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 2,
|
||||
}
|
||||
},
|
||||
replyTo && React.createElement('div', {
|
||||
style: {
|
||||
marginBottom: 10,
|
||||
padding: '8px 10px',
|
||||
borderLeft: `3px solid ${THEME.accent}`,
|
||||
background: THEME.accentSoft,
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
}
|
||||
},
|
||||
React.createElement('button', {
|
||||
onClick: () => jumpToMessage(replyTo.id),
|
||||
style: {
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}
|
||||
},
|
||||
React.createElement('div', {
|
||||
style: { color: THEME.accent, fontWeight: 700, fontSize: 13, marginBottom: 2 }
|
||||
}, getUserLabel(replyTo.createdById)),
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
color: THEME.muted,
|
||||
fontSize: 13,
|
||||
whiteSpace: 'pre-wrap',
|
||||
maxHeight: 36,
|
||||
overflow: 'hidden',
|
||||
}
|
||||
}, replyTo.content || '')
|
||||
),
|
||||
React.createElement(Button, {
|
||||
type: 'text',
|
||||
size: 'small',
|
||||
onClick: () => setReplyTo(null),
|
||||
style: {
|
||||
color: THEME.muted,
|
||||
minWidth: 36,
|
||||
height: 36,
|
||||
}
|
||||
}, '✕')
|
||||
),
|
||||
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
alignItems: 'flex-start',
|
||||
}
|
||||
},
|
||||
!isMobile && React.createElement(Avatar, { userId: currentUserId, size: 32 }),
|
||||
React.createElement('div', { style: { flex: 1 } },
|
||||
React.createElement(Input.TextArea, {
|
||||
value: text,
|
||||
onChange: (e) => setText(e.target.value),
|
||||
placeholder: isMobile ? 'Комментарий...' : 'Написать комментарий...',
|
||||
autoSize: { minRows: isMobile ? 3 : 4, maxRows: 8 },
|
||||
style: {
|
||||
background: THEME.inputBg,
|
||||
color: THEME.text,
|
||||
borderColor: THEME.border,
|
||||
borderRadius: 12,
|
||||
resize: 'none',
|
||||
fontSize: 16,
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
}),
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
marginTop: 10,
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center',
|
||||
gap: 10,
|
||||
}
|
||||
},
|
||||
React.createElement(Text, {
|
||||
style: { fontSize: 12, color: THEME.muted }
|
||||
}, isMobile ? 'Enter — отправить' : 'Enter — отправить, Shift + Enter — новая строка'),
|
||||
React.createElement(Button, {
|
||||
type: 'primary',
|
||||
loading: posting,
|
||||
disabled: !text.trim(),
|
||||
onClick: submit,
|
||||
block: isMobile,
|
||||
style: {
|
||||
minHeight: 44,
|
||||
borderRadius: 12,
|
||||
background: THEME.accent,
|
||||
borderColor: THEME.accent,
|
||||
color: '#08201d',
|
||||
boxShadow: 'none',
|
||||
fontWeight: 700,
|
||||
}
|
||||
}, replyTo ? 'Ответить' : 'Отправить')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
!isMobile && React.createElement(Col, { xs: 24, lg: 9 },
|
||||
React.createElement(Card, {
|
||||
style: {
|
||||
background: THEME.panel,
|
||||
borderColor: THEME.border,
|
||||
borderRadius: 14,
|
||||
height: '100%',
|
||||
},
|
||||
bodyStyle: { padding: 0 }
|
||||
},
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
borderBottom: `1px solid ${THEME.border}`,
|
||||
paddingInline: 8,
|
||||
gap: 2,
|
||||
}
|
||||
},
|
||||
tabButton('info', 'Инфо'),
|
||||
tabButton('related', 'Связанные'),
|
||||
tabButton('files', 'Файлы')
|
||||
),
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
padding: 16,
|
||||
minHeight: 420,
|
||||
}
|
||||
}, renderSideContent())
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
{isMobile
|
||||
? React.createElement(Card, {
|
||||
style: {
|
||||
background: THEME.panel,
|
||||
borderColor: THEME.border,
|
||||
borderRadius: 12,
|
||||
},
|
||||
bodyStyle: { padding: 14 }
|
||||
}, renderSideContent())
|
||||
: null}
|
||||
|
||||
,React.createElement(Modal, {
|
||||
open: !!editing,
|
||||
title: 'Изменить комментарий',
|
||||
onCancel: () => {
|
||||
setEditing(null);
|
||||
setEditText('');
|
||||
},
|
||||
onOk: updateComment,
|
||||
okText: 'Сохранить',
|
||||
cancelText: 'Отмена',
|
||||
confirmLoading: savingEdit,
|
||||
},
|
||||
React.createElement(Input.TextArea, {
|
||||
value: editText,
|
||||
onChange: (e) => setEditText(e.target.value),
|
||||
autoSize: { minRows: 4, maxRows: 10 },
|
||||
placeholder: 'Новый текст комментария...'
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
ctx.render(React.createElement(App));
|
||||
Reference in New Issue
Block a user