Compare commits

2 Commits

Author SHA1 Message Date
ti 10e727f375 show map 2025-05-16 17:50:08 +02:00
ti 573aa5cda7 attach geoposition on read page 2025-05-15 18:33:58 +02:00
11 changed files with 312 additions and 44 deletions
+3
View File
@@ -29,6 +29,7 @@ declare module 'vue' {
DownloadImageButton: typeof import('./src/components/manage/DownloadImageButton.vue')['default']
ExamplesSection: typeof import('./src/components/TheWelcome/ExamplesSection.vue')['default']
FooterCopyright: typeof import('./src/components/FooterCopyright.vue')['default']
GeoLocation: typeof import('./src/components/GeoLocation.vue')['default']
InputText: typeof import('primevue/inputtext')['default']
LayoutWithTabs: typeof import('./src/layouts/LayoutWithTabs.vue')['default']
LinkCopyButton: typeof import('./src/components/link/LinkCopyButton.vue')['default']
@@ -38,12 +39,14 @@ declare module 'vue' {
LinkShareButton: typeof import('./src/components/link/LinkShareButton.vue')['default']
LinkShareTelegramButton: typeof import('./src/components/link/LinkShareTelegramButton.vue')['default']
ManagePlacementInstructions: typeof import('./src/components/manage/ManagePlacementInstructions.vue')['default']
MapCoors: typeof import('./src/components/MapCoors.vue')['default']
ProgressSpinner: typeof import('primevue/progressspinner')['default']
QRCodeCard: typeof import('./src/components/manage/QRCodeCard.vue')['default']
QRCodePlacementIcon: typeof import('./src/components/manage/QRCodePlacementIcon.vue')['default']
QueryRender: typeof import('./src/components/QueryRender.vue')['default']
RadioButton: typeof import('primevue/radiobutton')['default']
ReadContactInfo: typeof import('./src/components/read/ReadContactInfo.vue')['default']
ReadGeolocation: typeof import('./src/components/read/ReadGeolocation.vue')['default']
ReadMessageSender: typeof import('./src/components/read/ReadMessageSender.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+2
View File
@@ -8,6 +8,7 @@ import type {
WebPushSubscriptionDocument,
PublicSettingsDocument,
UserChatSettingsDocument,
GeolocationDocument,
} from '@hereconnect/types'
import { DB_NAMES } from '@/constants/dbNames'
@@ -31,3 +32,4 @@ export const messagesDbRemote = getRemoteDb<MessageDocument>(DB_NAMES.MESSAGES)
export const userChatSettingsDbRemote = getRemoteDb<UserChatSettingsDocument>(
DB_NAMES.USER_CHAT_SETTINGS,
)
export const geolocationsDbRemote = getRemoteDb<GeolocationDocument>(DB_NAMES.GEOLOCATIONS)
+16
View File
@@ -0,0 +1,16 @@
<template>
<iframe v-if="src" :src allowfullscreen class="w-full h-auto aspect-square" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
coords: Omit<GeolocationPosition['coords'], 'toJSON'>
}>()
const src = computed(
() =>
`https://yandex.ru/map-widget/v1/?ll=${encodeURIComponent(props.coords.longitude + ',' + props.coords.latitude)}&mode=routes&rtext=~${encodeURIComponent(props.coords.latitude + ',' + props.coords.longitude)}&rtt=auto&ruri=~&z=18`,
)
</script>
@@ -1,42 +1,52 @@
<template>
<div class="place-self-stretch flex flex-col">
<div
v-for="message in messages"
:key="message._id"
:id="`msg-${message.created_at}`"
class="mb-4 max-w-md"
v-for="doc in docs"
:key="doc._id"
:id="`msg-${doc.created_at}`"
class="mb-4"
:class="{
'ml-auto text-right': message.from === userRole,
'mr-auto text-left': message.from !== userRole,
'max-w-md': doc.type === 'message',
'ml-auto text-right': doc.type === 'message' && doc.from === userRole,
'mr-auto text-left': doc.type === 'message' && doc.from !== userRole,
}"
>
<Card
class="p-card-sm"
:class="{
'bg-blue-50 dark:bg-blue-900 border-blue-300 dark:border-blue-700 text-blue-500 dark:text-blue-200':
message.from === userRole,
doc.from === userRole,
'bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300':
message.from !== userRole,
doc.from !== userRole,
}"
>
<template #content>
{{ message.body }}
<template v-if="doc.type === 'message'">
{{ doc.body }}
</template>
<template v-if="doc.type === 'geolocation'">
<MapCoors :coords="doc.coords" />
</template>
</template>
</Card>
</div>
<slot
name="endOfMessages"
:count="messages.length"
:lastMessage="messages.length > 0 ? messages[messages.length - 1] : null"
:count="docs.length"
:lastMessage="docs.length > 0 ? docs[docs.length - 1] : null"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { messagesDbLocal as db, messagesDbRemote as remoteDb } from '@/api/dbs'
import { type MessageDocument } from '@hereconnect/types'
import {
messagesDbLocal as db,
messagesDbRemote as remoteDb,
geolocationsDbRemote,
} from '@/api/dbs'
import { type MessageDocument, type GeolocationDocument } from '@hereconnect/types'
const { qr_code_uri, chat, userRole } = defineProps<{
qr_code_uri: MessageDocument['qr_code_uri']
@@ -45,7 +55,7 @@ const { qr_code_uri, chat, userRole } = defineProps<{
}>()
// Локальные состояния
const messages = ref<MessageDocument[]>([])
const docs = ref<(MessageDocument | GeolocationDocument)[]>([])
let changesSubscription: PouchDB.Core.Changes<MessageDocument> | null = null
let syncMessages: PouchDB.Replication.Sync<MessageDocument> | null = null
@@ -60,11 +70,25 @@ const messageSelector = {
// Загрузка сообщений
async function loadMessages() {
try {
const result = await db.find({
selector: messageSelector,
limit: Number.MAX_SAFE_INTEGER,
})
messages.value = result.docs as MessageDocument[]
const result = await Promise.all([
db.find({
selector: messageSelector,
limit: Number.MAX_SAFE_INTEGER,
}),
geolocationsDbRemote.find({
selector: {
type: 'geolocation',
qr_code_uri,
chat,
},
}),
])
docs.value = [
...(result[0].docs as MessageDocument[]),
...(result[1].docs as GeolocationDocument[]),
].sort((a, b) => (a.created_at > b.created_at ? 1 : -1))
} catch (error) {
console.error('Ошибка загрузки сообщений:', error)
}
@@ -81,16 +105,16 @@ function watchChanges() {
})
.on('change', (change: { doc?: MessageDocument }) => {
if (change.doc) {
const index = messages.value.findIndex((m) => m._id === change.doc!._id)
const index = docs.value.findIndex((m) => m._id === change.doc!._id)
if (index === -1) {
// Если сообщения нет, добавляем его
messages.value.push(change.doc)
docs.value.push(change.doc)
} else {
// Если сообщение существует, обновляем его
messages.value[index] = change.doc
docs.value[index] = change.doc
}
// Сортируем массив сообщений
messages.value.sort((a, b) => (a.created_at > b.created_at ? 1 : -1))
docs.value.sort((a, b) => (a.created_at > b.created_at ? 1 : -1))
}
})
.on('error', (err) => {
@@ -138,7 +162,7 @@ const emit = defineEmits<{
}>()
watch(
() => messages.value?.length,
() => docs.value?.length,
async () => {
await nextTick()
emit('scrollToBottom')
@@ -0,0 +1,76 @@
<template>
<div class="flex items-center gap-2">
<!-- <div class="w-5 h-5">-->
<!-- <ProgressSpinner style="width: 100%; height: 100%" strokeWidth="8" fill="transparent" />-->
<!-- </div>-->
<Checkbox v-model="enabled" inputId="geolocation" name="geolocation" binary />
<label for="geolocation" class="text-gray-700 dark:text-gray-300">
Прикрепить геопозицию
</label>
<Button
v-if="error"
icon="pi pi-refresh"
label="повторить"
@click="tryAgain"
size="small"
severity="secondary"
/>
</div>
<p v-if="coordsModel">
Latitude: {{ coordsModel.latitude }} Longitude: {{ coordsModel.longitude }}
</p>
<p v-if="error" class="text-red-500 dark:text-red-400 mb-2">
Ошибка{{ error.message ? `: ${error.message}` : '' }}
</p>
</template>
<script setup lang="ts">
import { useGeolocation } from '@vueuse/core'
const { coords, locatedAt, error, resume, pause } = useGeolocation({
enableHighAccuracy: true,
immediate: false,
})
const enabled = defineModel<boolean>('enabled')
const coordsModel = defineModel<typeof coords.value | null>('coords')
const locatedAtModel = defineModel<typeof locatedAt.value | null>('locatedAt')
watch(
enabled,
(value) => {
if (value) {
resume()
} else {
pause()
coordsModel.value = null
locatedAtModel.value = null
}
},
{ immediate: true },
)
watch(
[enabled, coords],
() => {
coordsModel.value =
enabled.value && coords.value.latitude < Number.POSITIVE_INFINITY ? coords.value : null
},
{ immediate: true },
)
watch(
[enabled, locatedAt],
() => {
locatedAtModel.value = enabled.value ? locatedAt.value : null
},
{ immediate: true },
)
function tryAgain() {
pause()
resume()
}
</script>
@@ -23,20 +23,28 @@
</div>
<!-- Текстовое поле ввода с кнопкой -->
<form @submit.prevent="sendCustomMessage" class="flex gap-2 items-center">
<InputText
id="customMessage"
v-model="customMessage"
class="flex-1 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700"
placeholder="Напишите сообщение"
:disabled="loading"
/>
<Button
type="submit"
icon="pi pi-send"
class="p-button-md dark:bg-primary-600 dark:hover:bg-primary-700"
:disabled="!customMessage.trim() || loading"
<form @submit.prevent="sendCustomMessage">
<ReadGeolocation
v-model:enabled="enabledGeolocation"
v-model:coords="coords"
v-model:locatedAt="locatedAt"
/>
<div class="flex gap-2 items-center">
<InputText
id="customMessage"
v-model="customMessage"
class="flex-1 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700"
placeholder="Напишите сообщение"
:disabled="loading"
/>
<Button
type="submit"
icon="pi pi-send"
class="p-button-md dark:bg-primary-600 dark:hover:bg-primary-700"
:disabled="!customMessage.trim() || loading"
/>
</div>
</form>
<p v-if="success" class="text-green-500 dark:text-green-400 mt-4 text-center">
@@ -49,8 +57,13 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { chatsDbRemote, messagesDbLocal as db } from '@/api/dbs'
import type { ChatDocument, MessageDocument, QRCodeDocument } from '@hereconnect/types'
import { chatsDbRemote, geolocationsDbRemote, messagesDbLocal as db } from '@/api/dbs'
import type {
ChatDocument,
GeolocationDocument,
MessageDocument,
QRCodeDocument,
} from '@hereconnect/types'
import { ROUTE_NAMES } from '@/constants/routes'
import { getUserUuid } from '@/utils/getUserUuid'
import { getBrowserUuid } from '@/utils/getBrowserUuid'
@@ -66,6 +79,9 @@ const customMessage = ref('')
const success = ref(false)
const error = ref<string | null>(null)
const loading = ref(false)
const enabledGeolocation = ref(false)
const coords = ref(null)
const locatedAt = ref(null)
// Генерация идентификатора чата
@@ -118,12 +134,28 @@ async function sendMessage(content: string) {
body: content.trim(),
}
await Promise.all([
const promises: Promise<unknown>[] = [
chatsDbRemote.bulkDocs([chatGuestDoc, chatOwnerDoc]),
// Сохраняем сообщение в локальную базу данных
db.put(message),
])
]
if (enabledGeolocation.value && coords.value) {
const geolocation: GeolocationDocument = {
_id: `geolocation:${doc.uri}:${chat}:${created_at}`,
type: 'geolocation',
qr_code_uri: doc.uri,
chat,
created_at,
from: 'guest',
user_uuid,
browser_uuid,
coords: coords.value,
located_at: new Date(locatedAt.value!).toISOString(),
}
promises.push(geolocationsDbRemote.put(geolocation))
}
await Promise.all(promises)
success.value = true
error.value = null
+1
View File
@@ -5,4 +5,5 @@ export enum DB_NAMES {
WEB_PUSH_SUBSCRIPTIONS = 'web_push_subscriptions',
PUBLIC_SETTINGS = 'public_settings',
USER_CHAT_SETTINGS = 'user_chat_settings',
GEOLOCATIONS = 'geolocations',
}
+1 -1
View File
@@ -33,7 +33,7 @@
v-if="action === 'viewContactInfo' && doc.contactInfo"
:contactInfo="doc.contactInfo"
/>
<ReadMessageSender v-else-if="action === 'sendMessage'" :doc />
<ReadMessageSender v-if="action === 'sendMessage'" :doc />
</template>
</template>
</QueryRender>
@@ -0,0 +1,33 @@
import type { BaseDocument } from "./BaseDocument";
import type { Role } from './Role'
export interface GeolocationDocument extends BaseDocument {
_id: string; // Формат: "geolocation:<name>"
type: "geolocation";
/**
* Координаты
*/
coords: Omit<GeolocationPosition['coords'], 'toJSON'>;
/** Дата получения геопозиции в формате ISO */
located_at: string;
/** Идентификатор QR‑кода, к которому относится сообщение */
qr_code_uri: string;
/** Идентификатор чата для связывания сообщений */
chat: string;
/** Отправитель сообщения ('guest' или 'owner') */
from: Role;
/** Опциональная геопозиция (широта и долгота) */
location?: { lat: number; lng: number };
/** Идентификатор пользователя, отправившего сообщение */
user_uuid: string;
/** Идентификатор браузера, отправившего сообщение */
browser_uuid?: string;
}
+1
View File
@@ -8,3 +8,4 @@ export * from "./ServerSettingsDocument";
export * from "./UserDocument";
export * from "./UserChatSettingsDocument";
export * from "./WebPushSubscriptionDocument";
export * from "./GeolocationDocument";
@@ -0,0 +1,80 @@
import { fetch } from "pouchdb-fetch";
const dbsSecurity = {
_users: null,
_replicator: null,
_global_changes: null,
geolocations: {
admins: { roles: ["_admin"] },
members: { roles: [] },
},
};
if (!process.env.API_URL) {
throw new Error("Environment variable API_URL is not set");
}
const urlObject = new URL(process.env.API_URL);
const auth = `${urlObject.username}:${urlObject.password}`;
const headers = {
"Content-Type": "application/json",
Authorization: "Basic " + btoa(auth),
};
const getUrl = (dbUrlPath) => {
return `${urlObject.protocol}//${urlObject.host}${urlObject.pathname}/${dbUrlPath}${urlObject.search}${urlObject.hash}`;
};
export const up = async () => {
for (const [dbName, dbSecurity] of Object.entries(dbsSecurity)) {
const responseCreateDb = await fetch(getUrl(dbName), {
method: "PUT",
headers,
}).then((response) => response.json());
if (responseCreateDb.ok) {
console.log(`created ${dbName}`);
} else if (responseCreateDb.error === "file_exists") {
console.log(`exists ${dbName}`);
} else {
throw new Error(
`${dbName} create error ${JSON.stringify(responseCreateDb)}`,
);
}
if (!dbSecurity) {
continue;
}
const responseSetSecurity = await fetch(getUrl(`${dbName}/_security`), {
method: "PUT",
headers,
body: JSON.stringify(dbSecurity),
}).then((response) => response.json());
if (responseSetSecurity.ok) {
console.log(`security ${dbName}`);
} else {
throw new Error(
`${dbName} security error ${JSON.stringify(responseSetSecurity)}`,
);
}
}
};
export const down = async () => {
const promises = Object.keys(dbsSecurity).map(async (dbName) => {
const responseDeleteDb = await fetch(getUrl(dbName), {
headers,
method: "DELETE",
}).then((response) => response.json());
if (responseDeleteDb.ok) {
console.log(`deleted ${dbName}`);
} else {
console.error(
`${dbName} delete error ${JSON.stringify(responseDeleteDb)}`,
);
}
});
await Promise.allSettled(promises);
await Promise.all(promises);
};