feat(ui): gemini-style top-anchored scroll, centered chat layout and task drawer sync (closes #49)
This commit is contained in:
@@ -229,13 +229,53 @@ async function saveManualPromptDraft(btnEl) {
|
||||
const highlightedHtml = buildHighlightedPromptHtml(newText, baseline);
|
||||
previewTextEl.innerHTML = highlightedHtml;
|
||||
}
|
||||
|
||||
let actionButtonsContainer = cardContainer.querySelector(".action-buttons-container");
|
||||
if (actionButtonsContainer) {
|
||||
actionButtonsContainer.innerHTML = `
|
||||
<button type="button" onclick="handleActionButtonClick('подтверждаю')"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
<span>Подтвердить</span>
|
||||
</button>
|
||||
<button type="button" onclick="handleActionButtonClick('отмена')"
|
||||
class="bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300 font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
<span>Отменить</span>
|
||||
</button>
|
||||
<button type="button" onclick="openInlinePromptEditor(this)"
|
||||
class="bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300 font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
|
||||
<i class="fa-solid fa-pen-to-square text-xs"></i>
|
||||
<span>✏️ Редактировать</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Ошибка сохранения черновика: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- [CHAT SEND PIPELINE] ---
|
||||
// ⭐️ Абсолютный расчет позиции скролла к верху видимой области
|
||||
function scrollToTurnTop(anchorId) {
|
||||
const chatWindow = document.getElementById("chat-window");
|
||||
const anchorEl = document.getElementById(anchorId);
|
||||
if (!chatWindow || !anchorEl) return;
|
||||
|
||||
const windowRect = chatWindow.getBoundingClientRect();
|
||||
const anchorRect = anchorEl.getBoundingClientRect();
|
||||
const currentScroll = chatWindow.scrollTop;
|
||||
|
||||
// Новая позиция скролла = текущий сдвиг + расстояние от верхней кромки окна до элемента
|
||||
const targetTop = currentScroll + (anchorRect.top - windowRect.top) - 12;
|
||||
|
||||
chatWindow.scrollTo({
|
||||
top: Math.max(0, targetTop),
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
|
||||
// --- [CHAT SEND PIPELINE (GEMINI STYLE TOP-SCROLL)] ---
|
||||
async function sendMessage(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
|
||||
@@ -257,18 +297,29 @@ async function sendMessage(e) {
|
||||
</div>` + userDisplayHtml;
|
||||
}
|
||||
|
||||
const turnId = "turn-" + Date.now();
|
||||
|
||||
const userMsgHtml = `
|
||||
<div class="flex justify-end mb-3">
|
||||
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
|
||||
<div id="${turnId}" class="chat-turn-anchor flex justify-end mb-3 pt-2 scroll-mt-3">
|
||||
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-xl text-xs sm:text-sm shadow-sm leading-relaxed">
|
||||
${userDisplayHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
|
||||
|
||||
// Вставляем вопрос строго перед невидимой распоркой
|
||||
const spacer = document.getElementById("chat-bottom-spacer");
|
||||
if (spacer) {
|
||||
spacer.insertAdjacentHTML("beforebegin", userMsgHtml);
|
||||
} else {
|
||||
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
|
||||
}
|
||||
|
||||
input.value = "";
|
||||
updateInputHeight(input);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
|
||||
// Скроллим сразу при отправке вопроса
|
||||
setTimeout(() => scrollToTurnTop(turnId), 50);
|
||||
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = true;
|
||||
@@ -384,7 +435,6 @@ async function sendMessage(e) {
|
||||
snapshotsWidgetHtml = renderSnapshotsCard(actionData.data);
|
||||
}
|
||||
|
||||
// ⭐️ БЛОК КАРТОЧКИ СКАЧИВАНИЯ ФАЙЛА
|
||||
let fileDownloadHtml = "";
|
||||
if (actionData && actionData.type === "FILE_DOWNLOAD_CARD") {
|
||||
fileDownloadHtml = `
|
||||
@@ -409,20 +459,28 @@ async function sendMessage(e) {
|
||||
const maxWidthClass = isWideWidget ? "max-w-4xl w-full" : "max-w-2xl";
|
||||
|
||||
const botMsgHtml = `
|
||||
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm ${maxWidthClass} mb-3" data-original-baseline="${escapeHtml(baselineText)}">
|
||||
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
|
||||
</p>
|
||||
<div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
|
||||
${previewEditorHtml}
|
||||
${interactiveWidgetHtml}
|
||||
${snapshotsWidgetHtml}
|
||||
${fileDownloadHtml}
|
||||
${actionButtonsHtml}
|
||||
</div>
|
||||
`;
|
||||
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm w-full mb-3" data-original-baseline="${escapeHtml(baselineText)}">
|
||||
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
|
||||
</p>
|
||||
<div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
|
||||
${previewEditorHtml}
|
||||
${interactiveWidgetHtml}
|
||||
${snapshotsWidgetHtml}
|
||||
${fileDownloadHtml}
|
||||
${actionButtonsHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Вставляем карточку ответа строго перед невидимой распоркой
|
||||
if (spacer) {
|
||||
spacer.insertAdjacentHTML("beforebegin", botMsgHtml);
|
||||
} else {
|
||||
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
|
||||
}
|
||||
|
||||
// Скроллим к началу хода после рендера карточки ответа
|
||||
setTimeout(() => scrollToTurnTop(turnId), 100);
|
||||
|
||||
clearAttachedFile();
|
||||
|
||||
@@ -437,8 +495,12 @@ async function sendMessage(e) {
|
||||
Ошибка связи с сервером.
|
||||
</div>
|
||||
`;
|
||||
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
if (spacer) {
|
||||
spacer.insertAdjacentHTML("beforebegin", errorHtml);
|
||||
} else {
|
||||
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
|
||||
}
|
||||
setTimeout(() => scrollToTurnTop(turnId), 50);
|
||||
setInputLocked(false);
|
||||
} finally {
|
||||
const inputEl = document.getElementById("user-input");
|
||||
|
||||
@@ -1,164 +1,117 @@
|
||||
/**
|
||||
===============================================================================
|
||||
FILE: modules/web_api/static/js/tasks.js
|
||||
ROLE: Управление боковой панелью задач (Drawer), фильтрация и рендеринг карточек.
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[DRAWER_TOGGLE]: Открытие и закрытие выезжающей панели.
|
||||
- ANCHOR[TASKS_FILTER]: Фильтрация списка (ALL, IN_PROGRESS, BACKLOG, COMPLETED).
|
||||
- ANCHOR[TASKS_LOAD_FETCH]: Асинхронная загрузка задач через /api/v1/tasks.
|
||||
- ANCHOR[TASKS_RENDER_DOM]: Генерация HTML-карточек в боковом меню.
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / static / js
|
||||
ROLE: Загрузка, фильтрация и рендеринг списка задач в боковой панели (Drawer).
|
||||
===============================================================================
|
||||
*/
|
||||
|
||||
let currentFilter = 'ALL';
|
||||
let allTasks = [];
|
||||
let currentTaskFilter = 'IN_PROGRESS';
|
||||
|
||||
// --- [SECTION 1: DRAWER TOGGLE] --- # ANCHOR[DRAWER_TOGGLE]
|
||||
function toggleDrawer() {
|
||||
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
|
||||
const drawer = document.getElementById("task-drawer");
|
||||
const backdrop = document.getElementById("drawer-backdrop");
|
||||
if (!drawer) return;
|
||||
|
||||
const isHidden = drawer.classList.contains("translate-x-full");
|
||||
if (isHidden) {
|
||||
drawer.classList.remove("translate-x-full");
|
||||
if (backdrop) backdrop.classList.remove("hidden");
|
||||
loadTasks();
|
||||
} else {
|
||||
drawer.classList.add("translate-x-full");
|
||||
if (backdrop) backdrop.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// --- [SECTION 2: FILTER CONTROL] --- # ANCHOR[TASKS_FILTER]
|
||||
function setFilter(status) {
|
||||
currentFilter = status;
|
||||
["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
|
||||
const btn = document.getElementById(`filter-${f}`);
|
||||
if (btn) {
|
||||
btn.className = (f === status)
|
||||
? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap"
|
||||
: "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap";
|
||||
}
|
||||
});
|
||||
renderTasks();
|
||||
}
|
||||
|
||||
// --- [SECTION 3: ASYNC DATA FETCH] --- # ANCHOR[TASKS_LOAD_FETCH]
|
||||
async function loadTasks() {
|
||||
const badge = document.getElementById("task-count-badge");
|
||||
const container = document.getElementById("tasks-container");
|
||||
|
||||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
|
||||
|
||||
if (isGuest || !token) {
|
||||
if (badge) badge.innerText = "0";
|
||||
return;
|
||||
}
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/tasks", {
|
||||
headers: {
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
if (typeof logout === 'function') logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
allTasks = data;
|
||||
} else if (data && Array.isArray(data.tasks)) {
|
||||
allTasks = data.tasks;
|
||||
} else if (data && typeof data === 'object') {
|
||||
allTasks = Object.values(data).find(val => Array.isArray(val)) || [];
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
renderSidebarTasks(data.tasks || []);
|
||||
} else {
|
||||
allTasks = [];
|
||||
}
|
||||
|
||||
if (badge) {
|
||||
badge.innerText = allTasks.length.toString();
|
||||
}
|
||||
|
||||
renderTasks();
|
||||
|
||||
} catch (err) {
|
||||
console.error("[Tasks Error]", err);
|
||||
if (badge) badge.innerText = "0";
|
||||
if (container) {
|
||||
container.innerHTML = `<div class="text-red-500 text-xs py-8 text-center font-medium">Ошибка обработки списка задач</div>`;
|
||||
renderSidebarError("Ошибка доступа. Авторизуйтесь снова.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Ошибка загрузки задач:", e);
|
||||
renderSidebarError("Ошибка сети. Сервер недоступен.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM]
|
||||
function renderTasks() {
|
||||
const container = document.getElementById("tasks-container");
|
||||
function renderSidebarError(msg) {
|
||||
// Поддерживаем оба варианта ID (новый и старый) для обратной совместимости
|
||||
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
|
||||
if (container) {
|
||||
container.innerHTML = `<div class="text-center py-8 text-xs text-rose-500 font-semibold">${msg}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterTasksByTab(status) {
|
||||
currentTaskFilter = status;
|
||||
|
||||
// Сброс стилей всех кнопок-вкладок
|
||||
document.querySelectorAll('.task-tab-btn').forEach(btn => {
|
||||
btn.classList.remove('text-indigo-600', 'bg-indigo-50');
|
||||
btn.classList.add('text-slate-600');
|
||||
});
|
||||
|
||||
// Установка активного стиля для выбранной вкладки
|
||||
const activeBtnId = {
|
||||
'IN_PROGRESS': 'tab-in-progress',
|
||||
'BACKLOG': 'tab-backlog',
|
||||
'COMPLETED': 'tab-completed',
|
||||
'ALL': 'tab-all'
|
||||
}[status];
|
||||
|
||||
if (activeBtnId) {
|
||||
const btn = document.getElementById(activeBtnId);
|
||||
if (btn) {
|
||||
btn.classList.remove('text-slate-600', 'hover:bg-slate-100');
|
||||
btn.classList.add('text-indigo-600', 'bg-indigo-50');
|
||||
}
|
||||
}
|
||||
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
function renderSidebarTasks(tasks) {
|
||||
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
|
||||
if (!container) return;
|
||||
|
||||
if (!Array.isArray(allTasks)) {
|
||||
allTasks = [];
|
||||
let filtered = tasks;
|
||||
if (currentTaskFilter !== 'ALL') {
|
||||
filtered = tasks.filter(t => t.status === currentTaskFilter);
|
||||
}
|
||||
|
||||
const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
|
||||
container.innerHTML = `<div class="text-center py-8 text-[11px] font-medium text-slate-400 bg-slate-50 rounded-xl border border-dashed border-slate-200">Нет задач в этой категории</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = filtered.map(t => {
|
||||
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
|
||||
let statusLabel = "В планах";
|
||||
let cardBg = "bg-white";
|
||||
const isCompleted = t.status === 'COMPLETED';
|
||||
const priorityColor = t.priority === 'HIGH' || t.priority === 'CRITICAL'
|
||||
? 'text-rose-600 bg-rose-50 border-rose-200'
|
||||
: t.priority === 'MEDIUM'
|
||||
? 'text-amber-600 bg-amber-50 border-amber-200'
|
||||
: 'text-slate-600 bg-slate-50 border-slate-200';
|
||||
|
||||
if (t.status === "COMPLETED") {
|
||||
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
|
||||
statusLabel = "Завершено";
|
||||
cardBg = "bg-emerald-50/20";
|
||||
} else if (t.status === "IN_PROGRESS") {
|
||||
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
|
||||
statusLabel = "В работе";
|
||||
cardBg = "bg-amber-50/20 border-amber-200";
|
||||
}
|
||||
|
||||
let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200";
|
||||
if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold";
|
||||
|
||||
let dueDateHtml = t.due_date ? `
|
||||
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
|
||||
<i class="fa-solid fa-clock text-amber-600"></i>
|
||||
<span>Срок: ${t.due_date}</span>
|
||||
</div>` : "";
|
||||
|
||||
return `
|
||||
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id || t.id || 'TASK'}</span>
|
||||
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
|
||||
</div>
|
||||
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${statusLabel}</span>
|
||||
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-indigo-400 hover:shadow-md transition cursor-pointer flex flex-col gap-2 group"
|
||||
onclick="handleActionButtonClick('покажи задачу ${t.id}')">
|
||||
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-wider group-hover:text-indigo-500 transition">#${t.id}</span>
|
||||
${isCompleted
|
||||
? `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md bg-emerald-50 text-emerald-600 border border-emerald-200 shadow-sm"><i class="fa-solid fa-check mr-0.5"></i> Готово</span>`
|
||||
: `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md ${priorityColor} shadow-sm">${t.priority || 'LOW'}</span>`
|
||||
}
|
||||
</div>
|
||||
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
|
||||
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
|
||||
<i class="fa-solid fa-folder-closed text-slate-300"></i>
|
||||
<span>${t.module || 'General'}</span>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-700 leading-snug line-clamp-3">${escapeHtml(t.title || 'Без названия')}</div>
|
||||
|
||||
<div class="flex items-center justify-between text-[10px] text-slate-400 mt-1">
|
||||
<span class="bg-slate-100 px-1.5 py-0.5 rounded font-mono truncate max-w-[120px]">${escapeHtml(t.module || 'general')}</span>
|
||||
${t.due_date ? `<span class="shrink-0 font-medium text-slate-500"><i class="fa-regular fa-calendar mr-1"></i>${escapeHtml(t.due_date)}</span>` : ''}
|
||||
</div>
|
||||
${dueDateHtml}
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Глобальная инициализация при загрузке DOM
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Небольшая задержка, чтобы гарантировать применение токена
|
||||
setTimeout(loadTasks, 200);
|
||||
});
|
||||
Reference in New Issue
Block a user