This commit is contained in:
Nikidze
2025-10-31 22:08:55 +03:00
commit e3f8caf59f
78 changed files with 9249 additions and 0 deletions

2
web-ui/.env Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=https://xczl-a4zl-n2ko.gw-1a.dockhost.net
VITE_OSRM_BASE_URL=https://8msn-80q0-el3y.gw-1a.dockhost.net

35
web-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# логи
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# зависимости
node_modules/
# сборка
dist/
dist-ssr/
# локальные настройки
*.local
# папки редакторов
.vscode/*
!.vscode/extensions.json
.idea/
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# временные/отладочные файлы
*.tgz
npm-debug.log*
yarn-debug.log*
yarn-error.log*

3
web-ui/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

37
web-ui/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# Stage 1: Build the Vue application
FROM node:20-alpine AS builder
# Set the working directory inside the container
WORKDIR /app
ARG VITE_API_BASE_URL
ARG VITE_OSRM_BASE_URL
# Make them available as environment variables during build
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_OSRM_BASE_URL=$VITE_OSRM_BASE_URL
# Copy package.json and package-lock.json (if it exists)
# to leverage Docker cache for dependencies
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install
# Copy the rest of the application code
COPY . .
# Build the Vue application for production
RUN npm run build
# Stage 2: Serve the application with Nginx
FROM nginx:alpine
# Copy the built application from the builder stage to Nginx's default public directory
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port 80, which is the default for Nginx
EXPOSE 80
# Command to run Nginx in the foreground
CMD ["nginx", "-g", "daemon off;"]

5
web-ui/Makefile Normal file
View File

@@ -0,0 +1,5 @@
build_image:
docker build -t gitea.private.nikidze.ru/nikidze/gorkycode-web-ui .
push_image:
docker push gitea.private.nikidze.ru/nikidze/gorkycode-web-ui

5
web-ui/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
web-ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gorkycode: Туризм</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2473
web-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
web-ui/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "tourist-ai",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
"axios": "^1.13.1",
"leaflet": "^1.9.4",
"lucide-vue-next": "^0.552.0",
"marked": "^16.4.1",
"pinia": "^3.0.3",
"vue": "^3.5.22"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.16",
"vite": "^7.1.7"
}
}

1
web-ui/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="#000000" height="200px" width="200px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 464.843 464.843" xml:space="preserve"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g> <path d="M231.671,262.343c-15.991,0-29,13.01-29,29c0,15.99,13.009,29,29,29c15.991,0,29-13.01,29-29 C260.671,275.353,247.662,262.343,231.671,262.343z M231.671,312.343c-11.58,0-21-9.421-21-21s9.42-21,21-21 c11.58,0,21,9.421,21,21S243.251,312.343,231.671,312.343z"></path> <path d="M185.671,380.343c-2.209,0-4,1.791-4,4v12c0,2.209,1.791,4,4,4s4-1.791,4-4v-12 C189.671,382.134,187.88,380.343,185.671,380.343z"></path> <path d="M208.921,380.343c-2.209,0-4,1.791-4,4v12c0,2.209,1.791,4,4,4s4-1.791,4-4v-12 C212.921,382.134,211.13,380.343,208.921,380.343z"></path> <path d="M232.171,380.343c-2.209,0-4,1.791-4,4v12c0,2.209,1.791,4,4,4s4-1.791,4-4v-12 C236.171,382.134,234.38,380.343,232.171,380.343z"></path> <path d="M255.421,380.343c-2.209,0-4,1.791-4,4v12c0,2.209,1.791,4,4,4s4-1.791,4-4v-12 C259.421,382.134,257.63,380.343,255.421,380.343z"></path> <path d="M278.671,380.343c-2.209,0-4,1.791-4,4v12c0,2.209,1.791,4,4,4c2.209,0,4-1.791,4-4v-12 C282.671,382.134,280.88,380.343,278.671,380.343z"></path> <path d="M218.645,16.132l5.961,2.329l-3.454,5.388c-1.143,1.784-0.698,4.149,1.017,5.396c0.708,0.515,1.531,0.764,2.349,0.764 c1.16,0,2.308-0.503,3.096-1.464l4.057-4.95l4.057,4.95c0.799,0.974,1.978,1.453,3.143,1.464c2.196-0.016,3.971-1.801,3.971-4 c0-0.928-0.316-1.781-0.846-2.46l-3.261-5.087l5.961-2.329c1.974-0.77,3.003-2.947,2.349-4.962 c-0.655-2.015-2.765-3.162-4.817-2.634l-6.191,1.62l-0.373-6.39C235.541,1.652,233.79,0,231.671,0c-2.119,0-3.87,1.652-3.993,3.767 l-0.373,6.39l-6.191-1.62c-2.05-0.53-4.163,0.618-4.817,2.634C215.642,13.185,216.672,15.361,218.645,16.132z"></path> <path d="M397.987,360.278c-1.342-0.683-2.951-0.557-4.168,0.329l-10.688,7.773l-10.698-7.773c-1.217-0.884-2.826-1.012-4.167-0.328 c-1.34,0.684-2.184,2.061-2.184,3.565v26.5h-16.15v-26.5c0-1.505-0.844-2.882-2.185-3.565c-1.342-0.683-2.951-0.557-4.168,0.329 l-10.688,7.773l-10.688-7.773c-1.217-0.885-2.825-1.011-4.168-0.329c-1.34,0.683-2.184,2.06-2.184,3.565v26.5h-16.18v-59.5 c0-2.209-1.791-4-4-4h-2v-19.067c0-2.209-1.791-4-4-4c-2.209,0-4,1.791-4,4v19.067h-5.5v-75c0-2.209-1.791-4-4-4h-7v-91.5 c0-2.209-1.791-4-4-4h-7.959L235.602,37.851c-0.357-1.89-2.008-3.258-3.931-3.258c-1.923,0-3.574,1.368-3.931,3.258L206.13,152.343 h-7.959c-2.209,0-4,1.791-4,4v91.5h-7c-2.209,0-4,1.791-4,4v75h-6v-19.067c0-2.209-1.791-4-4-4s-4,1.791-4,4v19.067h-2 c-2.209,0-4,1.791-4,4v59.5h-14.18v-26.5c0-1.505-0.844-2.882-2.185-3.565c-1.341-0.683-2.951-0.557-4.168,0.329l-10.688,7.773 l-10.688-7.773c-1.216-0.885-2.826-1.011-4.168-0.329c-1.34,0.683-2.185,2.06-2.185,3.565v26.5h-16.15v-26.5 c0-1.504-0.844-2.881-2.184-3.565c-1.34-0.682-2.95-0.555-4.167,0.328l-10.697,7.773l-10.688-7.773 c-1.216-0.885-2.826-1.011-4.168-0.329c-1.34,0.683-2.185,2.06-2.185,3.565v97c0,2.209,1.791,4,4,4h327.5c2.209,0,4-1.791,4-4v-97 C400.171,362.338,399.327,360.961,397.987,360.278z M171.171,334.843h19.75v28.2c0,2.209,1.791,4,4,4s4-1.791,4-4v-28.2h28.5v28.2 c0,2.209,1.791,4,4,4s4-1.791,4-4v-28.2h28.5v28.2c0,2.209,1.791,4,4,4c2.209,0,4-1.791,4-4v-28.2h19.75v85h-120.5V334.843z M261.171,215.343h-11v-55h11V215.343z M242.171,160.343v55h-21v-55H242.171z M231.671,60.159l17.4,92.184h-34.8L231.671,60.159z M213.171,160.343v55h-11v-55H213.171z M202.171,223.343h59v24.5h-59V223.343z M191.171,255.843h81v71h-81V255.843z M72.671,371.697l6.688,4.863c1.402,1.02,3.302,1.018,4.704,0.001l6.699-4.868v22.649c0,2.209,1.791,4,4,4h24.15 c2.209,0,4-1.791,4-4v-22.646l6.688,4.863c1.402,1.02,3.303,1.02,4.705,0l6.688-4.863v22.646c0,2.209,1.791,4,4,4h18.18v58.5h-90.5 V371.697z M171.171,456.843v-29h120.5v29H171.171z M392.171,456.843h-92.5v-58.5h20.18c2.209,0,4-1.791,4-4v-22.646l6.688,4.863 c1.402,1.02,3.303,1.02,4.705,0l6.688-4.863v22.646c0,2.209,1.791,4,4,4h24.15c2.209,0,4-1.791,4-4v-22.649l6.699,4.868 c1.402,1.018,3.302,1.019,4.704-0.001l6.688-4.863V456.843z"></path> <path d="M241.671,287.343h-6v-6.125c0-2.209-1.791-4-4-4s-4,1.791-4,4v10.125c0,2.209,1.791,4,4,4h10c2.209,0,4-1.791,4-4 C245.671,289.134,243.88,287.343,241.671,287.343z"></path> </g> </g></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

81
web-ui/src/App.vue Normal file
View File

@@ -0,0 +1,81 @@
<template>
<div class="w-full max-w-md mx-auto h-screen overflow-hidden flex flex-col bg-white">
<!-- Верхняя панель -->
<div v-if="store.screen != 1 && store.screen != 6" class="flex justify-between items-center p-1 border-b border-gray-200">
<!-- Кнопка Назад -->
<button
@click="goBack"
class="rounded-full p-2 hover:bg-gray-100 transition disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="store.screen === 1"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<!-- Кнопка Домой -->
<button
@click="goHome"
class="rounded-full p-2 hover:bg-gray-100 transition"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0h6" />
</svg>
</button>
</div>
<!-- Контейнер экранов -->
<div class="flex-1 relative overflow-hidden">
<Transition
enter-from-class="transform translate-y-full"
enter-active-class="transition-transform duration-500 ease-out"
enter-to-class="transform translate-y-0"
leave-from-class="transform translate-y-0"
leave-active-class="transition-transform duration-500 ease-out"
leave-to-class="transform -translate-y-full"
>
<component
:is="currentScreen"
:key="store.screen"
class="absolute top-0 left-0 w-full h-full"
/>
</Transition>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useMainStore } from "./store/main";
import ScreenWelcome from "./components/ScreenWelcome.vue";
import ScreenAddress from "./components/ScreenAddress.vue";
import ScreenInterests from "./components/ScreenInterests.vue";
import ScreenDuration from "./components/ScreenDuration.vue";
import ScreenChat from "./components/ScreenChat.vue";
import ScreenRoute from "./components/ScreenRoute.vue";
const store = useMainStore();
const currentScreen = computed(() => {
return [
ScreenWelcome,
ScreenAddress,
ScreenInterests,
ScreenDuration,
ScreenChat,
ScreenRoute,
][store.screen - 1];
});
// Навигация
function goBack() {
if (store.screen > 1) {
store.screen--;
}
}
function goHome() {
store.screen = 1;
}
</script>

View File

@@ -0,0 +1,5 @@
@import 'tailwindcss';
:root {
--btn: #06b6d4;
}

View File

@@ -0,0 +1,44 @@
<template>
<div class="flex flex-col h-full bg-white">
<div class="p-5 border-b border-gray-100">
<h2 class="text-xl font-semibold">Где ты находишься?</h2>
<p class="text-sm text-gray-500 mt-1">
Укажи адрес в свободной форме. Для точного поиска рекомендую ввести в формате: улица Такая-то, дом N.
Приложение работает по Нижнему Новгороду.
</p>
</div>
<div class="flex-1 p-5">
<input
v-model="store.address"
class="w-full border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="улица Дальняя, дом 8"
/>
</div>
<div class="p-4 border-t border-gray-100">
<button
:disabled="!store.address.trim()"
:class="{
'opacity-50 cursor-not-allowed': !store.address.trim()
}"
class="w-full bg-cyan-500 hover:bg-cyan-600 text-white py-3 rounded-2xl font-semibold transition"
@click="goNext"
>
Продолжить
</button>
</div>
</div>
</template>
<script setup>
import { useMainStore } from "../store/main";
const store = useMainStore();
function goNext() {
if (store.address.trim()) {
store.screen = 3;
}
}
</script>

View File

@@ -0,0 +1,181 @@
<template>
<div class="flex flex-col h-full bg-white">
<div class="p-5 border-b border-gray-100">
<h2 class="text-xl font-semibold">
Уточняем детали маршрута
</h2>
</div>
<div class="flex-1 overflow-y-auto p-5" ref="messagesContainer">
<div
v-for="(msg, index) in messages"
:key="index"
:class="[
'mb-4 p-3 rounded-lg max-w-[80%]',
msg.role === 'user'
? 'ml-auto bg-cyan-500 text-white'
: 'mr-auto bg-gray-100 text-gray-800'
]"
>
<p class="text-sm whitespace-pre-wrap">{{ msg.content }}</p>
</div>
<div v-if="loading" class="mb-4 p-3 rounded-lg max-w-[80%] mr-auto bg-gray-100 text-gray-800">
<p class="text-sm">Думаю...</p>
</div>
<div ref="bottomRef"></div>
</div>
<div class="p-4 border-t border-gray-100">
<div class="flex gap-2">
<input
v-model="userInput"
@keyup.enter="sendMessage"
:disabled="loading || routeBuilt"
class="flex-1 border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-cyan-500 disabled:bg-gray-100"
placeholder="Напишите сообщение..."
/>
<button
@click="sendMessage"
:disabled="!userInput.trim() || loading || routeBuilt"
:class="{
'opacity-50 cursor-not-allowed': !userInput.trim() || loading || routeBuilt
}"
class="bg-cyan-500 hover:bg-cyan-600 text-white px-6 rounded-lg font-semibold transition"
>
Отправить
</button>
</div>
</div>
</div>
</template>
<script setup>
import {ref, onMounted, nextTick} from 'vue';
import {useMainStore} from '../store/main';
import axios from 'axios';
const store = useMainStore();
const messages = ref([]);
const conversationHistory = ref([]);
const userInput = ref('');
const loading = ref(false);
const routeBuilt = ref(false);
const messagesContainer = ref(null);
const bottomRef = ref(null);
onMounted(async () => {
// Отправляем первое сообщение
const initialMessage = `Я нахожусь: ${store.address}. Мне интересно: ${store.interests}. У меня есть время: ${store.duration}`;
messages.value.push({
role: 'user',
content: initialMessage
});
await scrollToBottom();
await sendToServer(initialMessage);
});
async function sendMessage() {
if (!userInput.value.trim() || loading.value || routeBuilt.value) return;
const message = userInput.value.trim();
userInput.value = '';
messages.value.push({
role: 'user',
content: message
});
await scrollToBottom();
await sendToServer(message);
}
async function sendToServer(userMessage) {
loading.value = true;
try {
const response = await axios.post(import.meta.env.VITE_API_BASE_URL+'/chat', {
user_input: userMessage,
conversation_history: conversationHistory.value
}, {
timeout: 60000 // 60 секунд
});
const data = response.data;
// Обновляем историю разговора
conversationHistory.value = data.conversation_history || [];
if (data.success && !data.need_more_info) {
messages.value.push({
role: 'assistant',
content: data.result.description
});
store.route = {
coordinates: data.result.route,
places: data.result.places,
description: data.result.description,
};
routeBuilt.value = true;
await scrollToBottom();
setTimeout(() => {
store.screen = 6;
}, 2000);
} else if (data.need_more_info) {
const lastMessage = data.conversation_history[data.conversation_history.length - 1];
const assistantMessage = lastMessage?.content || 'Пожалуйста, уточните детали.';
messages.value.push({
role: 'assistant',
content: assistantMessage
});
await scrollToBottom();
} else if (data.error) {
messages.value.push({
role: 'assistant',
content: data.error
});
await scrollToBottom();
}
} catch (error) {
console.error('Chat error:', error);
// Проверка на таймаут
if (error.code === 'ECONNABORTED') {
messages.value.push({
role: 'assistant',
content: 'Время ожидания истекло. Попробуйте еще раз.'
});
} else {
messages.value.push({
role: 'assistant',
content: 'Произошла ошибка. Попробуйте еще раз.'
});
}
await scrollToBottom();
} finally {
loading.value = false;
}
}
async function scrollToBottom() {
await nextTick();
if (bottomRef.value) {
bottomRef.value.scrollIntoView({behavior: 'smooth'});
}
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="flex flex-col h-full bg-white">
<div class="p-5 border-b border-gray-100">
<h2 class="text-xl font-semibold">
Сколько времени у тебя на прогулку?
</h2>
<p class="text-sm text-gray-500 mt-1">
Укажи время в любом формате: «1 час», «90 минут», «полтора часа», «до 6 вечера» и т.д.
</p>
</div>
<div class="flex-1 p-5">
<input
v-model="store.duration"
class="w-full border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="2 часа"
/>
</div>
<div class="p-4 border-t border-gray-100">
<button
:disabled="!store.duration || !store.duration.trim()"
:class="{
'opacity-50 cursor-not-allowed': !store.duration || !store.duration.trim()
}"
class="w-full bg-cyan-500 hover:bg-cyan-600 text-white py-3 rounded-2xl font-semibold transition"
@click="goNext"
>
Продолжить
</button>
</div>
</div>
</template>
<script setup>
import {useMainStore} from "../store/main";
const store = useMainStore();
function goNext() {
if (store.duration && store.duration.trim()) {
store.screen = 5;
}
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex flex-col h-full bg-white">
<div class="p-5">
<h2 class="text-xl font-semibold">Как ты представляешь свой маршрут?</h2>
<p class="text-sm text-gray-500 mt-1">
Опиши в свободной форме.
</p>
</div>
<div class="flex-1 p-5 overflow-y-auto">
<textarea
v-model="store.interests"
@input="resizeTextarea"
ref="textareaRef"
class="w-full border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-cyan-500"
rows="3"
placeholder="Я хочу культурно провести время, а потом выпить кофе"
></textarea>
</div>
<div class="p-4">
<button
class="w-full bg-cyan-500 text-white py-3 rounded-2xl font-semibold cursor-pointer"
:class="{
'hover:bg-cyan-600': isButtonEnabled,
'opacity-50 cursor-not-allowed': !isButtonEnabled,
}"
@click="store.screen = 4"
:disabled="!isButtonEnabled"
>
Далее
</button>
</div>
</div>
</template>
<script setup>
import { computed, ref, nextTick } from "vue";
import { useMainStore } from "../store/main";
const store = useMainStore();
const textareaRef = ref(null);
const isButtonEnabled = computed(() => {
return store.interests.trim().length > 0;
});
function resizeTextarea() {
if (textareaRef.value) {
textareaRef.value.style.height = "auto";
textareaRef.value.style.height = textareaRef.value.scrollHeight + "px";
}
}
nextTick(() => {
resizeTextarea();
});
</script>

View File

@@ -0,0 +1,286 @@
<template>
<div class="flex flex-col h-full bg-white">
<!-- Шапка -->
<div class="p-4 flex justify-between items-center border-b">
<h2 class="text-lg font-semibold">Ваш маршрут готов</h2>
<button
class="text-sm text-cyan-600 cursor-pointer hover:underline"
@click="restart"
>
Начать заново
</button>
</div>
<!-- Основной контент с прокруткой -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Контейнер карты -->
<div class="relative w-full h-64 rounded-lg overflow-hidden border">
<div id="map" class="absolute inset-0"></div>
</div>
<!-- Описание маршрута -->
<div v-if="store.route?.description" class="prose prose-sm max-w-none">
<div v-html="renderedDescription" class="text-gray-700 leading-relaxed"></div>
</div>
<!-- Список точек маршрута -->
<div>
<h3 class="font-semibold text-lg mb-3">Точки маршрута</h3>
<div class="space-y-5">
<div v-for="(place, index) in store.route?.places" :key="index" class="border-b pb-4 last:border-b-0">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 bg-cyan-500 text-white rounded-full h-8 w-8 flex items-center justify-center font-bold">
{{ index + 1 }}
</div>
<div class="flex-grow">
<h4 class="font-semibold text-md">{{ place.title || place.name }}</h4>
<div class="text-xs text-gray-500 mt-1 flex items-center gap-4 flex-wrap">
<span v-if="place.time_to_travel" class="flex items-center gap-1">
<WalkIcon class="w-4 h-4" /> До точки: ~{{ Math.round(place.time_to_travel) }} мин.
</span>
<span v-if="place.time_to_visit" class="flex items-center gap-1">
<HourglassIcon class="w-4 h-4" /> На точке: {{ place.time_to_visit }} мин.
</span>
</div>
<p v-if="place.explanation" class="text-sm text-gray-700 mt-2">
{{ stripQuotes(place.explanation) }}
</p>
<p v-if="place.description" class="text-sm text-gray-700 mt-2">
{{ place.description }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="p-4 border-t">
<button
class="w-full border py-3 rounded-2xl cursor-pointer hover:bg-gray-50 transition"
@click="restart"
>
Построить ещё маршрут
</button>
</div>
</div>
</template>
<script setup>
import { onMounted, nextTick, computed } from "vue";
import { useMainStore } from "../store/main";
import { marked } from "marked";
import "leaflet/dist/leaflet.css";
import { Home as HomeIcon, Footprints as WalkIcon, Hourglass as HourglassIcon } from "lucide-vue-next";
const store = useMainStore();
let map;
const osrmBaseUrl = import.meta.env.VITE_OSRM_BASE_URL;
// Рендерим markdown в HTML
const renderedDescription = computed(() => {
if (!store.route?.description) return '';
return marked(store.route.description);
});
function stripQuotes(str) {
if (!str) return '';
return str.replace(/^"|"$/g, '');
}
async function getRouteFromOSRM() {
if (!osrmBaseUrl || !store.route?.places?.length) {
console.warn("OSRM URL не сконфигурирован или нет точек для маршрута.");
return null;
}
const allCoords = [
store.route.coordinates[0],
...store.route.places.map(p => {
if (typeof p.coordinate === 'string') {
return p.coordinate.split(',').map(Number);
} else if (Array.isArray(p.coordinate)) {
return p.coordinate;
}
return null;
}).filter(c => c !== null)
];
const coordsString = allCoords.map(coord => `${coord[1]},${coord[0]}`).join(';');
const url = `${osrmBaseUrl}/route/v1/foot/${coordsString}?overview=full&geometries=geojson`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`OSRM request failed with status: ${response.status}`);
}
const data = await response.json();
if (data.code === 'Ok' && data.routes && data.routes.length > 0) {
console.log("Маршрут от OSRM успешно получен.");
return data.routes[0].geometry;
} else {
throw new Error(`OSRM response error: ${data.message || 'No route found'}`);
}
} catch (error) {
console.error("Ошибка при получении маршрута от OSRM:", error);
return null;
}
}
async function initMap() {
if (!store.route?.coordinates) return;
const L = await import("leaflet");
await nextTick();
if (map) {
map.remove();
map = null;
}
const container = document.getElementById("map");
if (!container) return;
map = L.map(container, {
zoomControl: false,
attributionControl: false
}).setView(store.route.coordinates[0], 14);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(map);
let routeLayer;
const routeGeometry = await getRouteFromOSRM();
if (routeGeometry) {
routeLayer = L.geoJSON(routeGeometry, {
style: {
color: "#06b6d4",
weight: 5,
opacity: 0.8,
}
}).addTo(map);
} else {
console.warn("Не удалось построить маршрут через OSRM, рисуем прямые линии.");
routeLayer = L.polyline(store.route.coordinates, {
color: "#06b6d4",
weight: 5,
opacity: 0.8,
}).addTo(map);
}
store.route.places.forEach((place, i) => {
let lat, lon;
if (typeof place.coordinate === 'string') {
[lat, lon] = place.coordinate.split(',').map(Number);
} else if (Array.isArray(place.coordinate)) {
[lat, lon] = place.coordinate;
} else {
console.warn(`Неверный формат координат для точки ${i}`);
return;
}
const customIcon = L.divIcon({
className: 'custom-div-icon',
html: `<div style='background-color:#06b6d4; color:white; border-radius:50%; width:24px; height:24px; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:bold; border: 2px solid white;'>${i + 1}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const popupContent = `<b>${place.title || place.name}</b>${place.address ? '<br>' + place.address : ''}`;
L.marker([lat, lon], {icon: customIcon})
.addTo(map)
.bindPopup(popupContent);
});
const startIcon = L.divIcon({
className: 'start-div-icon',
html: `
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24' style='color:#06b6d4;'>
<path
d='M3 9.75V21h6v-5.25a3 3 0 0 1 6 0V21h6V9.75L12 3 3 9.75z'
fill='#06b6d4'
stroke='white'
stroke-width='2'
stroke-linejoin='round'
/>
</svg>
`,
iconSize: [24, 24],
iconAnchor: [12, 24]
});
L.marker(store.route.coordinates[0], {icon: startIcon})
.addTo(map)
.bindPopup("<b>Вы здесь</b><br>" + store.address);
map.fitBounds(routeLayer.getBounds().pad(0.1));
setTimeout(() => map.invalidateSize(), 350);
}
onMounted(() => initMap());
function restart() {
store.reset();
}
</script>
<style scoped>
/* Стили для markdown контента */
.prose :deep(h1) {
font-size: 1.5rem;
font-weight: 700;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.prose :deep(h2) {
font-size: 1.25rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.5rem;
}
.prose :deep(h3) {
font-size: 1.125rem;
font-weight: 600;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.prose :deep(p) {
margin-bottom: 0.75rem;
}
.prose :deep(ul), .prose :deep(ol) {
margin-left: 1.5rem;
margin-bottom: 0.75rem;
}
.prose :deep(li) {
margin-bottom: 0.25rem;
}
.prose :deep(strong) {
font-weight: 600;
}
.prose :deep(em) {
font-style: italic;
}
.prose :deep(code) {
background-color: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="h-full flex flex-col justify-between bg-[#0b61a8] text-white">
<div
class="flex-1 flex flex-col items-center justify-center text-center p-6"
>
<h1 class="text-2xl font-bold mb-2">
Привет. Я ИИ-помощник туриста.
</h1>
<p class="mt-6 mb-6">Давай составим пешеходный маршрут по Нижнему Новгороду?</p>
<div class="text-6xl mt-4 flex gap-4 justify-center">
<PlaneIcon class="w-12 h-12" />
<MapIcon class="w-12 h-12" />
</div>
<p class="mt-10">
Я помогу подобрать прогулку расскажи, где ты, что любишь и
сколько времени хочешь потратить.
</p>
</div>
<div class="p-4">
<button
class="w-full bg-cyan-500 hover:bg-cyan-600 text-white font-semibold py-3 rounded-2xl cursor-pointer"
@click="store.screen = 2"
>
Давай
</button>
</div>
</div>
</template>
<script setup>
import { useMainStore } from "../store/main";
import { Plane as PlaneIcon, Map as MapIcon } from "lucide-vue-next";
const store = useMainStore();
</script>

8
web-ui/src/main.js Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './assets/tailwind.css'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')

26
web-ui/src/store/main.js Normal file
View File

@@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'
export const useMainStore = defineStore('main', () => {
const screen = ref(1)
const address = ref('')
const interests = ref('')
const duration = ref('')
const chat = reactive({ messages: [], asked: 0 })
function reset() {
screen.value = 1
address.value = ''
duration.value = ''
interests.value = ''
}
return {
screen,
address,
duration,
interests,
chat,
reset,
}
})

11
web-ui/tailwind.config.js Normal file
View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

10
web-ui/vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
tailwindcss(),
vue(),
],
})