feat: новая функция
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -14,68 +14,231 @@
|
||||
<div class='modal-bg'>
|
||||
<div class='modal'>
|
||||
<h3>{{ rule ? 'Редактировать' : 'Создать' }} набор правил</h3>
|
||||
<label>Название</label>
|
||||
<input v-model="name" />
|
||||
|
||||
<label>Название *</label>
|
||||
<input v-model="name" placeholder="Например: VIP Правило" />
|
||||
|
||||
<label>Описание</label>
|
||||
<textarea v-model="description" rows="3" placeholder="Опишите правило в свободной форме" />
|
||||
<label>Правила (JSON)</label>
|
||||
<textarea v-model="rulesJson" rows="6"></textarea>
|
||||
<textarea v-model="description" rows="2" placeholder="Опишите правило в свободной форме" />
|
||||
|
||||
<div class="rules-section">
|
||||
<h4>Системный промпт</h4>
|
||||
<textarea
|
||||
v-model="ruleFields.system_prompt"
|
||||
rows="4"
|
||||
placeholder="Дополнительный системный промпт для этого правила. Например: 'Ты работаешь с VIP клиентами. Будь вежливым и профессиональным.'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rules-section">
|
||||
<h4>Параметры LLM</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Temperature (0.0-2.0)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="ruleFields.temperature"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
placeholder="0.7"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max Tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="ruleFields.max_tokens"
|
||||
min="1"
|
||||
max="4000"
|
||||
placeholder="500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rules-section">
|
||||
<h4>Правила поведения</h4>
|
||||
<div class="form-group">
|
||||
<label>Разрешенные темы (через запятую)</label>
|
||||
<input
|
||||
v-model="allowedTopicsText"
|
||||
placeholder="продукт, поддержка, VIP услуги"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Запрещенные слова (через запятую)</label>
|
||||
<input
|
||||
v-model="forbiddenWordsText"
|
||||
placeholder="ругательство, спам"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-checkbox">
|
||||
<label>
|
||||
<input type="checkbox" v-model="ruleFields.checkUserTags" />
|
||||
Учитывать теги пользователя при фильтрации RAG
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-checkbox">
|
||||
<label>
|
||||
<input type="checkbox" v-model="ruleFields.searchRagFirst" />
|
||||
Сначала искать в RAG базе знаний
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-checkbox">
|
||||
<label>
|
||||
<input type="checkbox" v-model="ruleFields.generateIfNoRag" />
|
||||
Генерировать ответ, если ничего не найдено в RAG
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rules-section" v-if="showJsonPreview">
|
||||
<h4>Предпросмотр JSON</h4>
|
||||
<pre class="json-preview">{{ generatedJson }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="save">Сохранить</button>
|
||||
<button @click="save" :disabled="!name.trim()">Сохранить</button>
|
||||
<button @click="close">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const props = defineProps({ rule: Object });
|
||||
|
||||
const name = ref(props.rule ? props.rule.name : '');
|
||||
const description = ref(props.rule ? props.rule.description : '');
|
||||
const rulesJson = ref(props.rule ? JSON.stringify(props.rule.rules, null, 2) : '{\n "checkUserTags": true\n}');
|
||||
const error = ref('');
|
||||
const showJsonPreview = ref(false);
|
||||
|
||||
watch(() => props.rule, (newRule) => {
|
||||
name.value = newRule ? newRule.name : '';
|
||||
description.value = newRule ? newRule.description : '';
|
||||
rulesJson.value = newRule ? JSON.stringify(newRule.rules, null, 2) : '{\n "checkUserTags": true\n}';
|
||||
// Поля правила
|
||||
const ruleFields = ref({
|
||||
system_prompt: props.rule?.rules?.system_prompt || '',
|
||||
temperature: props.rule?.rules?.temperature ?? 0.7,
|
||||
max_tokens: props.rule?.rules?.max_tokens ?? 500,
|
||||
checkUserTags: props.rule?.rules?.rules?.checkUserTags ?? true,
|
||||
searchRagFirst: props.rule?.rules?.rules?.searchRagFirst ?? true,
|
||||
generateIfNoRag: props.rule?.rules?.rules?.generateIfNoRag ?? true,
|
||||
allowed_topics: props.rule?.rules?.rules?.allowed_topics || [],
|
||||
forbidden_words: props.rule?.rules?.rules?.forbidden_words || []
|
||||
});
|
||||
|
||||
function convertToJson() {
|
||||
// Простейший пример: если в описании есть "теги", выставляем checkUserTags
|
||||
// В реальном проекте здесь можно интегрировать LLM или шаблоны
|
||||
try {
|
||||
if (/тег[а-я]* пользов/.test(description.value.toLowerCase())) {
|
||||
rulesJson.value = JSON.stringify({ checkUserTags: true }, null, 2);
|
||||
error.value = '';
|
||||
} else {
|
||||
rulesJson.value = JSON.stringify({ customRule: description.value }, null, 2);
|
||||
error.value = '';
|
||||
// Текстовые поля для массивов
|
||||
const allowedTopicsText = ref(
|
||||
props.rule?.rules?.rules?.allowed_topics?.join(', ') || ''
|
||||
);
|
||||
const forbiddenWordsText = ref(
|
||||
props.rule?.rules?.rules?.forbidden_words?.join(', ') || ''
|
||||
);
|
||||
|
||||
// Генерация JSON из полей формы
|
||||
const generatedJson = computed(() => {
|
||||
const rules = {
|
||||
system_prompt: ruleFields.value.system_prompt || undefined,
|
||||
temperature: ruleFields.value.temperature,
|
||||
max_tokens: ruleFields.value.max_tokens,
|
||||
rules: {
|
||||
checkUserTags: ruleFields.value.checkUserTags,
|
||||
searchRagFirst: ruleFields.value.searchRagFirst,
|
||||
generateIfNoRag: ruleFields.value.generateIfNoRag,
|
||||
allowed_topics: allowedTopicsText.value
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(t => t.length > 0),
|
||||
forbidden_words: forbiddenWordsText.value
|
||||
.split(',')
|
||||
.map(w => w.trim())
|
||||
.filter(w => w.length > 0)
|
||||
}
|
||||
};
|
||||
|
||||
// Удаляем undefined поля
|
||||
Object.keys(rules).forEach(key => {
|
||||
if (rules[key] === undefined) delete rules[key];
|
||||
});
|
||||
|
||||
return JSON.stringify(rules, null, 2);
|
||||
});
|
||||
|
||||
watch(() => props.rule, (newRule) => {
|
||||
if (newRule) {
|
||||
name.value = newRule.name || '';
|
||||
description.value = newRule.description || '';
|
||||
|
||||
ruleFields.value = {
|
||||
system_prompt: newRule.rules?.system_prompt || '',
|
||||
temperature: newRule.rules?.temperature ?? 0.7,
|
||||
max_tokens: newRule.rules?.max_tokens ?? 500,
|
||||
checkUserTags: newRule.rules?.rules?.checkUserTags ?? true,
|
||||
searchRagFirst: newRule.rules?.rules?.searchRagFirst ?? true,
|
||||
generateIfNoRag: newRule.rules?.rules?.generateIfNoRag ?? true,
|
||||
allowed_topics: newRule.rules?.rules?.allowed_topics || [],
|
||||
forbidden_words: newRule.rules?.rules?.forbidden_words || []
|
||||
};
|
||||
|
||||
allowedTopicsText.value = (newRule.rules?.rules?.allowed_topics || []).join(', ');
|
||||
forbiddenWordsText.value = (newRule.rules?.rules?.forbidden_words || []).join(', ');
|
||||
} else {
|
||||
// Сброс для нового правила
|
||||
name.value = '';
|
||||
description.value = '';
|
||||
ruleFields.value = {
|
||||
system_prompt: '',
|
||||
temperature: 0.7,
|
||||
max_tokens: 500,
|
||||
checkUserTags: true,
|
||||
searchRagFirst: true,
|
||||
generateIfNoRag: true,
|
||||
allowed_topics: [],
|
||||
forbidden_words: []
|
||||
};
|
||||
allowedTopicsText.value = '';
|
||||
forbiddenWordsText.value = '';
|
||||
}
|
||||
error.value = '';
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
error.value = 'Название обязательно для заполнения';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Генерируем JSON из полей формы
|
||||
const rules = JSON.parse(generatedJson.value);
|
||||
|
||||
if (props.rule && props.rule.id) {
|
||||
await axios.put(`/settings/ai-assistant-rules/${props.rule.id}`, {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
rules
|
||||
});
|
||||
} else {
|
||||
await axios.post('/settings/ai-assistant-rules', {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
rules
|
||||
});
|
||||
}
|
||||
emit('close', true);
|
||||
} catch (e) {
|
||||
error.value = 'Не удалось преобразовать описание в JSON';
|
||||
error.value = `Ошибка сохранения: ${e.message}`;
|
||||
console.error('Ошибка сохранения правила:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
let rules;
|
||||
try {
|
||||
rules = JSON.parse(rulesJson.value);
|
||||
} catch (e) {
|
||||
error.value = 'Ошибка в формате JSON!';
|
||||
return;
|
||||
}
|
||||
if (props.rule && props.rule.id) {
|
||||
await axios.put(`/settings/ai-assistant-rules/${props.rule.id}`, { name: name.value, description: description.value, rules });
|
||||
} else {
|
||||
await axios.post('/settings/ai-assistant-rules', { name: name.value, description: description.value, rules });
|
||||
}
|
||||
emit('close', true);
|
||||
function close() {
|
||||
emit('close', false);
|
||||
}
|
||||
function close() { emit('close', false); }
|
||||
</script>
|
||||
<style scoped>
|
||||
.modal-bg {
|
||||
@@ -86,48 +249,169 @@ function close() { emit('close', false); }
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 16px rgba(0,0,0,0.12);
|
||||
padding: 2rem;
|
||||
min-width: 320px;
|
||||
max-width: 420px;
|
||||
min-width: 500px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1rem;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
input, textarea {
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.625rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ddd;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="number"]:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #007bff);
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.rules-section {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.rules-section:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.form-checkbox label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0;
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.form-checkbox input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.json-preview {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--color-primary);
|
||||
background: var(--color-primary, #007bff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1.5rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--color-primary-dark, #0056b3);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button:last-child {
|
||||
background: #eee;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
button:last-child:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c00;
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #ffe6e6;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ffcccc;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -132,7 +132,7 @@ import { usePermissions } from '@/composables/usePermissions';
|
||||
|
||||
const props = defineProps(['rowId', 'column', 'cellValues']);
|
||||
const emit = defineEmits(['update']);
|
||||
const { canEditDataData } = usePermissions();
|
||||
const { canEditData } = usePermissions();
|
||||
|
||||
const localValue = ref('');
|
||||
const editing = ref(false);
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function connectWithWallet() {
|
||||
const docsResponse = await axios.get('/consent/documents');
|
||||
if (docsResponse.data && docsResponse.data.length > 0) {
|
||||
docsResponse.data.forEach(doc => {
|
||||
resources.push(`${window.location.origin}/public/page/${doc.id}`);
|
||||
resources.push(`${window.location.origin}/content/published/${doc.id}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -169,9 +169,11 @@ import { useAuthContext } from '@/composables/useAuth';
|
||||
import { usePermissions } from '@/composables/usePermissions';
|
||||
import { PERMISSIONS } from './permissions.js';
|
||||
import { useContactsAndMessagesWebSocket } from '@/composables/useContactsWebSocket';
|
||||
import websocketServiceModule from '@/services/websocketService';
|
||||
const { canEditContacts, canDeleteData, canManageTags, canBlockUsers, canSendToUsers, canGenerateAI, canViewContacts, hasPermission } = usePermissions();
|
||||
const { address, userId: currentUserId } = useAuthContext();
|
||||
const { markContactAsRead } = useContactsAndMessagesWebSocket();
|
||||
const { websocketService } = websocketServiceModule;
|
||||
|
||||
// Подписываемся на централизованные события очистки и обновления данных
|
||||
onMounted(() => {
|
||||
@@ -220,6 +222,13 @@ const tagsTableId = ref(null);
|
||||
const { onTagsUpdate } = useTagsWebSocket();
|
||||
let unsubscribeFromTags = null;
|
||||
|
||||
// Обработчик обновления контактов через WebSocket
|
||||
const handleContactsUpdate = async () => {
|
||||
console.log('[ContactDetailsView] Получено обновление контакта, перезагружаем данные');
|
||||
await reloadContact();
|
||||
await loadUserTags();
|
||||
};
|
||||
|
||||
// Функция маскировки персональных данных для читателей
|
||||
function maskPersonalData(data) {
|
||||
if (!data || data === '-') return '-';
|
||||
@@ -725,6 +734,9 @@ onMounted(async () => {
|
||||
await loadAllTags();
|
||||
await loadUserTags();
|
||||
});
|
||||
|
||||
// Подписываемся на обновления контактов (для обновления имени)
|
||||
websocketService.on('contacts-updated', handleContactsUpdate);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -732,6 +744,7 @@ onUnmounted(() => {
|
||||
if (unsubscribeFromTags) {
|
||||
unsubscribeFromTags();
|
||||
}
|
||||
websocketService.off('contacts-updated', handleContactsUpdate);
|
||||
});
|
||||
watch(userId, async () => {
|
||||
await reloadContact();
|
||||
|
||||
@@ -43,10 +43,27 @@
|
||||
<span class="page-status"><i class="fas fa-file"></i>{{ p.format || 'html' }}</span>
|
||||
</div>
|
||||
<div v-if="canManageLegalDocs && address" class="page-actions">
|
||||
<button class="action-btn primary" title="Индексировать" @click.stop="reindex(p.id)"><i class="fas fa-sync"></i><span>Индекс</span></button>
|
||||
<button
|
||||
class="action-btn primary"
|
||||
title="Индексировать"
|
||||
:disabled="reindexStatus[p.id]?.state === 'loading'"
|
||||
@click.stop="reindex(p.id)"
|
||||
>
|
||||
<i :class="['fas', reindexStatus[p.id]?.state === 'loading' ? 'fa-spinner fa-spin' : 'fa-sync']"></i>
|
||||
<span>Индекс</span>
|
||||
</button>
|
||||
<button class="action-btn primary" title="Редактировать" @click.stop="goEdit(p.id)"><i class="fas fa-edit"></i><span>Ред.</span></button>
|
||||
<button class="action-btn danger" title="Удалить" @click.stop="doDelete(p.id)"><i class="fas fa-trash"></i><span>Удалить</span></button>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="reindexStatus[p.id]"
|
||||
class="reindex-status"
|
||||
:class="reindexStatus[p.id].state"
|
||||
>
|
||||
{{ reindexStatus[p.id].message }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,7 +77,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import BaseLayout from '../../components/BaseLayout.vue';
|
||||
import pagesService from '../../services/pagesService';
|
||||
@@ -79,19 +96,48 @@ const props = defineProps({
|
||||
const router = useRouter();
|
||||
const search = ref('');
|
||||
const pages = ref([]);
|
||||
const reindexStatus = ref({});
|
||||
const { address } = useAuthContext();
|
||||
const { hasPermission } = usePermissions();
|
||||
const canManageLegalDocs = computed(() => hasPermission(SHARED_PERMISSIONS.MANAGE_LEGAL_DOCS));
|
||||
|
||||
const reindexTimers = new Map();
|
||||
|
||||
function goBack() { router.push({ name: 'content-list' }); }
|
||||
function openPublic(id) { router.push({ name: 'public-page-view', params: { id } }); }
|
||||
function goEdit(id) { router.push({ name: 'content-create', query: { edit: id } }); }
|
||||
function updateReindexStatus(id, state, message) {
|
||||
reindexStatus.value = {
|
||||
...reindexStatus.value,
|
||||
[id]: { state, message }
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReindexCleanup(id, delay = 4000) {
|
||||
if (reindexTimers.has(id)) {
|
||||
clearTimeout(reindexTimers.get(id));
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
const next = { ...reindexStatus.value };
|
||||
delete next[id];
|
||||
reindexStatus.value = next;
|
||||
reindexTimers.delete(id);
|
||||
}, delay);
|
||||
reindexTimers.set(id, timer);
|
||||
}
|
||||
async function reindex(id) {
|
||||
try {
|
||||
if (reindexStatus.value[id]?.state === 'loading') {
|
||||
return;
|
||||
}
|
||||
updateReindexStatus(id, 'loading', 'Индексация запущена...');
|
||||
await api.post(`/pages/${id}/reindex`);
|
||||
alert('Индексация выполнена');
|
||||
updateReindexStatus(id, 'success', 'Индексация выполняется. Проверьте логи.');
|
||||
scheduleReindexCleanup(id);
|
||||
} catch (e) {
|
||||
alert('Ошибка индексации: ' + (e?.response?.data?.error || e.message));
|
||||
const errorMessage = e?.response?.data?.error || e.message;
|
||||
updateReindexStatus(id, 'error', `Ошибка индексации: ${errorMessage}`);
|
||||
scheduleReindexCleanup(id, 6000);
|
||||
}
|
||||
}
|
||||
async function doDelete(id) {
|
||||
@@ -119,6 +165,11 @@ onMounted(async () => {
|
||||
pages.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
reindexTimers.forEach(timer => clearTimeout(timer));
|
||||
reindexTimers.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -151,6 +202,13 @@ onMounted(async () => {
|
||||
.action-btn.primary:hover { background: var(--color-primary-dark); }
|
||||
.action-btn.danger { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
|
||||
.action-btn.danger:hover { background: #fee2e2; }
|
||||
.action-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.reindex-status { margin-top: 10px; font-size: 0.9rem; font-weight: 500; }
|
||||
.reindex-status.loading { color: #2563eb; }
|
||||
.reindex-status.success { color: #16a34a; }
|
||||
.reindex-status.error { color: #dc2626; }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
.empty-state { text-align:center; padding: 60px 20px; }
|
||||
.empty-icon { font-size: 3rem; color: var(--color-grey-dark); margin-bottom: 10px; }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,13 +66,21 @@ export default defineConfig({
|
||||
rewrite: (path) => path,
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('error', (err, req, res) => {
|
||||
// Игнорируем ошибки ECONNREFUSED при старте сервера - это нормально
|
||||
if (err.code === 'ECONNREFUSED' || err.message.includes('ECONNREFUSED')) {
|
||||
// Не логируем как ошибку - это нормальное поведение при рестарте сервера
|
||||
// Фронтенд автоматически переподключится
|
||||
return;
|
||||
}
|
||||
console.log('WebSocket proxy error:', err.message);
|
||||
});
|
||||
proxy.on('proxyReqWs', (proxyReq, req, socket) => {
|
||||
console.log('WebSocket proxy request to:', req.url);
|
||||
// Убираем избыточное логирование - это происходит слишком часто
|
||||
// console.log('WebSocket proxy request to:', req.url);
|
||||
});
|
||||
proxy.on('proxyResWs', (proxyRes, req, socket) => {
|
||||
console.log('WebSocket proxy response:', proxyRes.statusCode);
|
||||
// Убираем избыточное логирование
|
||||
// console.log('WebSocket proxy response:', proxyRes.statusCode);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user