377 lines
12 KiB
Vue
377 lines
12 KiB
Vue
<script setup>
|
||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||
import { NDrawer, NDrawerContent, NInput, NButton, NAlert, NIcon, NSpin, NFlex, NEl } from 'naive-ui';
|
||
import { TbChartBubble, TbX, TbSend } from 'vue-icons-plus/tb';
|
||
import axios from 'axios';
|
||
import { marked } from 'marked'
|
||
import {useAuthStore} from "../../Stores/auth.js";
|
||
|
||
// Пропсы (период, может быть null)
|
||
const props = defineProps({
|
||
periodStart: {
|
||
type: String,
|
||
default: null,
|
||
},
|
||
periodEnd: {
|
||
type: String,
|
||
default: null,
|
||
},
|
||
});
|
||
|
||
const userStore = useAuthStore()
|
||
|
||
// Состояние
|
||
const isOpen = ref(false);
|
||
const question = ref('');
|
||
const loading = ref(false);
|
||
const error = ref(null);
|
||
const history = ref([]);
|
||
const chatContainer = ref(null);
|
||
let abortController = null;
|
||
let isFirstOpen = true;
|
||
|
||
// Загрузка истории из localStorage
|
||
onMounted(() => {
|
||
const saved = localStorage.getItem('ai_stats_history');
|
||
if (saved) {
|
||
try {
|
||
history.value = JSON.parse(saved).slice(0, 50);
|
||
} catch (e) {
|
||
history.value = [];
|
||
}
|
||
}
|
||
});
|
||
|
||
// Сохранение истории
|
||
function saveHistory() {
|
||
localStorage.setItem('ai_stats_history', JSON.stringify(history.value));
|
||
}
|
||
|
||
// Добавление нового сообщения в историю (в конец)
|
||
function addMessage(type, content, structured = null) {
|
||
history.value.push({
|
||
type, // 'user' или 'assistant'
|
||
content,
|
||
structured,
|
||
time: new Date().toLocaleString(),
|
||
});
|
||
saveHistory();
|
||
scrollToBottom();
|
||
}
|
||
|
||
// Обновление последнего сообщения (для замены "Думаю..." на ответ)
|
||
function updateLastMessage(content, structured = null) {
|
||
const last = history.value[history.value.length - 1];
|
||
if (last && last.type === 'assistant') {
|
||
last.content = content;
|
||
last.structured = structured || null;
|
||
saveHistory();
|
||
scrollToBottom();
|
||
}
|
||
}
|
||
|
||
// Очистка истории
|
||
function clearHistory() {
|
||
history.value = [];
|
||
localStorage.removeItem('ai_stats_history');
|
||
}
|
||
|
||
// Прокрутка вниз
|
||
function scrollToBottom() {
|
||
nextTick(() => {
|
||
if (chatContainer.value) {
|
||
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Основной метод запроса
|
||
async function ask() {
|
||
const userQuestion = question.value.trim();
|
||
if (!userQuestion) return;
|
||
|
||
// Отменяем предыдущий запрос
|
||
if (abortController) {
|
||
abortController.abort();
|
||
abortController = null;
|
||
}
|
||
|
||
// Сброс ошибки
|
||
error.value = null;
|
||
|
||
// Добавляем вопрос пользователя в историю
|
||
addMessage('user', userQuestion);
|
||
question.value = '';
|
||
|
||
// Добавляем заглушку ответа ассистента ("Думаю...")
|
||
addMessage('assistant', 'Думаю...', null);
|
||
|
||
loading.value = true;
|
||
|
||
abortController = new AbortController();
|
||
|
||
try {
|
||
const response = await axios.post(
|
||
'/api/ai/ask',
|
||
{
|
||
question: userQuestion,
|
||
period_start: props.periodStart,
|
||
period_end: props.periodEnd,
|
||
},
|
||
{
|
||
signal: abortController.signal,
|
||
}
|
||
);
|
||
|
||
if (response.data.success) {
|
||
const { answer, structured } = response.data;
|
||
// Обновляем последнее сообщение ассистента
|
||
updateLastMessage(answer, structured || null);
|
||
} else {
|
||
error.value = response.data.message || 'Ошибка получения ответа';
|
||
// Удаляем заглушку, если ошибка
|
||
history.value.pop();
|
||
saveHistory();
|
||
}
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') {
|
||
// Запрос отменён – удаляем заглушку и вопрос? Оставим вопрос, заглушку убираем
|
||
history.value.pop(); // убираем "Думаю..."
|
||
saveHistory();
|
||
return;
|
||
}
|
||
error.value = 'Не удалось получить ответ. Проверьте соединение и попробуйте снова.';
|
||
console.error('AI stats error:', err);
|
||
// Удаляем заглушку
|
||
history.value.pop();
|
||
saveHistory();
|
||
} finally {
|
||
loading.value = false;
|
||
abortController = null;
|
||
scrollToBottom();
|
||
}
|
||
}
|
||
|
||
// Отмена запроса при размонтировании
|
||
onBeforeUnmount(() => {
|
||
if (abortController) {
|
||
abortController.abort();
|
||
}
|
||
});
|
||
|
||
// Переключение дровера
|
||
function toggleOpen() {
|
||
isOpen.value = !isOpen.value;
|
||
if (isOpen.value && isFirstOpen) {
|
||
isFirstOpen = false;
|
||
setTimeout(scrollToBottom, 300);
|
||
}
|
||
}
|
||
|
||
function renderMarkdown(text) {
|
||
if (!text) return ''
|
||
return marked.parse(text)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="ai-fab-container">
|
||
<!-- FAB -->
|
||
<n-button
|
||
v-if="userStore.hasPermission('ai.access')"
|
||
circle
|
||
type="primary"
|
||
size="large"
|
||
class="ai-fab"
|
||
@click="toggleOpen"
|
||
>
|
||
<n-icon size="24">
|
||
<TbChartBubble />
|
||
</n-icon>
|
||
</n-button>
|
||
|
||
<!-- Drawer -->
|
||
<n-drawer v-model:show="isOpen" width="calc(100vw / 3)" placement="right" :mask-closable="true">
|
||
<n-drawer-content :native-scrollbar="false">
|
||
<template #header>
|
||
<div class="flex items-center justify-between w-full">
|
||
<span class="text-lg font-medium">Ассистент</span>
|
||
<div class="flex gap-2">
|
||
<n-button size="small" quaternary @click="clearHistory" v-if="history.length">
|
||
Очистить историю
|
||
</n-button>
|
||
<n-button size="small" quaternary @click="toggleOpen">
|
||
<n-icon><TbX /></n-icon>
|
||
</n-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Контейнер чата -->
|
||
<div class="chat-container">
|
||
<!-- Область сообщений -->
|
||
<div ref="chatContainer" class="chat-messages">
|
||
<!-- Пустое состояние -->
|
||
<div v-if="!history.length" class="empty-state">
|
||
<p class="text-gray-400 text-sm">Задайте вопрос, и я постараюсь помочь вам.</p>
|
||
</div>
|
||
|
||
<!-- Сообщения -->
|
||
<div v-for="(item, idx) in history" :key="idx" class="message-item">
|
||
<!-- Если тип 'user' -->
|
||
<NEl v-if="item.type === 'user'" class="message message-user">
|
||
<div class="message-bubble user-bubble">
|
||
<div class="message-header">
|
||
<span class="text-xs">{{ item.time }}</span>
|
||
</div>
|
||
<div class="message-text">{{ item.content }}</div>
|
||
</div>
|
||
</NEl>
|
||
|
||
<!-- Если тип 'assistant' -->
|
||
<NEl v-else class="message message-assistant">
|
||
<div class="message-bubble assistant-bubble">
|
||
<div class="message-header">
|
||
<span class="text-xs text-gray-400">Ассистент</span>
|
||
<span class="text-xs text-gray-400">{{ item.time }}</span>
|
||
</div>
|
||
<div class="message-text" v-if="item.content !== 'Думаю...'" v-html="renderMarkdown(item.content)" />
|
||
<div v-else class="message-text flex items-center gap-2 min-w-[200px]">
|
||
<n-spin size="small" scale="0.5" />
|
||
<span>Думаю...</span>
|
||
</div>
|
||
</div>
|
||
</NEl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<NSpace vertical class="w-full">
|
||
<!-- Ошибка -->
|
||
<n-alert v-if="error" type="error" class="mb-2" closable @close="error = null">
|
||
{{ error }}
|
||
</n-alert>
|
||
|
||
<!-- Форма ввода -->
|
||
<NEl class="p-2 rounded-lg" style="background-color: var(--input-color);">
|
||
<n-input
|
||
v-model:value="question"
|
||
type="textarea"
|
||
placeholder="Задайте вопрос..."
|
||
:autosize="{ minRows: 2, maxRows: 2 }"
|
||
:disabled="loading"
|
||
:focusable="false"
|
||
@keydown.enter.prevent="ask"
|
||
style="--n-color: transparent; --n-color-disabled: transparent; --n-border: none; --n-border-hover: none; --n-border-focus: none; --n-color-focus: none; --n-box-shadow-focus: none;"
|
||
/>
|
||
<NFlex justify="end" align="end">
|
||
<n-button
|
||
circle
|
||
type="primary"
|
||
:loading="loading"
|
||
:disabled="!question.trim()"
|
||
@click="ask"
|
||
>
|
||
<template #icon>
|
||
<TbSend size="18" />
|
||
</template>
|
||
</n-button>
|
||
</NFlex>
|
||
</NEl>
|
||
</NSpace>
|
||
</template>
|
||
</n-drawer-content>
|
||
</n-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.ai-fab-container {
|
||
position: fixed;
|
||
bottom: 2rem;
|
||
right: 2rem;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.ai-fab {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
transition: transform 0.2s;
|
||
}
|
||
.ai-fab:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
/* Контейнер чата внутри DrawerContent */
|
||
.chat-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
}
|
||
|
||
.chat-messages {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
min-height: 200px;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 2rem 0;
|
||
color: #aaa;
|
||
}
|
||
|
||
.message-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.message {
|
||
display: flex;
|
||
}
|
||
.message-user {
|
||
justify-content: flex-end;
|
||
}
|
||
.message-assistant {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.message-bubble {
|
||
max-width: 80%;
|
||
padding: 0.6rem 0.8rem;
|
||
border-radius: 12px;
|
||
word-wrap: break-word;
|
||
}
|
||
.user-bubble {
|
||
background: var(--primary-color-suppl);
|
||
color: white;
|
||
}
|
||
.assistant-bubble {
|
||
background: var(--input-color);
|
||
color: white;
|
||
}
|
||
|
||
.message-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 0.7rem;
|
||
color: #888;
|
||
margin-bottom: 0.2rem;
|
||
}
|
||
.user-bubble .message-header {
|
||
color: rgba(255,255,255,0.7);
|
||
}
|
||
.message-text {
|
||
font-size: 0.9rem;
|
||
line-height: 1.4;
|
||
}
|
||
.message-text .text-gray-400 {
|
||
color: #999;
|
||
}
|
||
|
||
.chat-input .n-input {
|
||
flex: 1;
|
||
}
|
||
</style>
|