This commit is contained in:
talorr
2026-03-27 03:36:08 +03:00
parent 8a97ce6d54
commit cda36918e8
225 changed files with 35641 additions and 0 deletions

455
frontend/pages/chat.vue Normal file
View File

@@ -0,0 +1,455 @@
<script setup lang="ts">
import type { SupportConversation } from "~/types";
definePageMeta({
middleware: "auth"
});
const { user, token } = useAuth();
const { formatDateTime } = useBrowserDateTime();
const { connect, disconnect, onConversationUpdated, onMessageCreated } = useSupportRealtime();
const { replaceConversations, upsertConversation } = useSupportUnread();
const conversations = ref<SupportConversation[]>([]);
const activeConversationId = ref<string | null>(null);
const conversation = ref<SupportConversation | null>(null);
const loading = ref(false);
const sending = ref(false);
const statusSaving = ref(false);
const draft = ref("");
const errorMessage = ref("");
const messageListRef = ref<HTMLElement | null>(null);
const isMobile = ref(false);
const isAdmin = computed(() => user.value?.role === "admin");
const showAdminConversation = computed(() => !isAdmin.value || !isMobile.value || Boolean(activeConversationId.value));
const showAdminList = computed(() => isAdmin.value && (!isMobile.value || !activeConversationId.value));
const activeConversation = computed(() => {
if (!isAdmin.value) {
return conversation.value;
}
return conversations.value.find((item) => item.id === activeConversationId.value) ?? conversation.value;
});
const canSend = computed(() => draft.value.trim().length > 0 && !sending.value);
const sortConversations = (items: SupportConversation[]) =>
[...items].sort((left, right) => new Date(right.lastMessageAt).getTime() - new Date(left.lastMessageAt).getTime());
const updateConversationInList = (nextConversation: SupportConversation) => {
const withoutCurrent = conversations.value.filter((item) => item.id !== nextConversation.id);
conversations.value = sortConversations([nextConversation, ...withoutCurrent]);
};
const replaceActiveConversation = (nextConversation: SupportConversation) => {
conversation.value = nextConversation;
updateConversationInList(nextConversation);
if (!activeConversationId.value) {
activeConversationId.value = nextConversation.id;
}
};
const scrollToBottom = async () => {
await nextTick();
if (!messageListRef.value) {
return;
}
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
};
const loadUserConversation = async () => {
loading.value = true;
try {
const response = await useChatApi<SupportConversation>("/support/conversation");
conversation.value = response;
activeConversationId.value = response.id;
conversations.value = [response];
replaceConversations([response]);
errorMessage.value = "";
await scrollToBottom();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Не удалось загрузить чат";
} finally {
loading.value = false;
}
};
const loadAdminConversations = async () => {
loading.value = true;
try {
const response = await useChatApi<SupportConversation[]>("/admin/support/conversations");
conversations.value = sortConversations(response);
replaceConversations(response);
if (!activeConversationId.value && response.length > 0) {
activeConversationId.value = response[0].id;
}
if (activeConversationId.value) {
await loadAdminConversation(activeConversationId.value);
} else {
conversation.value = null;
}
errorMessage.value = "";
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Не удалось загрузить обращения";
} finally {
loading.value = false;
}
};
const loadAdminConversation = async (conversationId: string) => {
activeConversationId.value = conversationId;
try {
const response = await useChatApi<SupportConversation>(`/admin/support/conversations/${conversationId}`);
replaceActiveConversation(response);
upsertConversation(response);
errorMessage.value = "";
await scrollToBottom();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Не удалось открыть диалог";
}
};
const closeMobileConversation = () => {
if (!isAdmin.value || !isMobile.value) {
return;
}
activeConversationId.value = null;
conversation.value = null;
};
const loadPage = async () => {
if (isAdmin.value) {
await loadAdminConversations();
return;
}
await loadUserConversation();
};
const sendMessage = async () => {
const body = draft.value.trim();
if (!body) {
return;
}
sending.value = true;
try {
const response = isAdmin.value
? await useChatApi<SupportConversation>(`/admin/support/conversations/${activeConversationId.value}/messages`, {
method: "POST",
body: {
body
}
})
: await useChatApi<SupportConversation>("/support/conversation/messages", {
method: "POST",
body: {
body
}
});
draft.value = "";
replaceActiveConversation(response);
upsertConversation(response);
errorMessage.value = "";
await scrollToBottom();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Не удалось отправить сообщение";
} finally {
sending.value = false;
}
};
const setConversationStatus = async (status: "open" | "closed") => {
if (!isAdmin.value || !activeConversationId.value) {
return;
}
statusSaving.value = true;
try {
const response = await useChatApi<SupportConversation>(`/admin/support/conversations/${activeConversationId.value}`, {
method: "PATCH",
body: {
status
}
});
replaceActiveConversation(response);
upsertConversation(response);
errorMessage.value = "";
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Не удалось обновить статус";
} finally {
statusSaving.value = false;
}
};
const handleRealtimeConversation = async (payload: { conversation: SupportConversation }) => {
if (!payload?.conversation) {
return;
}
updateConversationInList(payload.conversation);
upsertConversation(payload.conversation);
if (!isAdmin.value) {
conversation.value = payload.conversation;
await scrollToBottom();
return;
}
if (!activeConversationId.value) {
activeConversationId.value = payload.conversation.id;
}
if (payload.conversation.id === activeConversationId.value) {
conversation.value = payload.conversation;
await scrollToBottom();
}
};
const handleRealtimeMessage = async (payload: { conversation: SupportConversation }) => {
await handleRealtimeConversation(payload);
};
let offConversationUpdated: (() => void) | null = null;
let offMessageCreated: (() => void) | null = null;
onMounted(async () => {
if (process.client) {
const mediaQuery = window.matchMedia("(max-width: 767px)");
const syncIsMobile = () => {
isMobile.value = mediaQuery.matches;
};
syncIsMobile();
mediaQuery.addEventListener("change", syncIsMobile);
onBeforeUnmount(() => {
mediaQuery.removeEventListener("change", syncIsMobile);
});
}
await loadPage();
if (!process.client || !token.value) {
return;
}
connect();
offConversationUpdated = onConversationUpdated((payload) => {
void handleRealtimeConversation(payload);
});
offMessageCreated = onMessageCreated((payload) => {
void handleRealtimeMessage(payload);
});
});
onBeforeUnmount(() => {
offConversationUpdated?.();
offConversationUpdated = null;
offMessageCreated?.();
offMessageCreated = null;
disconnect();
});
watch(
() => activeConversation.value?.messages?.length,
() => {
void scrollToBottom();
}
);
</script>
<template>
<section class="page admin-grid">
<div class="page-header page-header--admin-section">
<div>
<h2>{{ isAdmin ? "Обращения пользователей" : "Чат с поддержкой" }}</h2>
<p class="muted">
{{
isAdmin
? "Все администраторы видят единый поток обращений и могут отвечать пользователям прямо из приложения."
: "Напишите админу прямо здесь. Когда поддержка ответит, сообщение появится в этом чате."
}}
</p>
</div>
</div>
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
<div
class="grid gap-4"
:style="isAdmin && !isMobile ? { gridTemplateColumns: 'minmax(280px, 360px) minmax(0, 1fr)' } : undefined"
>
<div v-if="showAdminList" class="panel" style="padding: 0;">
<div class="admin-list" style="padding: 1rem;">
<div
v-for="item in conversations"
:key="item.id"
class="rounded-2xl border p-4 transition cursor-pointer"
:style="{
borderColor: item.id === activeConversationId ? 'var(--accent)' : 'var(--border)',
backgroundColor: item.id === activeConversationId ? 'color-mix(in srgb, var(--accent) 8%, var(--surface))' : 'var(--surface)'
}"
@click="loadAdminConversation(item.id)"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<strong class="block truncate">{{ item.user.email }}</strong>
<span class="text-sm text-(--muted)">
{{ item.status === "closed" ? "Закрыт" : item.unreadForAdmin ? "Новое сообщение" : "Открыт" }}
</span>
</div>
<span
v-if="item.unreadForAdmin"
class="signal-row__badge signal-row__badge--win"
>
новое
</span>
</div>
<p class="mt-3 mb-1 text-sm break-words">
{{ item.latestMessage?.body || "Пока без сообщений" }}
</p>
<p class="m-0 text-xs text-(--muted)">
{{ formatDateTime(item.lastMessageAt) }}
</p>
</div>
<p v-if="!conversations.length && !loading" class="muted">Обращений пока нет.</p>
</div>
</div>
<div v-if="showAdminConversation" class="panel">
<div
v-if="activeConversation"
class="grid gap-4"
:style="{ minHeight: isAdmin ? '70vh' : '60vh', gridTemplateRows: 'auto minmax(0, 1fr) auto' }"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<button
v-if="isAdmin && isMobile"
type="button"
class="secondary"
style="padding-inline: 0.85rem;"
@click="closeMobileConversation"
>
Назад
</button>
<div>
<strong class="block">
{{ isAdmin ? activeConversation.user.email : "Поддержка" }}
</strong>
<span class="text-sm text-(--muted)">
{{
activeConversation.status === "closed"
? "Диалог закрыт"
: isAdmin
? "Отвечайте пользователю от лица поддержки"
: "Админы ответят сюда же"
}}
</span>
</div>
</div>
<div v-if="isAdmin" class="flex gap-2">
<button
class="secondary"
:disabled="statusSaving || activeConversation.status === 'open'"
@click="setConversationStatus('open')"
>
Открыть
</button>
<button
:disabled="statusSaving || activeConversation.status === 'closed'"
@click="setConversationStatus('closed')"
>
Закрыть
</button>
</div>
</div>
<div
ref="messageListRef"
class="grid gap-3 overflow-y-auto rounded-2xl border p-3"
:style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }"
>
<div
v-for="message in activeConversation.messages ?? []"
:key="message.id"
class="flex"
:style="{ justifyContent: message.author.id === user?.id ? 'flex-end' : 'flex-start' }"
>
<div
class="max-w-[85%] rounded-2xl px-4 py-3"
:style="message.author.id === user?.id
? {
backgroundColor: 'color-mix(in srgb, var(--accent) 18%, var(--surface))',
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))'
}
: {
backgroundColor: 'var(--surface-strong)',
border: '1px solid var(--border)'
}"
>
<div class="mb-2 text-xs text-(--muted)">
{{ message.author.email }} · {{ formatDateTime(message.createdAt) }}
</div>
<div class="whitespace-pre-wrap break-words">{{ message.body }}</div>
</div>
</div>
<p v-if="!(activeConversation.messages ?? []).length" class="muted">
{{ isAdmin ? "В этом диалоге ещё нет сообщений." : "Напишите первое сообщение админу." }}
</p>
</div>
<form class="grid gap-3" @submit.prevent="sendMessage">
<textarea
v-model="draft"
rows="4"
placeholder="Введите сообщение"
:disabled="activeConversation.status === 'closed' && !isAdmin"
/>
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-(--muted)">
{{
activeConversation.status === "closed"
? isAdmin
? "Диалог закрыт, но администратор может открыть его снова."
: "Диалог закрыт. Дождитесь, пока администратор откроет его снова."
: "Сообщения доставляются в реальном времени."
}}
</span>
<button
type="submit"
:disabled="!canSend || (!isAdmin && activeConversation.status === 'closed')"
>
{{ sending ? "Отправка..." : "Отправить" }}
</button>
</div>
</form>
</div>
<div v-else-if="loading" class="muted">Загрузка чата...</div>
<div v-else class="muted">
{{ isAdmin ? "Выберите диалог слева или дождитесь нового обращения." : "Чат пока недоступен." }}
</div>
</div>
</div>
</section>
</template>