Files
talorr cda36918e8 init
2026-03-27 03:36:08 +03:00

456 lines
15 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>