456 lines
15 KiB
Vue
456 lines
15 KiB
Vue
<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>
|