Compare commits
2 Commits
main
...
geoposition
| Author | SHA1 | Date | |
|---|---|---|---|
|
10e727f375
|
|||
|
573aa5cda7
|
Vendored
+3
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user