add chat
Main daploy / deploy (push) Successful in 45s

This commit is contained in:
2024-11-20 01:59:52 +02:00
parent d2eb6f02fd
commit 99ff4beba3
18 changed files with 626 additions and 68 deletions
+4
View File
@@ -0,0 +1,4 @@
import PouchDB from 'pouchdb-browser'
import PouchDBFindPlugin from 'pouchdb-find'
export default PouchDB.plugin(PouchDBFindPlugin)
+12 -2
View File
@@ -1,8 +1,18 @@
import PouchDB from 'pouchdb-browser'
import { type QRCodeDocument } from '@/types/DBDocumentTypes'
import PouchDB from '@/api/PouchDB'
import type { QRCodeDocument, MessageDocument } from '@/types/DBDocumentTypes'
export const qrCodeDbRemote = new PouchDB<QRCodeDocument>('https://hereconnect.condev.ru/api/qr', {
skip_setup: true,
})
export const qrCodeDbLocal = new PouchDB<QRCodeDocument>('qr')
export const messagesDbLocal = new PouchDB<MessageDocument>('messages')
export const messagesDbRemote = new PouchDB<MessageDocument>(
'https://hereconnect.condev.ru/api/messages',
{
skip_setup: true,
},
)
+193
View File
@@ -0,0 +1,193 @@
<template>
<div class="flex flex-col flex-grow">
<!-- Список сообщений -->
<div class="flex-grow overflow-y-auto px-4 py-2" ref="messagesContainer">
<div
v-for="message in messages"
:key="message._id"
class="mb-4 max-w-md"
:class="{ 'ml-auto': message.from === userRole, 'mr-auto': message.from !== userRole }"
>
<Card
class="p-card-sm"
:class="{
'bg-blue-50 border-blue-300 text-blue-500': message.from === userRole,
'bg-gray-50 border-gray-300 text-gray-700': message.from !== userRole,
}"
>
<template #content>
{{ message.body }}
</template>
</Card>
</div>
<slot name="endOfMessages" :count="messages.length"></slot>
</div>
<!-- Форма отправки сообщений -->
<form @submit.prevent="sendMessage" class="flex gap-2 items-center" ref="form">
<InputText
id="newMessage"
v-model="newMessage"
class="flex-1"
placeholder="Напишите сообщение"
/>
<Button type="submit" icon="pi pi-send" class="p-button-md" :disabled="!newMessage.trim()" />
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, useTemplateRef, watch, nextTick } from 'vue'
import { messagesDbLocal as db, messagesDbRemote as remoteDb } from '@/api/dbs'
import { type MessageDocument } from '@/types/DBDocumentTypes'
const formRef = useTemplateRef('form')
const messagesContainerRef = useTemplateRef('messagesContainer')
const { qr_code_uri, chat, userRole } = defineProps<{
qr_code_uri: MessageDocument['qr_code_uri']
chat: MessageDocument['chat']
userRole: MessageDocument['from']
}>()
// Локальные состояния
const messages = ref<MessageDocument[]>([])
const newMessage = ref('')
let changesSubscription: PouchDB.Core.Changes<MessageDocument> | null = null
let syncHandler: PouchDB.Replication.Sync<MessageDocument> | null = null
// Селектор для фильтрации сообщений
const messageSelector = {
type: 'message',
qr_code_uri,
chat,
}
// Загрузка сообщений
async function loadMessages() {
try {
const result = await db.find({
selector: messageSelector,
})
messages.value = result.docs as MessageDocument[]
} catch (error) {
console.error('Ошибка загрузки сообщений:', error)
}
}
// Отправка нового сообщения
async function sendMessage() {
const created_at = new Date().toISOString()
const message: MessageDocument = {
_id: `message:${qr_code_uri}:${chat}:${created_at}`,
type: 'message',
qr_code_uri,
chat,
created_at,
from: userRole,
body: newMessage.value.trim(),
}
try {
await db.put(message)
newMessage.value = ''
} catch (error) {
console.error('Ошибка отправки сообщения:', error)
}
}
// Отслеживание изменений
function watchChanges() {
changesSubscription = db
.changes({
since: 'now',
live: true,
include_docs: true,
selector: messageSelector,
})
.on('change', (change: { doc?: MessageDocument }) => {
if (change.doc) {
const index = messages.value.findIndex((m) => m._id === change.doc!._id)
if (index === -1) {
// Если сообщения нет, добавляем его
messages.value.push(change.doc)
} else {
// Если сообщение существует, обновляем его
messages.value[index] = change.doc
}
// Сортируем массив сообщений
messages.value.sort((a, b) => (a.created_at > b.created_at ? 1 : -1))
}
})
.on('error', (err) => {
console.error('Ошибка отслеживания изменений:', err)
})
}
// Настройка синхронизации
function setupSync() {
syncHandler = db.sync(remoteDb, {
live: true,
retry: true,
selector: messageSelector,
})
syncHandler.on('error', (err) => {
console.error('Ошибка синхронизации:', err)
})
}
// Остановка синхронизации
function stopSync() {
if (syncHandler) {
syncHandler.cancel()
syncHandler = null
}
}
// Отписка от изменений
function stopWatchingChanges() {
if (changesSubscription) {
changesSubscription.cancel()
changesSubscription = null
}
}
// Логика жизненного цикла
onMounted(async () => {
watchChanges()
setupSync()
await loadMessages()
formRef.value?.querySelector('input')?.focus()
})
watch(
() => messages.value?.length,
async () => {
if (messagesContainerRef.value) {
await nextTick()
messagesContainerRef.value.scrollTop = messagesContainerRef.value.scrollHeight
}
},
)
onUnmounted(() => {
stopWatchingChanges()
stopSync()
})
</script>
<style scoped>
/* Чат занимает всё доступное пространство */
.flex-grow {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.overflow-y-auto {
overflow-y: auto;
height: 0;
flex-grow: 1;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts" generic="R, E">
import type { UseQueryReturnType } from '@tanstack/vue-query'
type Props = {
query: UseQueryReturnType<R, E>
}
const { query } = defineProps<Props>()
const { data, error, refetch, isSuccess, isError, isLoading } = query
</script>
<template>
<slot v-if="isSuccess" name="default" :data="data! as R" />
<slot v-else-if="isLoading" name="loading"><p class="text-gray-500">Загрузка данных...</p></slot>
<slot v-else-if="isError" name="error" :error="error as E" :reload="refetch">
Ошибка загрузки данных: {{ error && 'message' in error ? error.message : error }}
</slot>
</template>
+2 -3
View File
@@ -48,10 +48,9 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import md5 from 'md5'
import type { ContactInfo } from '@/types/DBDocumentTypes'
const { contactInfo } = defineProps<{
contactInfo: { name?: string; phone?: string; email?: string }
}>()
const { contactInfo } = defineProps<{ contactInfo: ContactInfo }>()
const success = ref(false)
const error = ref<string | null>(null)
+32 -12
View File
@@ -1,14 +1,14 @@
<template>
<section class="max-w-prose w-full">
<p class="text-gray-600 mb-4 text-center" v-if="presetMessages.length">
<p class="text-gray-600 mb-4 text-center" v-if="doc.predefinedMessages?.length">
Выберите сообщение из списка или напишите своё:
</p>
<!-- Предустановленные сообщения -->
<div v-if="presetMessages.length" class="mb-6">
<div v-if="doc.predefinedMessages?.length" class="mb-6">
<div class="flex flex-col gap-3">
<Button
v-for="(message, index) in presetMessages"
v-for="(message, index) in doc.predefinedMessages"
:key="index"
class="p-button-outlined w-full text-left"
:disabled="loading"
@@ -43,31 +43,51 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { messagesDbLocal as db } from '@/api/dbs'
import type { MessageDocument, QRCodeDocument } from '@/types/DBDocumentTypes'
import { ROUTE_NAMES } from '@/constants/routes'
const { qr_code_uri, predefinedMessages } = defineProps<{
qr_code_uri: string
predefinedMessages: string[]
const { doc } = defineProps<{
doc: QRCodeDocument
}>()
const router = useRouter()
// Локальные состояния
const presetMessages = ref(predefinedMessages || [])
const customMessage = ref('')
const success = ref(false)
const error = ref<string | null>(null)
const loading = ref(false)
// Генерация идентификатора чата
// Функция для отправки сообщения
async function sendMessage(message: string) {
async function sendMessage(content: string) {
try {
loading.value = true
console.log({
qr_code_uri,
message: message.trim(),
})
const chat = (new Date().getTime() - new Date(doc.created_at).getTime()).toString(36)
const message: MessageDocument = {
_id: `message:${doc.uri}:${chat}:${new Date().toISOString()}`,
type: 'message',
qr_code_uri: doc.uri,
chat,
created_at: new Date().toISOString(),
from: 'guest',
body: content.trim(),
}
// Сохраняем сообщение в локальную базу данных
await db.put(message)
success.value = true
error.value = null
customMessage.value = ''
// Перенаправляем на страницу чата
await router.push({ name: ROUTE_NAMES.PUBLIC_CHAT, params: { qr_code_uri: doc.uri, chat } })
} catch {
success.value = false
error.value = 'Ошибка отправки сообщения'
+18
View File
@@ -0,0 +1,18 @@
export const ROUTE_NAMES = {
HOME: 'home',
CREATE: 'create',
MANAGE_QR_CODE_READY: 'manage-qr-code-ready',
READ_QR_CODE: 'read-qr-code',
DONATE: 'donate',
PUBLIC_CHAT: 'public-chat',
// CREATE_ACTIONS: 'create-actions',
// MANAGE_SETTINGS: 'manage-settings',
// MANAGE_PROFILE: 'manage-profile',
// MANAGE_CHAT: 'manage-chat',
// PUBLIC_QR: 'public-qr',
// HELP: 'help',
// ABOUT: 'about',
// CONTACT: 'contact',
// PRIVACY_POLICY: 'privacy-policy',
// TERMS_OF_SERVICE: 'terms-of-service',
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createApp } from 'vue'
import { VueQueryPlugin } from '@tanstack/vue-query'
import { createPinia } from 'pinia'
import PouchDB from 'pouchdb-browser'
import PouchDB from '@/api/PouchDB'
import PrimeVue from 'primevue/config'
import { definePreset } from '@primevue/themes'
+23 -23
View File
@@ -1,91 +1,91 @@
import { createRouter, createWebHistory } from 'vue-router'
import { ROUTE_NAMES } from '@/constants/routes'
import HomeView from '@/views/HomeView.vue'
const routes = [
{
path: '/',
name: 'home',
name: ROUTE_NAMES.HOME,
component: HomeView,
},
{
path: '/create',
name: ROUTE_NAMES.CREATE,
component: () => import('@/views/CreateView.vue'),
},
{
path: '/manage/:qr_code_uri/ready',
name: 'ManageQRCodeReady',
name: ROUTE_NAMES.MANAGE_QR_CODE_READY,
component: () => import('@/views/ManageQRCodeReadyView.vue'),
props: true,
},
{
path: '/read/:qr_code_uri',
name: 'ReadQRCode',
name: ROUTE_NAMES.READ_QR_CODE,
component: () => import('@/views/ReadQRCodeView.vue'),
props: true,
},
{
path: '/donate',
name: 'Donate',
name: ROUTE_NAMES.DONATE,
component: () => import('@/views/DonateView.vue'),
},
{
path: '/chat/:qr_code_uri/:chat',
name: ROUTE_NAMES.PUBLIC_CHAT,
component: () => import('@/views/PublicChatView.vue'),
props: true,
},
// Закомментированные маршруты
// {
// path: '/create/actions',
// name: 'create-actions',
// name: ROUTE_NAMES.CREATE_ACTIONS,
// component: () => import('@/views/CreateActionsView.vue'), // Второй шаг создания QR‑кода
// },
// {
// path: '/manage/:qr_code/settings',
// name: 'manage-settings',
// name: ROUTE_NAMES.MANAGE_SETTINGS,
// component: () => import('@/views/SettingsView.vue'), // Настройки QR‑кода для владельца
// },
// {
// path: '/manage/profile',
// name: 'manage-profile',
// name: ROUTE_NAMES.MANAGE_PROFILE,
// component: () => import('@/views/ProfileView.vue'), // Личный кабинет владельца
// },
// {
// path: '/manage/:qr_code/:chat',
// name: 'manage-chat',
// name: ROUTE_NAMES.MANAGE_CHAT,
// component: () => import('@/views/ChatView.vue'), // Чат для владельца
// },
// {
// path: '/chat/:qr_code/:chat',
// name: 'public-chat',
// component: () => import('@/views/PublicChatView.vue'), // Публичный чат для гостей
// },
// {
// path: '/:qr_code',
// name: 'public-qr',
// name: ROUTE_NAMES.PUBLIC_QR,
// component: () => import('@/views/PublicQRView.vue'), // Публичная страница QR‑кода
// },
// {
// path: '/help',
// name: 'help',
// name: ROUTE_NAMES.HELP,
// component: () => import('@/views/HelpView.vue'), // Страница помощи и FAQ
// },
// {
// path: '/about',
// name: 'about',
// name: ROUTE_NAMES.ABOUT,
// component: () => import('@/views/AboutView.vue'), // Страница информации о компании и проекте
// },
// {
// path: '/contact',
// name: 'contact',
// name: ROUTE_NAMES.CONTACT,
// component: () => import('@/views/ContactView.vue'), // Страница с контактной информацией
// },
// {
// path: '/privacy-policy',
// name: 'privacy-policy',
// name: ROUTE_NAMES.PRIVACY_POLICY,
// component: () => import('@/views/PrivacyPolicyView.vue'), // Политика конфиденциальности
// },
// {
// path: '/terms-of-service',
// name: 'terms-of-service',
// name: ROUTE_NAMES.TERMS_OF_SERVICE,
// component: () => import('@/views/TermsOfServiceView.vue'), // Условия использования
// },
]
+1 -1
View File
@@ -9,7 +9,7 @@ export interface MessageDocument extends BaseDocument {
type: 'message'
/** Идентификатор QR‑кода, к которому относится сообщение */
qr_code: string
qr_code_uri: string
/** Идентификатор чата для связывания сообщений */
chat: string
@@ -1,5 +1,11 @@
import type { BaseDocument } from './BaseDocument'
export type ContactInfo = {
name?: string
phone?: string
email?: string
}
/**
* Интерфейс для документа QR‑кода.
* Представляет QR‑код, созданный пользователем, и информацию о привязанном объекте.
@@ -30,4 +36,6 @@ export interface QRCodeDocument extends BaseDocument {
/** Предустановленное сообщение для гостей */
predefinedMessages?: string[]
contactInfo?: ContactInfo
}
+5 -1
View File
@@ -47,6 +47,7 @@ import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { createQrCodeDocument } from '@/api/qrCode'
import type { QRCodeDocument } from '@/types/DBDocumentTypes'
import { ROUTE_NAMES } from '@/constants/routes'
// Состояния шагов
const placement = ref<null | QRCodeDocument['placement']>(null)
@@ -70,7 +71,10 @@ async function createQrCode() {
})
error.value = null
await router.push({ name: 'ManageQRCodeReady', params: { qr_code_uri: qrCodeDocument.uri } })
await router.push({
name: ROUTE_NAMES.MANAGE_QR_CODE_READY,
params: { qr_code_uri: qrCodeDocument.uri },
})
} catch (err) {
error.value = (err as Error).message || 'Неизвестная ошибка'
}
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="flex flex-col min-h-screen">
<!-- Основной контент -->
<main class="flex-grow flex flex-col">
<section class="flex flex-col flex-grow max-w-prose w-full mx-auto p-4">
<ChatComponent userRole="guest" :qr_code_uri="qr_code_uri" :chat="chat">
<template #endOfMessages="{ count }">
<!-- Уведомление, если гость отправил только одно сообщение -->
<div
v-if="count === 1"
class="flex flex-col items-center justify-center text-center bg-gray-50 border border-gray-200 rounded-lg shadow-md p-6 mt-6"
>
<p class="text-gray-700 font-medium text-lg">Ваше сообщение отправлено.</p>
<p class="text-gray-600 mt-2">
Вы можете написать ещё одно сообщение или подождать ответа.
</p>
</div>
<p class="mt-10 text-sm text-gray-500 text-center">
Хотите создать собственный QRкод?
<a href="/create" class="underline">Создайте его здесь</a>.
</p>
<p class="mt-1 text-sm text-gray-500 text-center">
Сделайте НаСвязи удобнее. Мы будем рады вашей&nbsp;<RouterLink
to="/donate"
class="underline"
>поддержке</RouterLink
>.
</p>
</template>
</ChatComponent>
</section>
</main>
<!-- Футер -->
<!-- <footer class="bg-gray-100 text-center py-4 border-t text-sm text-gray-700">-->
<!-- <p><a href="/create" class="underline">Создайте свой QRкод</a>.</p>-->
<!-- <p class="mt-1">-->
<!-- Сделайте НаСвязи удобнее. Будем рады вашей&nbsp;<a href="/donate" class="underline"-->
<!-- >поддержке</a-->
<!-- >.-->
<!-- </p>-->
<!-- </footer>-->
</div>
</template>
<script setup lang="ts">
import type { MessageDocument } from '@/types/DBDocumentTypes'
import ChatComponent from '@/components/ChatComponent.vue'
defineProps<{ qr_code_uri: MessageDocument['qr_code_uri']; chat: MessageDocument['chat'] }>()
</script>
+16 -23
View File
@@ -2,14 +2,15 @@
<div class="flex flex-col min-h-screen">
<main class="flex-grow flex items-center justify-center py-6 px-4 md:px-6">
<section class="max-w-prose w-full flex flex-col gap-10">
<template v-for="action in actions">
<ReadContactInfo v-if="action === 'viewContactInfo'" :contactInfo="contactInfo" />
<ReadMessageSender
v-else-if="action === 'sendMessage'"
:qr_code_uri="qr_code_uri"
:predefinedMessages="predefinedMessages"
/>
</template>
<QueryRender :query #default="{ data: doc }">
<template v-for="action in doc.actions" :key="action">
<ReadContactInfo
v-if="action === 'viewContactInfo' && doc.contactInfo"
:contactInfo="doc.contactInfo"
/>
<ReadMessageSender v-else-if="action === 'sendMessage'" :doc />
</template>
</QueryRender>
</section>
</main>
@@ -28,24 +29,16 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { getQrCodeDocument } from '@/api/qrCode'
import type { QRCodeDocument } from '@/types/DBDocumentTypes'
import QueryRender from '@/components/QueryRender.vue'
const { qr_code_uri } = defineProps<{ qr_code_uri: string }>()
// Локальные состояния
const predefinedMessages = ref<string[]>([])
const contactInfo = ref<{ name?: string; phone?: string; email?: string }>({})
const actions = ref<string[]>([])
onMounted(async () => {
try {
const qrCodeDoc = await getQrCodeDocument(qr_code_uri)
predefinedMessages.value = qrCodeDoc!.predefinedMessage || []
contactInfo.value = qrCodeDoc!.contactInfo || { name: 'waka', phone: '+123' }
actions.value = qrCodeDoc!.actions || []
} catch (err) {
console.error(err)
}
// Хук для загрузки данных QR-кода
const query = useQuery({
queryKey: ['qrCodeData', qr_code_uri],
queryFn: (): Promise<QRCodeDocument> => getQrCodeDocument(qr_code_uri),
})
</script>