init
This commit is contained in:
455
frontend/pages/chat.vue
Normal file
455
frontend/pages/chat.vue
Normal 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>
|
||||
Reference in New Issue
Block a user