Compare commits
35 Commits
fa9fe1fa47
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c27dce95b5 | |||
| 27cc9cc972 | |||
| 28f443a393 | |||
| eea92e19b9 | |||
| bf83ed8770 | |||
| 45551a64f1 | |||
| d10a74eea5 | |||
| f7b91ed75e | |||
| e3d9eedbab | |||
| 148518d1ef | |||
| c19ec3b232 | |||
| 9318821099 | |||
| cb4ca3a570 | |||
| 27839d3fae | |||
| 2e7d6ac7c1 | |||
| e9129667ed | |||
| e20f2a95df | |||
| d4150045e2 | |||
| 82ff923256 | |||
| 686daa1a37 | |||
| e61634e396 | |||
| 49361d3561 | |||
| 5b7ab3bf5a | |||
| a05e02f5df | |||
| 729006359c | |||
| 9388703b04 | |||
| 094df8e802 | |||
| 11dcd11a3e | |||
| da02da6661 | |||
| c134708b79 | |||
| c62b151926 | |||
| 6e58337490 | |||
| 796769d270 | |||
| 5652b52ca9 | |||
| 67a775c82a |
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" href="https://img.icons8.com/?size=100&id=0ny63oDHuHzk&format=png&color=000000" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Parser</title>
|
||||
<script src="https://www.google.com/recaptcha/api.js?render=6LdfSo8sAAAAAGGhbgIGO51nHgMUALYjcAMOxnOg"></script>
|
||||
<!-- <script src="https://www.google.com/recaptcha/api.js?render=6LdfSo8sAAAAAGGhbgIGO51nHgMUALYjcAMOxnOg"></script> -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
70
src/App.vue
70
src/App.vue
@@ -1,66 +1,28 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import All_new_section from "./components/All_new_section.vue";
|
||||
import General_section from "./components/General_section.vue";
|
||||
import Setings from "./components/Setings.vue";
|
||||
import Authe from "./components/Authe.vue";
|
||||
import Istochnik from "./components/Istochnik.vue";
|
||||
import { ref } from 'vue'
|
||||
import NavBar from './components/Naw_bar/All_new_section.vue'
|
||||
import NewsFeed from './components/News_section/General_section.vue'
|
||||
import Settings from './components/Settings_section/Settings.vue'
|
||||
import Auth from './components/Autherization/Authe.vue'
|
||||
import Sources from './components/Istochnik_section/Istochnik.vue'
|
||||
|
||||
// Инициализация темы при загрузке приложения
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
if (savedTheme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else if (savedTheme === "light") {
|
||||
document.documentElement.classList.remove("dark");
|
||||
} else {
|
||||
// Проверяем системные настройки
|
||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
}
|
||||
});
|
||||
const currentPage = ref('admin-panel')
|
||||
|
||||
// Состояния страницы и данные для входа
|
||||
const currentPage = ref("admin-panel");
|
||||
|
||||
function handleUpdate(newValue) {
|
||||
currentPage.value = newValue; // изменение значения в родителе
|
||||
function navigateTo(page) {
|
||||
currentPage.value = page
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<Authe :currentPage="currentPage" @update="handleUpdate" />
|
||||
<div v-if="currentPage === 'rezylt'">
|
||||
<div class="sm:flex">
|
||||
<All_new_section :currentPage="currentPage" @update="handleUpdate" />
|
||||
<General_section filter="all" />
|
||||
</div>
|
||||
</div>
|
||||
<Auth :currentPage="currentPage" @update="navigateTo" />
|
||||
|
||||
<div v-if="currentPage === 'setings'">
|
||||
<template v-if="currentPage !== 'admin-panel'">
|
||||
<div class="sm:flex">
|
||||
<All_new_section :currentPage="currentPage" @update="handleUpdate" />
|
||||
<Setings />
|
||||
<NavBar :currentPage="currentPage" @update="navigateTo" />
|
||||
<NewsFeed v-if="currentPage === 'rezylt'" filter="all" />
|
||||
<Settings v-else-if="currentPage === 'setings'" />
|
||||
<Sources v-else-if="currentPage === 'istochnik'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div v-if="currentPage === 'status'">
|
||||
<div class="sm:flex">
|
||||
<All_new_section :currentPage="currentPage" @update="handleUpdate" />
|
||||
<General_section filter="status" />
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div v-if="currentPage === 'istochnik'">
|
||||
<div class="sm:flex">
|
||||
<All_new_section :currentPage="currentPage" @update="handleUpdate" />
|
||||
<Istochnik />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 w-full sm:w-1/5 sm:h-screen bg-white z-10 pt-5 md:p-5 dark:bg-gray-800 flex sm:justify-between flex-col"
|
||||
>
|
||||
<div class="">
|
||||
<div class="flex sm:flex-col">
|
||||
<button
|
||||
@click="ValueRezylt"
|
||||
:class="getButtonClass('rezylt')"
|
||||
>
|
||||
Результат
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="ValueSeting"
|
||||
:class="getButtonClass('setings')"
|
||||
class="sm:mt-3"
|
||||
>
|
||||
Настройка
|
||||
</button>
|
||||
<!--
|
||||
<button
|
||||
@click="Valuestatus"
|
||||
:class="getButtonClass('status')"
|
||||
class="sm:mt-3"
|
||||
>
|
||||
Избранное
|
||||
</button> -->
|
||||
|
||||
<button
|
||||
@click="Valueistochnik"
|
||||
:class="getButtonClass('istochnik')"
|
||||
class="sm:mt-3"
|
||||
>
|
||||
Источники
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Переключатель темы -->
|
||||
<div class="hidden sm:block">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ThemeToggle from "./ThemeToggle.vue";
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: String,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update"]);
|
||||
|
||||
function getButtonClass(page) {
|
||||
const baseClass = "w-full min-w-30 px-4 py-2 rounded cursor-pointer";
|
||||
const activeClass = "bg-gray-600 text-white";
|
||||
const inactiveClass = "bg-gray-300 hover:text-white hover:bg-gray-600";
|
||||
|
||||
return `${baseClass} ${props.currentPage === page ? activeClass : inactiveClass}`;
|
||||
}
|
||||
|
||||
function ValueSeting() {
|
||||
emit("update", "setings");
|
||||
}
|
||||
|
||||
function ValueRezylt() {
|
||||
emit("update", "rezylt");
|
||||
}
|
||||
|
||||
// function Valuestatus() {
|
||||
// emit("update", "status");
|
||||
// }
|
||||
|
||||
function Valueistochnik() {
|
||||
emit("update", "istochnik");
|
||||
}
|
||||
</script>
|
||||
@@ -1,137 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
// Получение props
|
||||
const props = defineProps({
|
||||
currentPage: String,
|
||||
});
|
||||
|
||||
// Объявление события для обновления страницы
|
||||
const emit = defineEmits(["update"]);
|
||||
|
||||
const login = ref("");
|
||||
const password = ref("");
|
||||
const authError = ref(false);
|
||||
const isLoggedIn = ref(false);
|
||||
|
||||
// Показать badge reCAPTCHA
|
||||
const showRecaptchaBadge = () => {
|
||||
setTimeout(() => {
|
||||
const badge = document.querySelector(".grecaptcha-badge");
|
||||
if (badge) {
|
||||
badge.style.display = "block";
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Скрыть badge reCAPTCHA
|
||||
const hideRecaptchaBadge = () => {
|
||||
const badge = document.querySelector(".grecaptcha-badge");
|
||||
if (badge) {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
// Функция авторизации
|
||||
const auth_my = async () => {
|
||||
try {
|
||||
// Получение токена reCAPTCHA
|
||||
const recaptchaToken = await grecaptcha.execute(
|
||||
"6LdfSo8sAAAAAGGhbgIGO51nHgMUALYjcAMOxnOg",
|
||||
{ action: "login" },
|
||||
);
|
||||
|
||||
const response = await axios.post("https://allowlgroup.ru/api/8004/login", {
|
||||
username: login.value,
|
||||
password: password.value,
|
||||
recaptcha_token: recaptchaToken,
|
||||
});
|
||||
if (response.data.message === "Login successful") {
|
||||
authError.value = false;
|
||||
isLoggedIn.value = true;
|
||||
hideRecaptchaBadge();
|
||||
emit("update", "rezylt");
|
||||
} else {
|
||||
authError.value = true;
|
||||
}
|
||||
} catch (err) {
|
||||
authError.value = true;
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработка глобального нажатия Enter
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Enter" && !isLoggedIn.value) {
|
||||
auth_my();
|
||||
}
|
||||
};
|
||||
|
||||
// Добавляем глобальный слушатель при монтировании
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
showRecaptchaBadge();
|
||||
});
|
||||
|
||||
// Удаляем при размонтировании
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
// Переход на стартовую страницу (сброс данных)
|
||||
function showStartPage() {
|
||||
emit("update", "admin-panel");
|
||||
login.value = "";
|
||||
password.value = "";
|
||||
authError.value = false;
|
||||
isLoggedIn.value = false;
|
||||
// Показываем badge reCAPTCHA
|
||||
showRecaptchaBadge();
|
||||
}
|
||||
|
||||
// Отслеживание перехода на страницу admin-panel
|
||||
watch(() => props.currentPage, (newPage) => {
|
||||
if (newPage === 'admin-panel' && !isLoggedIn.value) {
|
||||
showRecaptchaBadge();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="props.currentPage === 'admin-panel'"
|
||||
class="p-4 bg-white rounded shadow max-w-md mx-auto mt-10 dark:bg-gray-800 dark:text-neutral-300"
|
||||
>
|
||||
<h2>Вход для администратора</h2>
|
||||
<div class="mb-2">
|
||||
<label>Логин:</label>
|
||||
<input v-model="login" type="text" class="border p-1 w-full" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label>Пароль:</label>
|
||||
<input v-model="password" type="password" class="border p-1 w-full" />
|
||||
</div>
|
||||
<button
|
||||
@click="auth_my"
|
||||
class="mr-2 bg-teal-600 hover:bg-teal-800 text-white px-4 py-2 rounded cursor-pointer"
|
||||
>
|
||||
Войти
|
||||
</button>
|
||||
<button
|
||||
@click="showStartPage"
|
||||
class="dark:bg-gray-600 dark:hover:bg-gray-700 bg-gray-500 hover:bg-gray-600 px-4 py-2 rounded cursor-pointer"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<div v-if="authError" class="mt-2 text-red-600">
|
||||
Неверный логин или пароль
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grecaptcha-badge {
|
||||
display: block !important;
|
||||
}
|
||||
</style>
|
||||
128
src/components/Autherization/Authe.vue
Normal file
128
src/components/Autherization/Authe.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { login as apiLogin, checkAuth } from '@/services/authService.js'
|
||||
import { isAuthenticated, getAuthToken, logout } from '@/utils/auth.js'
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: String,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const login = ref('')
|
||||
const password = ref('')
|
||||
const authError = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const isCheckingAuth = ref(true)
|
||||
|
||||
// Проверка авторизации при загрузке
|
||||
onMounted(async () => {
|
||||
if (isAuthenticated()) {
|
||||
const result = await checkAuth()
|
||||
if (result.authenticated) {
|
||||
emit('update', 'rezylt')
|
||||
} else {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
isCheckingAuth.value = false
|
||||
})
|
||||
|
||||
async function handleLogin() {
|
||||
isLoading.value = true
|
||||
authError.value = false
|
||||
|
||||
try {
|
||||
const data = await apiLogin(login.value, password.value)
|
||||
|
||||
if (data.message === 'Login successful') {
|
||||
emit('update', 'rezylt')
|
||||
} else {
|
||||
authError.value = true
|
||||
}
|
||||
} catch (err) {
|
||||
authError.value = true
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === 'Enter') {
|
||||
handleLogin()
|
||||
}
|
||||
}
|
||||
|
||||
function showStartPage() {
|
||||
emit('update', 'admin-panel')
|
||||
login.value = ''
|
||||
password.value = ''
|
||||
authError.value = false
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
emit('update', 'admin-panel')
|
||||
login.value = ''
|
||||
password.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Показываем форму входа только если страница admin-panel и проверка авторизации завершена -->
|
||||
<div
|
||||
v-if="currentPage === 'admin-panel' && !isCheckingAuth"
|
||||
class="p-6 bg-white rounded-xl shadow max-w-md mx-auto mt-10 dark:bg-gray-800 dark:text-neutral-300"
|
||||
>
|
||||
<h2 class="text-xl font-semibold mb-4">Вход для администратора</h2>
|
||||
<div class="mb-3">
|
||||
<label for="login-input" class="block mb-1 text-sm">Логин</label>
|
||||
<input
|
||||
id="login-input"
|
||||
name="login"
|
||||
v-model="login"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
class="border rounded-lg p-2 w-full dark:bg-gray-900 dark:border-gray-600"
|
||||
@keydown="handleKeyDown"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="password-input" class="block mb-1 text-sm">Пароль</label>
|
||||
<input
|
||||
id="password-input"
|
||||
name="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="border rounded-lg p-2 w-full dark:bg-gray-900 dark:border-gray-600"
|
||||
@keydown="handleKeyDown"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isLoading"
|
||||
class="mr-2 bg-teal-600 hover:bg-teal-800 text-white px-4 py-2 rounded-lg cursor-pointer disabled:opacity-50"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ isLoading ? 'Вход...' : 'Войти' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dark:bg-gray-600 dark:hover:bg-gray-700 bg-gray-500 hover:bg-gray-600 px-4 py-2 rounded-lg cursor-pointer"
|
||||
@click="showStartPage"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<div v-if="authError" class="mt-3 text-red-600 text-sm">
|
||||
Неверный логин или пароль
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Загрузка при проверке авторизации -->
|
||||
<div v-if="isCheckingAuth" class="flex justify-center items-center min-h-screen">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-600"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full sm:w-4/5 dark:text-neutral-300">
|
||||
<div class="bg-white flex justify-between p-3 lg:p-5 dark:bg-gray-800">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<div class="relative">
|
||||
<img
|
||||
v-if="isDarkMode"
|
||||
src="https://img.icons8.com/?size=100&id=WwWusvLMTFd7&format=png&color=000000"
|
||||
class="absolute top-4 left-3 h-5"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="https://img.icons8.com/?size=100&id=zR5EBMqZTIBz&format=png&color=000000"
|
||||
class="absolute top-4 left-3 h-5"
|
||||
/>
|
||||
<input
|
||||
v-model="poisk"
|
||||
type="text"
|
||||
placeholder="Поиск..."
|
||||
class="dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 pl-11"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
@change="onfilterItems($event.target.value)"
|
||||
class="dark:bg-gray-900 border-slate-100 shadow rounded-xl h-12 p-3 mt-3 md:mt-0 md:ml-4"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
<option value="time">По времени</option>
|
||||
<option value="viewed">Просмотренные</option>
|
||||
<option value="status">Избранные</option>
|
||||
</select>
|
||||
</div>
|
||||
<Time />
|
||||
</div>
|
||||
|
||||
<div ref="scrollContainer" class="p-4">
|
||||
<Stat
|
||||
v-for="item in items"
|
||||
:key="item.url"
|
||||
:url="item.url"
|
||||
:title="item.title"
|
||||
:article_date="item.article_date"
|
||||
:short_text="item.short_text"
|
||||
:category="item.category"
|
||||
:parsed_at="item.parsed_at"
|
||||
:status="item.status"
|
||||
:viewed="item.viewed"
|
||||
:original_text="item.original_text"
|
||||
:translation_text="item.translation_text"
|
||||
:other="item.other"
|
||||
@update:viewed="handleViewedChange"
|
||||
@update:status="handleStatusChange"
|
||||
/>
|
||||
<!-- Sentinel для бесконечного скролла -->
|
||||
<div ref="sentinel" class="h-4"></div>
|
||||
<div v-if="isLoading" class="text-center p-4">Загрузка...</div>
|
||||
<div v-if="!hasMore && items.length > 0" class="text-center p-4">
|
||||
Все загружено
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import Stat from "./One_kard.vue";
|
||||
import Time from "./Time.vue";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
filter: { type: String, default: "all" },
|
||||
});
|
||||
|
||||
// Константы
|
||||
const LIMIT = 50;
|
||||
const POLL_INTERVAL = 15000; // 15 секунд
|
||||
|
||||
// Состояния
|
||||
const isDarkMode = ref(document.documentElement.classList.contains("dark"));
|
||||
const sentinel = ref(null);
|
||||
const scrollContainer = ref(null); // Реф контейнера скролла
|
||||
const isLoading = ref(false);
|
||||
const hasMore = ref(true);
|
||||
const items = ref([]);
|
||||
const poisk = ref("");
|
||||
|
||||
// Пагинация
|
||||
let currentFilter = "default";
|
||||
let currentOffset = 0;
|
||||
let pollTimer = null;
|
||||
let lastScrollTop = 0; // Сохраняем позицию скролла
|
||||
|
||||
|
||||
const fetchData = async (url) => {
|
||||
try {
|
||||
const { data } = await axios.get(url);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`Ошибка при получении данных:`, err);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTotalCount = async (filterValue) => {
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`https://allowlgroup.ru/api/8002/records_all/count?item=${filterValue}`,
|
||||
);
|
||||
return data.count;
|
||||
} catch (err) {
|
||||
console.error("Ошибка при получении количества:", err);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSearchCount = async (query, filterValue) => {
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`https://allowlgroup.ru/api/8002/poisk/count?query=${query}&item=${filterValue}`,
|
||||
);
|
||||
return data.count;
|
||||
} catch (err) {
|
||||
console.error("Ошибка при получении количества:", err);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// === Загрузка данных ===
|
||||
|
||||
const loadItems = async (filterValue, append = false) => {
|
||||
if (isLoading.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
if (!append) {
|
||||
items.value = [];
|
||||
currentOffset = 0;
|
||||
hasMore.value = true;
|
||||
}
|
||||
|
||||
currentFilter = filterValue;
|
||||
|
||||
try {
|
||||
const url = poisk.value.trim()
|
||||
? `https://allowlgroup.ru/api/8002/poisk?query=${poisk.value}&item=${filterValue}&offset=${currentOffset}&limit=${LIMIT}`
|
||||
: `https://allowlgroup.ru/api/8002/records_all?item=${filterValue}&offset=${currentOffset}&limit=${LIMIT}`;
|
||||
|
||||
const totalCount = poisk.value.trim()
|
||||
? await fetchSearchCount(poisk.value, filterValue)
|
||||
: await fetchTotalCount(filterValue);
|
||||
|
||||
const data = await fetchData(url);
|
||||
|
||||
if (append) {
|
||||
items.value = [...items.value, ...data];
|
||||
} else {
|
||||
items.value = data;
|
||||
}
|
||||
|
||||
currentOffset += LIMIT;
|
||||
hasMore.value = currentOffset < totalCount;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// === Polling - проверка новых данных ===
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
// Не проверяем при активном поиске или загрузке
|
||||
if (isLoading.value || (poisk.value && poisk.value.trim())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const totalCount = await fetchTotalCount(currentFilter);
|
||||
const data = await fetchData(
|
||||
`https://allowlgroup.ru/api/8002/records_all?item=${currentFilter}&offset=0&limit=${LIMIT}`,
|
||||
);
|
||||
|
||||
if (!data.length) return;
|
||||
|
||||
// Создаём Map существующих URL для быстрого поиска
|
||||
const existingUrls = new Map(items.value.map((item) => [item.url, item]));
|
||||
|
||||
const newItems = [];
|
||||
let hasNew = false;
|
||||
|
||||
for (const item of data) {
|
||||
const existing = existingUrls.get(item.url);
|
||||
|
||||
if (!existing) {
|
||||
// Новая запись - добавляем в начало
|
||||
newItems.push(item);
|
||||
hasNew = true;
|
||||
} else if (
|
||||
existing.viewed !== item.viewed ||
|
||||
existing.status !== item.status
|
||||
) {
|
||||
// Изменились viewed/status - обновляем
|
||||
const index = items.value.indexOf(existing);
|
||||
items.value[index] = { ...item };
|
||||
hasNew = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем новые записи в начало
|
||||
if (newItems.length > 0) {
|
||||
items.value = [...newItems, ...items.value];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка при проверке обновлений:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
pollTimer = setInterval(checkForUpdates, POLL_INTERVAL);
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// === Обработчики событий от Stat ===
|
||||
|
||||
const handleViewedChange = ({ url, viewed }) => {
|
||||
const item = items.value.find((i) => i.url === url);
|
||||
if (item) item.viewed = viewed;
|
||||
};
|
||||
|
||||
const handleStatusChange = ({ url, status }) => {
|
||||
const item = items.value.find((i) => i.url === url);
|
||||
if (item) item.status = status;
|
||||
};
|
||||
|
||||
// === Watch ===
|
||||
|
||||
let debounceTimer = null;
|
||||
watch(poisk, (newVal) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
currentOffset = 0;
|
||||
loadItems(currentFilter);
|
||||
}, 800);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.filter,
|
||||
(newFilter) => {
|
||||
const filterValue = newFilter === "all" ? "default" : newFilter;
|
||||
currentFilter = filterValue;
|
||||
poisk.value = "";
|
||||
loadItems(filterValue);
|
||||
},
|
||||
);
|
||||
|
||||
const onfilterItems = (filterValue) => {
|
||||
const filter = filterValue === "all" ? "default" : filterValue;
|
||||
loadItems(filter);
|
||||
};
|
||||
|
||||
// === Lifecycle ===
|
||||
|
||||
let observer = null;
|
||||
|
||||
onMounted(() => {
|
||||
// Тема
|
||||
isDarkMode.value = document.documentElement.classList.contains("dark");
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
isDarkMode.value = document.documentElement.classList.contains("dark");
|
||||
});
|
||||
mutationObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
// Загрузка данных
|
||||
const initialFilter = props.filter === "all" ? "default" : props.filter;
|
||||
currentFilter = initialFilter;
|
||||
loadItems(initialFilter);
|
||||
|
||||
// Запускаем polling
|
||||
startPolling();
|
||||
|
||||
// Observer для бесконечного скролла
|
||||
nextTick(() => {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore.value) {
|
||||
loadItems(currentFilter, true); // append = true
|
||||
}
|
||||
},
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
|
||||
if (sentinel.value) {
|
||||
observer.observe(sentinel.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
if (observer) observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
@@ -1,86 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-row justify-between w-full lg:w-auto gap-2 p-2 bg-gray-100 shadow dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<dev class="">
|
||||
<input
|
||||
v-model="displayUrl"
|
||||
type="text"
|
||||
placeholder="URL источника"
|
||||
readonly
|
||||
class="flex-1 m-1 h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 min-w-0"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-model="displayPromt"
|
||||
type="text"
|
||||
placeholder="Промт"
|
||||
readonly
|
||||
class="flex-1 m-1 h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 max-w-20"
|
||||
/>
|
||||
</dev>
|
||||
<button
|
||||
@click="startParsing"
|
||||
:disabled="isLoading"
|
||||
class="w-25 h-11 m-1 dark:bg-orange-500 hover:dark:bg-orange-600 shadow text-white bg-sky-700 hover:bg-sky-900 rounded-xl px-2 cursor-pointer whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isLoading ? "..." : "Start" }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({ url: "", promt: "" }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["sourceStarted"]);
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
||||
const displayUrl = computed({
|
||||
get: () => props.source.url || "",
|
||||
set: (val) => {
|
||||
// Только для чтения
|
||||
},
|
||||
});
|
||||
|
||||
const displayPromt = computed({
|
||||
get: () => props.source.promt || "",
|
||||
set: (val) => {
|
||||
// Только для чтения
|
||||
},
|
||||
});
|
||||
|
||||
const startParsing = async () => {
|
||||
if (!props.source.url) {
|
||||
alert("URL источника не указан");
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
await axios.post("https://allowlgroup.ru/api/8001/parser_all", {
|
||||
// await axios.post("http://127.0.0.1:8001/parser_all", {
|
||||
url: props.source.url,
|
||||
promt: props.source.promt,
|
||||
});
|
||||
|
||||
emit("sourceStarted", props.source.url);
|
||||
alert(`Парсинг для ${props.source.url} запущен`);
|
||||
} catch (err) {
|
||||
console.error("Ошибка запуска парсинга:", err);
|
||||
alert("Ошибка при запуске парсинга");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -3,28 +3,34 @@
|
||||
<div
|
||||
class="bg-white flex flex-col lg:flex-row justify-between items-center p-3 lg:p-5 dark:bg-gray-800 gap-3 lg:gap-4"
|
||||
>
|
||||
<!-- Блок 1: Поиск + Фильтр (всегда в ряд) -->
|
||||
<div class="flex flex-row w-full lg:w-auto">
|
||||
<div class="relative w-2/3">
|
||||
<img
|
||||
v-if="isDarkMode"
|
||||
v-if="isDark"
|
||||
src="https://img.icons8.com/?size=100&id=WwWusvLMTFd7&format=png&color=000000"
|
||||
class="absolute top-1/2 -translate-y-1/2 left-3 h-5"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="https://img.icons8.com/?size=100&id=zR5EBMqZTIBz&format=png&color=000000"
|
||||
class="absolute top-1/2 -translate-y-1/2 left-3 h-5"
|
||||
alt=""
|
||||
/>
|
||||
<input
|
||||
v-model="poisk"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Поиск..."
|
||||
id="source-search"
|
||||
name="source_search"
|
||||
class="w-full h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 pl-11"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
@change="onfilterItems($event.target.value)"
|
||||
v-model="activeCategory"
|
||||
@change="onFilterChange"
|
||||
id="source-category"
|
||||
name="source_category"
|
||||
class="w-1/3 h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 ml-2"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
@@ -38,7 +44,6 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Блок 2: URL + Категория + Кнопка (всегда в ряд) -->
|
||||
<div
|
||||
class="flex flex-row w-full lg:w-auto gap-2 p-2 bg-gray-50 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
@@ -46,11 +51,15 @@
|
||||
v-model="newSourceUrl"
|
||||
type="text"
|
||||
placeholder="https://example.com"
|
||||
id="new-source-url"
|
||||
name="new_source_url"
|
||||
class="flex-1 h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 min-w-0"
|
||||
/>
|
||||
|
||||
<select
|
||||
v-model="newSourceCategory"
|
||||
id="new-source-category"
|
||||
name="new_source_category"
|
||||
class="w-28 h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 min-w-30"
|
||||
>
|
||||
<option value="" disabled>Категория</option>
|
||||
@@ -64,6 +73,7 @@
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addSource"
|
||||
class="w-20 h-12 bg-green-600 text-white px-2 rounded-xl shadow hover:bg-green-700 cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
@@ -72,96 +82,94 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список источников -->
|
||||
<div class="p-4 xl:flex gap-2 justify-between">
|
||||
<div class="p-4 grid gap-2 grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
<div
|
||||
v-for="source in filteredSources"
|
||||
:key="source.url"
|
||||
class="mb-4"
|
||||
class="mb-4 hover:-translate-y-2 hover:shadow-2xl transition"
|
||||
>
|
||||
<Istochnik_one_kard :source="source" />
|
||||
<SourceCard
|
||||
:source="source"
|
||||
@sourceStarted="handleSourceStarted"
|
||||
@sourceDeleted="handleSourceDeleted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import axios from "axios";
|
||||
import Istochnik_one_kard from "./Istochnik_one_kard.vue";
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import SourceCard from './Istochnik_one_kard.vue'
|
||||
import { useDarkMode } from '@/composables/useDarkMode.js'
|
||||
import { fetchCategories, fetchSources, addSource as apiAddSource } from '@/services/sourceService.js'
|
||||
|
||||
const isDarkMode = ref(false);
|
||||
const newSourceUrl = ref("");
|
||||
const newSourceCategory = ref("");
|
||||
const categories = ref([]);
|
||||
const poisk = ref("");
|
||||
const sources = ref([]);
|
||||
const { isDark } = useDarkMode()
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const data = await axios.get(
|
||||
"https://allowlgroup.ru/api/8001/categories_promt",
|
||||
);
|
||||
categories.value = data.data;
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки категорий:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSources = async () => {
|
||||
try {
|
||||
const data = await axios.get(
|
||||
"https://allowlgroup.ru/api/8001/all_sources",
|
||||
);
|
||||
sources.value = data.data.sources || [];
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки источников:", err);
|
||||
}
|
||||
};
|
||||
const newSourceUrl = ref('')
|
||||
const newSourceCategory = ref('')
|
||||
const categories = ref([])
|
||||
const searchQuery = ref('')
|
||||
const sources = ref([])
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const filteredSources = computed(() => {
|
||||
if (!poisk.value) return sources.value;
|
||||
return sources.value.filter(source =>
|
||||
source.url.toLowerCase().includes(poisk.value.toLowerCase()) ||
|
||||
source.promt.toLowerCase().includes(poisk.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
if (!searchQuery.value) return sources.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return sources.value.filter(
|
||||
(source) =>
|
||||
source.url.toLowerCase().includes(q) ||
|
||||
source.promt.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
const addSource = async () => {
|
||||
async function loadCategories() {
|
||||
try {
|
||||
categories.value = await fetchCategories()
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки категорий:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSources(category = 'all') {
|
||||
try {
|
||||
sources.value = await fetchSources(category)
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки источников:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function addSource() {
|
||||
if (!newSourceUrl.value.trim() || !newSourceCategory.value) {
|
||||
alert("Заполните все поля");
|
||||
return;
|
||||
alert('Заполните все поля')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post("https://allowlgroup.ru/api/8001/add_sources", {
|
||||
url: newSourceUrl.value,
|
||||
promt: newSourceCategory.value,
|
||||
});
|
||||
newSourceUrl.value = "";
|
||||
newSourceCategory.value = "";
|
||||
fetchSources(); // Обновляем список после добавления
|
||||
await apiAddSource(newSourceUrl.value, newSourceCategory.value)
|
||||
newSourceUrl.value = ''
|
||||
newSourceCategory.value = ''
|
||||
await loadSources(activeCategory.value)
|
||||
} catch (err) {
|
||||
console.error("Ошибка добавления источника:", err);
|
||||
alert("Ошибка при добавлении источника");
|
||||
console.error('Ошибка добавления источника:', err)
|
||||
alert('Ошибка при добавлении источника')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onfilterItems = (filterValue) => {
|
||||
console.log("Фильтр:", filterValue);
|
||||
};
|
||||
function onFilterChange() {
|
||||
loadSources(activeCategory.value)
|
||||
}
|
||||
|
||||
function handleSourceStarted(url) {
|
||||
console.log('Парсинг запущен для:', url)
|
||||
}
|
||||
|
||||
function handleSourceDeleted(deletedUrl) {
|
||||
sources.value = sources.value.filter((source) => source.url !== deletedUrl)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isDarkMode.value = document.documentElement.classList.contains("dark");
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
isDarkMode.value = document.documentElement.classList.contains("dark");
|
||||
});
|
||||
mutationObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
fetchCategories();
|
||||
fetchSources();
|
||||
});
|
||||
loadCategories()
|
||||
loadSources('all')
|
||||
})
|
||||
</script>
|
||||
90
src/components/Istochnik_section/Istochnik_one_kard.vue
Normal file
90
src/components/Istochnik_section/Istochnik_one_kard.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row justify-between w-full lg:w-auto gap-2 p-2 bg-white shadow dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600"
|
||||
:class="{ 'opacity-50': props.source.status }"
|
||||
>
|
||||
<div class="flex-1 flex gap-2 min-w-0">
|
||||
<input
|
||||
:value="props.source.url"
|
||||
type="text"
|
||||
placeholder="URL источника"
|
||||
:id="'source-url-' + props.source.url"
|
||||
:name="'source_url_' + props.source.url"
|
||||
readonly
|
||||
class="flex-1 sm:mr-1 h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 min-w-0"
|
||||
/>
|
||||
<input
|
||||
:value="props.source.promt"
|
||||
type="text"
|
||||
placeholder="Промт"
|
||||
:id="'source-promt-' + props.source.url"
|
||||
:name="'source_promt_' + props.source.url"
|
||||
readonly
|
||||
class="h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 w-1/3 sm:max-w-20"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-40 flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@click="deleteParsing"
|
||||
:disabled="isDeleting"
|
||||
class="w-1/3 sm:w-9 h-11 bg-rose-600 hover:bg-rose-800 shadow text-white rounded-xl cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="startParsing"
|
||||
:disabled="isLoading"
|
||||
class="w-2/3 sm:w-25 h-11 dark:bg-orange-500 hover:dark:bg-orange-600 shadow text-white bg-sky-700 hover:bg-sky-900 rounded-xl px-2 cursor-pointer whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isLoading ? '...' : 'Start' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { startSourceParsing, deleteSource } from '@/services/sourceService.js'
|
||||
|
||||
const props = defineProps({
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({ url: '', promt: '', status: false }),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sourceStarted', 'sourceDeleted'])
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
async function startParsing() {
|
||||
if (!props.source.url) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
await startSourceParsing(props.source.url, props.source.promt)
|
||||
emit('sourceStarted', props.source.url)
|
||||
} catch (err) {
|
||||
console.error('Ошибка запуска парсинга:', err)
|
||||
alert('Ошибка при запуске парсинга')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteParsing() {
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await deleteSource(props.source.url)
|
||||
emit('sourceDeleted', props.source.url)
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении источника:', error)
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
50
src/components/Naw_bar/All_new_section.vue
Normal file
50
src/components/Naw_bar/All_new_section.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<nav
|
||||
class="sticky top-0 w-full sm:w-1/5 sm:h-screen bg-white z-10 pt-5 md:p-5 dark:bg-gray-800 flex sm:justify-between flex-col"
|
||||
>
|
||||
<div class="flex sm:flex-col gap-2 sm:gap-0">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.page"
|
||||
type="button"
|
||||
:class="getButtonClass(item.page)"
|
||||
class="sm:mt-3 first:mt-0"
|
||||
@click="navigate(item.page)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block mt-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: String,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const navItems = [
|
||||
{ page: 'rezylt', label: 'Результат' },
|
||||
{ page: 'setings', label: 'Настройка' },
|
||||
{ page: 'istochnik', label: 'Источники' },
|
||||
]
|
||||
|
||||
function getButtonClass(page) {
|
||||
const baseClass = 'w-full min-w-30 px-4 py-2 rounded-lg cursor-pointer transition-colors text-white'
|
||||
const activeClass = 'bg-gray-600 text-white'
|
||||
const inactiveClass = 'bg-gray-300 hover:text-white hover:bg-gray-600 dark:bg-gray-700 dark:hover:bg-gray-600'
|
||||
|
||||
return `${baseClass} ${props.currentPage === page ? activeClass : inactiveClass}`
|
||||
}
|
||||
|
||||
function navigate(page) {
|
||||
emit('update', page)
|
||||
}
|
||||
</script>
|
||||
@@ -1,40 +1,23 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ref, watch } from 'vue'
|
||||
import { useDarkMode } from '@/composables/useDarkMode.js'
|
||||
|
||||
const isDark = ref(false);
|
||||
const { isDark } = useDarkMode()
|
||||
const localDark = ref(isDark.value)
|
||||
|
||||
// Инициализация темы при загрузке
|
||||
onMounted(() => {
|
||||
// Проверяем localStorage
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
if (savedTheme) {
|
||||
// Если есть сохранённая тема - используем её
|
||||
isDark.value = savedTheme === "dark";
|
||||
watch(isDark, (val) => {
|
||||
localDark.value = val
|
||||
})
|
||||
|
||||
function toggleTheme() {
|
||||
localDark.value = !localDark.value
|
||||
if (localDark.value) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
// Нет сохранённой темы - берём из системных настроек браузера
|
||||
isDark.value = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
applyTheme();
|
||||
});
|
||||
|
||||
// Применение темы
|
||||
const applyTheme = () => {
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
localStorage.setItem("theme", isDark.value ? "dark" : "light");
|
||||
};
|
||||
|
||||
// Переключение темы
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value;
|
||||
applyTheme();
|
||||
};
|
||||
|
||||
// Следим за изменениями (для реактивности)
|
||||
watch(isDark, applyTheme);
|
||||
localStorage.setItem('theme', localDark.value ? 'dark' : 'light')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
241
src/components/News_section/General_section.vue
Normal file
241
src/components/News_section/General_section.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="w-full sm:w-4/5 dark:text-neutral-300">
|
||||
<div class="bg-white p-3 lg:p-5 dark:bg-gray-800">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex flex-col md:flex-row gap-3 md:gap-4">
|
||||
<div class="relative">
|
||||
<img
|
||||
v-if="isDark"
|
||||
src="https://img.icons8.com/?size=100&id=WwWusvLMTFd7&format=png&color=000000"
|
||||
class="absolute top-1/2 -translate-y-1/2 left-3 h-5"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="https://img.icons8.com/?size=100&id=zR5EBMqZTIBz&format=png&color=000000"
|
||||
class="absolute top-1/2 -translate-y-1/2 left-3 h-5"
|
||||
alt=""
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Поиск..."
|
||||
id="news-search"
|
||||
name="news_search"
|
||||
class="dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 pl-11"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
v-model="activeFilter"
|
||||
@change="onFilterChange"
|
||||
id="news-filter"
|
||||
name="news_filter"
|
||||
class="dark:bg-gray-900 border-slate-100 shadow rounded-xl h-12 p-3"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
<option value="time">По времени</option>
|
||||
<option value="Китай">Китай</option>
|
||||
<option value="Корея">Корея</option>
|
||||
<option value="Япония">Япония</option>
|
||||
<option value="viewed">Просмотренные</option>
|
||||
<option value="status">Избранные</option>
|
||||
<option value="tematik">Тематическая</option>
|
||||
<option value="svodka">Сводка</option>
|
||||
<option value="donesenie">Донесение</option>
|
||||
<option value="bilutene">Билутень</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Time />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dark:bg-gray-800 bg-white p-3 hidden lg:block">
|
||||
<div class="mx-2 p-3 bg-gray-100 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600">
|
||||
<SettingsDownloads />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="scrollContainer" class="p-4">
|
||||
<NewsCard
|
||||
v-for="item in items"
|
||||
:key="item.url"
|
||||
v-bind="item"
|
||||
@update:viewed="handleFieldChange('viewed', $event)"
|
||||
@update:status="handleFieldChange('status', $event)"
|
||||
@update:tematik="handleFieldChange('tematik', $event)"
|
||||
@update:svodka="handleFieldChange('svodka', $event)"
|
||||
@update:donesenie="handleFieldChange('donesenie', $event)"
|
||||
@update:bilutene="handleFieldChange('bilutene', $event)"
|
||||
/>
|
||||
<div ref="sentinel" class="h-4"></div>
|
||||
<div v-if="isLoading" class="text-center p-4">Загрузка...</div>
|
||||
<div v-if="!hasMore && items.length > 0" class="text-center p-4">
|
||||
Все загружено
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import NewsCard from './NewsCard.vue'
|
||||
import Time from './Time.vue'
|
||||
import SettingsDownloads from '../Settings_section/SettingsDownloads.vue'
|
||||
import { useDarkMode } from '@/composables/useDarkMode.js'
|
||||
import {
|
||||
fetchRecords,
|
||||
fetchRecordsCount,
|
||||
searchRecords,
|
||||
fetchSearchCount,
|
||||
} from '@/services/newsService.js'
|
||||
|
||||
const props = defineProps({
|
||||
filter: { type: String, default: 'all' },
|
||||
})
|
||||
|
||||
const LIMIT = 30
|
||||
const POLL_INTERVAL = 60 * 60000 // 60 минут
|
||||
|
||||
const { isDark } = useDarkMode()
|
||||
|
||||
const sentinel = ref(null)
|
||||
const scrollContainer = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const items = ref([])
|
||||
const searchQuery = ref('')
|
||||
const activeFilter = ref('all')
|
||||
|
||||
let currentFilter = 'default'
|
||||
let currentOffset = 0
|
||||
let pollTimer = null
|
||||
let observer = null
|
||||
let debounceTimer = null
|
||||
|
||||
async function loadItems(filterValue, append = false) {
|
||||
if (isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
currentFilter = filterValue
|
||||
|
||||
if (!append) {
|
||||
items.value = []
|
||||
currentOffset = 0
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const isSearch = searchQuery.value.trim().length > 0
|
||||
const totalCount = isSearch
|
||||
? await fetchSearchCount(searchQuery.value, filterValue)
|
||||
: await fetchRecordsCount(filterValue)
|
||||
|
||||
const data = isSearch
|
||||
? await searchRecords(searchQuery.value, filterValue, currentOffset, LIMIT)
|
||||
: await fetchRecords(filterValue, currentOffset, LIMIT)
|
||||
|
||||
items.value = append ? [...items.value, ...data] : data
|
||||
currentOffset += LIMIT
|
||||
hasMore.value = currentOffset < totalCount
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки данных:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
if (isLoading.value || searchQuery.value.trim()) return
|
||||
|
||||
try {
|
||||
const data = await fetchRecords(currentFilter, 0, LIMIT)
|
||||
if (!data.length) return
|
||||
|
||||
const existingMap = new Map(items.value.map((item) => [item.url, item]))
|
||||
const newItems = []
|
||||
const changedFields = ['viewed', 'status', 'tematik', 'svodka', 'donesenie', 'bilutene']
|
||||
|
||||
for (const item of data) {
|
||||
const existing = existingMap.get(item.url)
|
||||
if (!existing) {
|
||||
newItems.push(item)
|
||||
} else if (changedFields.some((f) => existing[f] !== item[f])) {
|
||||
Object.assign(existing, item)
|
||||
}
|
||||
}
|
||||
|
||||
if (newItems.length) {
|
||||
items.value = [...newItems, ...items.value]
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при проверке обновлений:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(checkForUpdates, POLL_INTERVAL)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleFieldChange(field, { url, [field]: value }) {
|
||||
const item = items.value.find((i) => i.url === url)
|
||||
if (item) item[field] = value
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
const filter = activeFilter.value === 'all' ? 'default' : activeFilter.value
|
||||
loadItems(filter)
|
||||
}
|
||||
|
||||
watch(searchQuery, () => {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
currentOffset = 0
|
||||
loadItems(currentFilter)
|
||||
}, 800)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.filter,
|
||||
(newFilter) => {
|
||||
const filterValue = newFilter === 'all' ? 'default' : newFilter
|
||||
activeFilter.value = newFilter === 'all' ? 'all' : newFilter
|
||||
currentFilter = filterValue
|
||||
searchQuery.value = ''
|
||||
loadItems(filterValue)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
startPolling()
|
||||
|
||||
nextTick(() => {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore.value) {
|
||||
loadItems(currentFilter, true)
|
||||
}
|
||||
},
|
||||
{ rootMargin: '100px' },
|
||||
)
|
||||
|
||||
if (sentinel.value) {
|
||||
observer.observe(sentinel.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
if (observer) observer.disconnect()
|
||||
})
|
||||
</script>
|
||||
270
src/components/News_section/NewsCard.vue
Normal file
270
src/components/News_section/NewsCard.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
|
||||
import { useDarkMode } from "@/composables/useDarkMode.js";
|
||||
import { updateNewsStatus } from "@/services/newsService.js";
|
||||
import { api8001 } from "@/services/api.js";
|
||||
import { downloadBlob, extractFilenameFromHeaders } from "@/utils/download.js";
|
||||
|
||||
const emit = defineEmits([
|
||||
"update:viewed",
|
||||
"update:status",
|
||||
"update:tematik",
|
||||
"update:svodka",
|
||||
"update:donesenie",
|
||||
"update:bilutene",
|
||||
]);
|
||||
|
||||
const props = defineProps({
|
||||
url: { type: String, required: true },
|
||||
parsed_at: String,
|
||||
title: { type: String, required: true },
|
||||
category: String,
|
||||
short_text: String,
|
||||
article_date: String,
|
||||
original_text: String,
|
||||
translation_text: String,
|
||||
other: String,
|
||||
viewed: Boolean,
|
||||
status: Boolean,
|
||||
tematik: Boolean,
|
||||
svodka: Boolean,
|
||||
donesenie: Boolean,
|
||||
bilutene: Boolean,
|
||||
download: Boolean,
|
||||
});
|
||||
|
||||
const { isDark } = useDarkMode();
|
||||
|
||||
const flags = ref({
|
||||
viewed: props.viewed,
|
||||
status: props.status,
|
||||
tematik: props.tematik,
|
||||
svodka: props.svodka,
|
||||
donesenie: props.donesenie,
|
||||
bilutene: props.bilutene,
|
||||
});
|
||||
|
||||
const flagFields = ['viewed', 'status', 'tematik', 'svodka', 'donesenie', 'bilutene'];
|
||||
flagFields.forEach((field) => {
|
||||
watch(() => props[field], (val) => {
|
||||
flags.value[field] = val;
|
||||
});
|
||||
});
|
||||
|
||||
const isOpen = ref(false);
|
||||
const cardRef = ref(null);
|
||||
|
||||
function toggle() {
|
||||
isOpen.value = true;
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
if (cardRef.value && !cardRef.value.contains(event.target)) {
|
||||
isOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFlag(field) {
|
||||
const newValue = !flags.value[field];
|
||||
flags.value[field] = newValue;
|
||||
emit(`update:${field}`, { url: props.url, [field]: newValue });
|
||||
|
||||
try {
|
||||
await updateNewsStatus(props.url, field, newValue);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
flags.value[field] = !newValue;
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
try {
|
||||
const { data, headers } = await api8001.get("/file_download", {
|
||||
params: {
|
||||
path: props.article_date?.split(" ")[0],
|
||||
title: props.title,
|
||||
},
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
const fallback = `${props.title}.docx`;
|
||||
const filename = extractFilenameFromHeaders(headers, fallback);
|
||||
downloadBlob(data, filename);
|
||||
} catch (err) {
|
||||
console.error("Ошибка скачивания:", err);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 hover:-translate-y-2 hover:shadow-2xl rounded-xl transition pb-1 mb-3"
|
||||
:class="{ 'opacity-50': flags.viewed }"
|
||||
>
|
||||
<div ref="cardRef" class="cursor-pointer" @click="toggle">
|
||||
<div class="flex justify-between p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<p>{{ article_date }}</p>
|
||||
<span class="text-sm">
|
||||
<img
|
||||
v-if="other === 'source1' || other === 'source2'"
|
||||
src="https://img.icons8.com/?size=100&id=OafC2pWK4RV4&format=png&color=000000"
|
||||
class="h-6 w-auto"
|
||||
alt="Китай"
|
||||
/>
|
||||
<img
|
||||
v-else-if="other === 'Япония'"
|
||||
src="https://img.icons8.com/?size=100&id=KvglG3FkCenH&format=png&color=000000"
|
||||
class="h-6 w-auto"
|
||||
alt="Япония"
|
||||
/>
|
||||
<img
|
||||
v-else-if="other === 'Корея'"
|
||||
src="https://img.icons8.com/?size=100&id=uCynf758t5TG&format=png&color=000000"
|
||||
class="h-6 w-auto"
|
||||
alt="Корея"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="max-w-50 xl:max-w-100 text-blue-600 dark:text-gray-400 hover:text-blue-800 active:text-blue-800 underline flex truncate"
|
||||
@click.stop
|
||||
>
|
||||
<img src="/src/assets/href.webp" class="w-5 h-6 mr-2 shrink-0" alt="" />
|
||||
<span class="truncate">{{ url }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="m-3 dark:bg-gray-900 shadow rounded-xl p-3">
|
||||
<p class="font-medium">{{ title }}</p>
|
||||
</div>
|
||||
<div class="m-3 bg-gray-300 dark:bg-gray-950 shadow rounded-xl p-3 max-w-170">
|
||||
<p>Категория: {{ category }}</p>
|
||||
</div>
|
||||
|
||||
<div v-show="isOpen" class="transition-all">
|
||||
<div class="m-3 dark:bg-gray-900 shadow rounded-xl p-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Краткое содержание</p>
|
||||
<p>{{ short_text }}</p>
|
||||
</div>
|
||||
<div class="m-3 dark:bg-gray-900 shadow rounded-xl p-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Перевод</p>
|
||||
<p>{{ translation_text }}</p>
|
||||
</div>
|
||||
<div class="m-3 dark:bg-gray-900 shadow rounded-xl p-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Оригинал</p>
|
||||
<p>{{ original_text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="ml-4 mb-4 flex items-end gap-2">
|
||||
<button type="button" class="cursor-pointer" @click.stop="toggleFlag('viewed')">
|
||||
<img
|
||||
v-if="flags.viewed"
|
||||
src="https://img.icons8.com/?size=100&id=IJNt9jGwqy9N&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
alt="Просмотрено"
|
||||
/>
|
||||
<img
|
||||
v-else-if="isDark"
|
||||
src="https://img.icons8.com/?size=100&id=qliQ29ghmkZh&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
alt="Отметить просмотренным"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="https://img.icons8.com/?size=100&id=KDfrR4UXlVPn&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
alt="Отметить просмотренным"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button type="button" class="cursor-pointer" @click.stop="toggleFlag('status')">
|
||||
<img
|
||||
v-if="flags.status"
|
||||
src="https://img.icons8.com/?size=100&id=fiJBCEhvIMyT&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
alt="В избранном"
|
||||
/>
|
||||
<img
|
||||
v-else-if="isDark"
|
||||
src="https://img.icons8.com/?size=100&id=BmD1kkH92ppy&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
alt="Добавить в избранное"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="https://img.icons8.com/?size=100&id=wVQaONwgqX8h&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
alt="Добавить в избранное"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="toggleFlag('tematik')"
|
||||
class="w-10 h-10 rounded-lg font-bold text-sm hover:opacity-75 active:opacity-75 transition-colors cursor-pointer"
|
||||
:class="flags.tematik
|
||||
? 'bg-gradient-to-r from-red-700 to-red-400 text-white'
|
||||
: 'bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-300'"
|
||||
>
|
||||
Т
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="toggleFlag('svodka')"
|
||||
class="w-10 h-10 rounded-lg font-bold text-sm hover:opacity-75 active:opacity-75 transition-colors cursor-pointer"
|
||||
:class="flags.svodka
|
||||
? 'bg-gradient-to-r from-blue-900 to-blue-500 text-white'
|
||||
: 'bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-300'"
|
||||
>
|
||||
С
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="toggleFlag('donesenie')"
|
||||
class="w-10 h-10 rounded-lg font-bold text-sm hover:opacity-75 active:opacity-75 transition-colors cursor-pointer"
|
||||
:class="flags.donesenie
|
||||
? 'bg-gradient-to-r from-green-700 to-green-400 text-white'
|
||||
: 'bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-300'"
|
||||
>
|
||||
Д
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="toggleFlag('bilutene')"
|
||||
class="w-10 h-10 rounded-lg font-bold text-sm hover:opacity-75 active:opacity-75 transition-colors cursor-pointer"
|
||||
:class="flags.bilutene
|
||||
? 'bg-gradient-to-r from-olive-700 to-olive-500 text-white'
|
||||
: 'bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-300'"
|
||||
>
|
||||
Б
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:opacity-75 active:opacity-75 mx-5 rounded-xl cursor-pointer"
|
||||
@click.stop="download"
|
||||
>
|
||||
<img src="/src/assets/word.png" class="h-10 mb-2" alt="Скачать DOCX" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
26
src/components/News_section/Time.vue
Normal file
26
src/components/News_section/Time.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>Дата и время по Гринвичу: <br />{{ currentDateTime }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { formatUtcDateTime } from '@/utils/date.js'
|
||||
|
||||
const currentDateTime = ref('')
|
||||
let timer = null
|
||||
|
||||
function updateTime() {
|
||||
currentDateTime.value = formatUtcDateTime()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
timer = setInterval(updateTime, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
@@ -1,248 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
// Добавляем emit для уведомления родителя об изменениях
|
||||
const emit = defineEmits(["update:viewed", "update:status"]);
|
||||
|
||||
const props = defineProps({
|
||||
url: String,
|
||||
parsed_at: String,
|
||||
title: String,
|
||||
category: String,
|
||||
short_text: String,
|
||||
article_date: String,
|
||||
original_text: String,
|
||||
translation_text: String,
|
||||
other: String,
|
||||
viewed: Boolean,
|
||||
status: Boolean,
|
||||
});
|
||||
|
||||
// Делаем viewed и status реактивными для локального обновления
|
||||
const localViewed = ref(props.viewed);
|
||||
const localStatus = ref(props.status);
|
||||
|
||||
// Следим за изменениями props и обновляем локальные значения
|
||||
import { watch } from "vue";
|
||||
watch(
|
||||
() => props.viewed,
|
||||
(val) => {
|
||||
localViewed.value = val;
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.status,
|
||||
(val) => {
|
||||
localStatus.value = val;
|
||||
},
|
||||
);
|
||||
|
||||
// Тема
|
||||
const isDarkMode = ref(document.documentElement.classList.contains("dark"));
|
||||
|
||||
onMounted(() => {
|
||||
isDarkMode.value = document.documentElement.classList.contains("dark");
|
||||
const observer = new MutationObserver(() => {
|
||||
isDarkMode.value = document.documentElement.classList.contains("dark");
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
});
|
||||
|
||||
// Карточка
|
||||
const isOpen = ref(false);
|
||||
const cardRef = ref(null);
|
||||
|
||||
function toggle() {
|
||||
isOpen.value = true;
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
if (cardRef.value && !cardRef.value.contains(event.target)) {
|
||||
isOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем локально + отправляем emit
|
||||
const viewed_b = async () => {
|
||||
const newValue = !localViewed.value;
|
||||
localViewed.value = newValue; // Локальное обновление сразу
|
||||
emit("update:viewed", { url: props.url, viewed: newValue });
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
"https://allowlgroup.ru/api/8002/update_viewed_status",
|
||||
null,
|
||||
{ params: { url: props.url, viewed: newValue } },
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
// При ошибке откатываем локальное значение
|
||||
localViewed.value = !newValue;
|
||||
}
|
||||
};
|
||||
|
||||
const status_b = async () => {
|
||||
const newValue = !localStatus.value;
|
||||
localStatus.value = newValue; // Локальное обновление сразу
|
||||
emit("update:status", { url: props.url, status: newValue });
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
"https://allowlgroup.ru/api/8002/update_status_status",
|
||||
null,
|
||||
{ params: { url: props.url, status: newValue } },
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
localStatus.value = !newValue;
|
||||
}
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const rez = await axios.get(
|
||||
"https://allowlgroup.ru/api/8001/file_download",
|
||||
// "http://127.0.0.1:8001/file_download",
|
||||
{
|
||||
params: {
|
||||
path: props.article_date.split(" ")[0],
|
||||
title: props.title,
|
||||
},
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
const blobUrl = window.URL.createObjectURL(
|
||||
new Blob([rez.data], {
|
||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
}),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
let filename = props.title + ".docx";
|
||||
try {
|
||||
const disposition = rez.headers?.["content-disposition"];
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename\*?=([^;\n]+)/i);
|
||||
if (match) {
|
||||
let name = match[1];
|
||||
// Убираем префикс utf-8'' (case insensitive)
|
||||
const prefix = "utf-8''";
|
||||
if (name.toLowerCase().startsWith(prefix)) {
|
||||
name = name.substring(prefix.length);
|
||||
}
|
||||
// Убираем кавычки и декодируем
|
||||
name = decodeURIComponent(name.replace(/"/g, ""));
|
||||
if (!name.endsWith(".docx")) name += ".docx";
|
||||
filename = name;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
link.href = blobUrl;
|
||||
link.setAttribute("download", filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 hover:-translate-y-2 hover:shadow-2xl rounded-xl transition pb-1 mb-3"
|
||||
:class="{ 'opacity-50': localViewed }"
|
||||
>
|
||||
<div ref="cardRef" class="cursor-pointer" @click="toggle">
|
||||
<div class="flex justify-between p-3">
|
||||
<p>{{ article_date }}</p>
|
||||
<a
|
||||
:href="url"
|
||||
class="max-w-100 text-blue-600 dark:text-gray-400 hover:text-blue-800 active:text-blue-800 underline flex"
|
||||
style="overflow: hidden; white-space: nowrap; text-overflow: ellipsis"
|
||||
>
|
||||
<img src="/src/assets/href.webp" class="w-5 h-6 mr-2" />
|
||||
{{ url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="m-3 dark:bg-gray-900 shadow rounded-xl p-3">
|
||||
<p>{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<div v-show="isOpen" class="transition-all">
|
||||
<div class="m-3 dark:bg-gray-900 shadow rounded-xl p-3">
|
||||
<p>{{ short_text }}</p>
|
||||
</div>
|
||||
<div class="m-3 dark:bg-gray-900 shadow rounded-xl p-3">
|
||||
<p>{{ translation_text }}</p>
|
||||
</div>
|
||||
<div class="m-3 dark:bg-gray-900 shadow rounded-xl p-3">
|
||||
<p>{{ category }}</p>
|
||||
</div>
|
||||
<div class="m-3 dark:bg-gray-900 shadow rounded-xl p-3">
|
||||
<p>{{ original_text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="ml-4 mb-4">
|
||||
<button class="cursor-pointer" @click="viewed_b">
|
||||
<img
|
||||
v-if="localViewed"
|
||||
src="https://img.icons8.com/?size=100&id=IJNt9jGwqy9N&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
/>
|
||||
<img
|
||||
v-else-if="isDarkMode"
|
||||
src="https://img.icons8.com/?size=100&id=qliQ29ghmkZh&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="https://img.icons8.com/?size=100&id=KDfrR4UXlVPn&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button class="cursor-pointer" @click="status_b">
|
||||
<img
|
||||
v-if="localStatus"
|
||||
src="https://img.icons8.com/?size=100&id=fiJBCEhvIMyT&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
/>
|
||||
<img
|
||||
v-else-if="isDarkMode"
|
||||
src="https://img.icons8.com/?size=100&id=BmD1kkH92ppy&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="https://img.icons8.com/?size=100&id=wVQaONwgqX8h&format=png&color=000000"
|
||||
class="h-12 hover:opacity-75 active:opacity-75"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="hover:opacity-75 active:opacity-75 mr-10 rounded-xl cursor-pointer"
|
||||
@click="download"
|
||||
>
|
||||
<img src="/src/assets/word.png" class="h-10 mb-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,271 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full sm:w-4/5 dark:text-neutral-300">
|
||||
<!-- Ввод времени -->
|
||||
<div class="bg-white p-4 mb-4 flex-colum sm:flex dark:bg-gray-800">
|
||||
<div class="w-full sm:max-w-100 flex">
|
||||
<DatePicker v-model="time" placeholder="01.01.2026" />
|
||||
<button
|
||||
class="dark:bg-orange-500 hover:dark:bg-orange-600 ml-4 shadow text-white bg-sky-700 w-1/2 hover:bg-sky-900 rounded-xl px-2 min-h-11 cursor-pointer"
|
||||
@click="start_parser_1"
|
||||
>
|
||||
Парсить 1 источник
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="dark:bg-orange-500 hover:dark:bg-orange-600 sm:ml-2 shadow mt-1 sm:mt-0 text-white w-full sm:max-w-50 bg-sky-700 hover:bg-sky-900 rounded-xl px-2 min-h-11 cursor-pointer"
|
||||
@click="start_parser_2"
|
||||
>
|
||||
Парсить 2 источник
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Список источников и промт -->
|
||||
<div
|
||||
class="dark:bg-gray-800 sm:m-5 bg-white p-4 hover:-translate-y-2 hover:shadow-2xl border-slate-100 rounded-xl transition"
|
||||
>
|
||||
<div
|
||||
class="flex-colum sm:flex items-center mb-4 border-slate-900 justify-between"
|
||||
>
|
||||
<div class="w-full sm:max-w-60 flex">
|
||||
<button
|
||||
v-for="(source, index) in sources"
|
||||
:key="source.name"
|
||||
@click="selectSource(index)"
|
||||
class="dark:bg-neutral-500 hover:dark:bg-neutral-600 p-2 mr-2 rounded-xl bg-sky-700 hover:bg-sky-900 w-1/2 text-white cursor-pointer"
|
||||
>
|
||||
{{ source.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка сохранения источников -->
|
||||
<button
|
||||
class="shadow mt-1 sm:mt-0 text-white bg-green-500 hover:bg-green-600 rounded-xl w-full sm:max-w-60 px-2 min-h-11 cursor-pointer"
|
||||
@click="saveSources"
|
||||
>
|
||||
Сохранить изменения
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Редактируемый промт -->
|
||||
<div class=" ">
|
||||
<textarea
|
||||
v-model="promt"
|
||||
class="w-full min-h-100 rounded-xl p-2 border-2 border-neutral-300"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список логов -->
|
||||
<div
|
||||
class="dark:bg-gray-800 mt-5 sm:m-5 bg-white p-4 hover:-translate-y-2 hover:shadow-2xl border-slate-100 rounded-xl transition"
|
||||
>
|
||||
<!-- Кнопка для переключения -->
|
||||
<button
|
||||
@click="toggleLogs"
|
||||
class="dark:bg-orange-500 hover:dark:bg-orange-600 shadow text-white bg-sky-700 w-full hover:bg-sky-900 rounded-xl px-2 min-h-11 cursor-pointer"
|
||||
>
|
||||
{{ showLogs ? "Скрыть логи" : "Показать логи" }}
|
||||
</button>
|
||||
|
||||
<!-- Блок логов, показывается или скрывается -->
|
||||
<div
|
||||
class="border-1 border-neutral-300 p-2 mt-2 rounded-xl"
|
||||
v-if="showLogs"
|
||||
>
|
||||
<div v-for="(log, index) in logs" :key="index">
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="dark:bg-gray-800 mt-5 sm:m-5 bg-white p-4 hover:-translate-y-2 hover:shadow-2xl border-slate-100 rounded-xl transition"
|
||||
>
|
||||
<!-- Кнопка для переключения -->
|
||||
<button
|
||||
@click="toggletask"
|
||||
class="dark:bg-orange-500 hover:dark:bg-orange-600 shadow text-white bg-sky-700 w-full hover:bg-sky-900 rounded-xl px-2 min-h-11 cursor-pointer"
|
||||
>
|
||||
{{ showtask ? "Скрыть задачи" : "Показать задачи" }}
|
||||
</button>
|
||||
<div
|
||||
class="border-1 border-neutral-300 p-2 mt-2 rounded-xl overflow-x-auto"
|
||||
v-if="showtask"
|
||||
>
|
||||
<table class="w-full min-w-max">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-2 py-2 text-left">Status</th>
|
||||
<th class="px-2 py-2 text-left">Source URL</th>
|
||||
<th class="px-2 py-2 text-left">Time start</th>
|
||||
<th class="px-2 py-2 text-left">Time finish</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in tasks" :key="task.id">
|
||||
<td
|
||||
class="px-2 py-2 whitespace-nowrap overflow-hidden overflow-ellipsis max-w-full"
|
||||
>
|
||||
{{ task.status }}
|
||||
</td>
|
||||
<td
|
||||
class="px-2 py-2 whitespace-nowrap overflow-hidden overflow-ellipsis max-w-full"
|
||||
>
|
||||
{{ task.source_url }}
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="px-2 py-2 whitespace-nowrap overflow-hidden overflow-ellipsis max-w-full"
|
||||
>
|
||||
{{ task.created_at }}
|
||||
</td>
|
||||
<td
|
||||
class="px-2 py-2 whitespace-nowrap overflow-hidden overflow-ellipsis max-w-full"
|
||||
>
|
||||
{{ task.finished_at }}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="text-red-500 p-1 rounded-full hover:text-red-700 cursor-pointer"
|
||||
@click="delete_row(task.id)"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Setings_downloads />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import axios from "axios";
|
||||
import Setings_downloads from "./Setings_downloads.vue";
|
||||
import DatePicker from "./DatePicker.vue";
|
||||
|
||||
const sources = ref([{ name: "", promt: "" }]);
|
||||
const time = ref(getTodayDate());
|
||||
const currentSource = ref(sources.value[0]);
|
||||
const promt = ref("Начальный текст");
|
||||
const logs = ref([]);
|
||||
const tasks = ref([]);
|
||||
const showLogs = ref(false); // скрыто по умолчанию
|
||||
const showtask = ref(false); // скрыто по умолчанию
|
||||
|
||||
// Функция для получения сегодняшней даты в формате YYYY-MM-DD
|
||||
function getTodayDate() {
|
||||
return new Date().toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// функция для переключения видимости логов
|
||||
function toggleLogs() {
|
||||
showLogs.value = !showLogs.value;
|
||||
if (showLogs.value) {
|
||||
load_log();
|
||||
}
|
||||
}
|
||||
const load_log = async () => {
|
||||
try {
|
||||
const response = await axios.get("https://allowlgroup.ru/api/8001/logs");
|
||||
logs.value = response.data.logs;
|
||||
} catch (error) {
|
||||
console.error("Ошибка при загрузке настроек:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// функция для переключения видимости задач
|
||||
function toggletask() {
|
||||
showtask.value = !showtask.value;
|
||||
if (showtask.value) {
|
||||
loadTasks();
|
||||
}
|
||||
}
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
"https://allowlgroup.ru/api/8001/get_tasks_offset",
|
||||
);
|
||||
tasks.value = response.data;
|
||||
} catch (error) {
|
||||
console.error("Ошибка при загрузке задач:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const delete_row = async (id) => {
|
||||
try {
|
||||
const response = await axios.delete(
|
||||
`https://allowlgroup.ru/api/8001/delete_task/${id}`,
|
||||
);
|
||||
console.log(response.data);
|
||||
loadTasks();
|
||||
} catch (error) {
|
||||
console.error("Ошибка при удалении задачи:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка настроек при монтировании
|
||||
onMounted(() => {
|
||||
loadSettings();
|
||||
});
|
||||
|
||||
// Получение настроек с сервера
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
"https://allowlgroup.ru/api/8001/settings",
|
||||
);
|
||||
sources.value = response.data.sources;
|
||||
currentSource.value = sources.value[0];
|
||||
promt.value = currentSource.value.promt;
|
||||
} catch (error) {
|
||||
console.error("Ошибка при загрузке настроек:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Выбор источника
|
||||
const selectSource = (index) => {
|
||||
currentSource.value = sources.value[index];
|
||||
promt.value = currentSource.value.promt;
|
||||
};
|
||||
|
||||
// Сохранение источников
|
||||
const saveSources = async () => {
|
||||
try {
|
||||
await axios.post("https://allowlgroup.ru/api/8001/settings", {
|
||||
name: currentSource.value.name,
|
||||
promt: promt.value,
|
||||
});
|
||||
// Здесь можно отправить на сервер, если нужно
|
||||
loadSettings();
|
||||
} catch (error) {
|
||||
console.error("Ошибка при сохранении источников:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Запуск парсинга 1
|
||||
const start_parser_1 = async () => {
|
||||
try {
|
||||
// await axios.post("http://127.0.0.1:8001/parser_1", {
|
||||
await axios.post("https://allowlgroup.ru/api/8001/parser_1", {
|
||||
time: time.value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Ошибка запроса parser_1:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Запуск парсинга 2
|
||||
const start_parser_2 = async () => {
|
||||
try {
|
||||
await axios.post("https://allowlgroup.ru/api/8001/parser_2");
|
||||
} catch (error) {
|
||||
console.error("Ошибка запроса parser_2:", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="dark:bg-gray-800 mt-5 sm:m-5 bg-white p-4 hover:-translate-y-2 hover:shadow-2xl border-slate-100 rounded-xl transition"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 w-full justify-between">
|
||||
<!-- Поля ввода дат в одной строке -->
|
||||
<div class="flex items-center gap-2 flex-grow max-w-130">
|
||||
<span class="dark:text-neutral-300 whitespace-nowrap">с</span>
|
||||
|
||||
<DatePicker v-model="data_start" />
|
||||
|
||||
<span class="dark:text-neutral-300 whitespace-nowrap">по</span>
|
||||
|
||||
<DatePicker v-model="data_finish" />
|
||||
</div>
|
||||
|
||||
<!-- Кнопка выгрузки -->
|
||||
<button
|
||||
class="dark:bg-orange-500 hover:dark:bg-orange-600 shadow text-white bg-sky-700 hover:bg-sky-900 rounded-xl px-2 min-h-11 cursor-pointer w-full sm:w-auto sm:min-w-40 flex-shrink-0"
|
||||
@click="downloadAll"
|
||||
>
|
||||
Выгрузить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import axios from "axios";
|
||||
import DatePicker from "./DatePicker.vue";
|
||||
|
||||
// Переменные для выгрузки с датами по умолчанию
|
||||
const data_start = ref(getYesterdayDate());
|
||||
const data_finish = ref(getTodayDate());
|
||||
|
||||
// Функция для получения вчерашней даты в формате YYYY-MM-DD
|
||||
function getYesterdayDate() {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 1);
|
||||
return date.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// Функция для получения сегодняшней даты в формате YYYY-MM-DD
|
||||
function getTodayDate() {
|
||||
return new Date().toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// Выгрузка
|
||||
const downloadAll = async () => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
// "http://127.0.0.1:8001/download_all",
|
||||
"https://allowlgroup.ru/api/8001/download_all",
|
||||
{
|
||||
data_start: data_start.value,
|
||||
data_finish: data_finish.value,
|
||||
},
|
||||
{
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`documents_${data_start.value}_${data_finish.value}.zip`,
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("Ошибка при выгрузке:", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="date-input-container relative flex-grow">
|
||||
<input
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
v-model="model"
|
||||
type="date"
|
||||
:placeholder="placeholder"
|
||||
class="date-input dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 cursor-pointer w-full appearance-none"
|
||||
@@ -27,18 +26,22 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const model = defineModel({ type: String, default: '' })
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(["update:modelValue"]);
|
||||
inputId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
258
src/components/Settings_section/Settings.vue
Normal file
258
src/components/Settings_section/Settings.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="w-full sm:w-4/5 dark:text-neutral-300">
|
||||
<!-- Ввод времени -->
|
||||
<div class="bg-white p-4 mb-4 flex-colum sm:flex dark:bg-gray-800">
|
||||
<div class="w-full sm:max-w-100 flex">
|
||||
<DatePicker v-model="time" placeholder="01.01.2026" inputId="parser-date" name="parser_date" />
|
||||
<button
|
||||
class="dark:bg-orange-500 hover:dark:bg-orange-600 ml-4 shadow text-white bg-sky-700 w-1/2 hover:bg-sky-900 rounded-xl px-2 min-h-11 cursor-pointer"
|
||||
@click="startParser1"
|
||||
>
|
||||
Парсить 1 источник
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="dark:bg-orange-500 hover:dark:bg-orange-600 sm:ml-2 shadow mt-1 sm:mt-0 text-white w-full sm:max-w-50 bg-sky-700 hover:bg-sky-900 rounded-xl px-2 min-h-11 cursor-pointer"
|
||||
@click="startParser2"
|
||||
>
|
||||
Парсить 2 источник
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Список источников и промт -->
|
||||
<div
|
||||
class="dark:bg-gray-800 sm:m-5 bg-white p-4 hover:-translate-y-2 hover:shadow-2xl border-slate-100 rounded-xl transition"
|
||||
>
|
||||
<div
|
||||
class="flex-colum md:flex items-center mb-4 justify-between"
|
||||
>
|
||||
<div class="w-full md:max-w-60 flex">
|
||||
<button
|
||||
v-for="(source, index) in sources"
|
||||
:key="source.name"
|
||||
type="button"
|
||||
@click="selectSource(index)"
|
||||
:class="[
|
||||
'p-2 mr-2 rounded-xl w-1/2 text-white cursor-pointer transition-colors',
|
||||
currentSource?.name === source.name
|
||||
? 'bg-sky-900 dark:bg-neutral-700 ring-2 ring-offset-1 ring-sky-300 dark:ring-neutral-400'
|
||||
: 'bg-sky-700 hover:bg-sky-900 dark:bg-neutral-500 dark:hover:bg-neutral-600',
|
||||
]"
|
||||
>
|
||||
{{ source.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="shadow mt-1 md:mt-0 text-white bg-green-500 hover:bg-green-600 rounded-xl w-full md:max-w-60 px-2 min-h-11 cursor-pointer"
|
||||
@click="saveSources"
|
||||
>
|
||||
Сохранить изменения
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="prompt"
|
||||
id="prompt-textarea"
|
||||
name="prompt"
|
||||
class="w-full min-h-96 rounded-xl p-2 border-2 border-neutral-300 dark:bg-gray-900 dark:border-neutral-600"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Список логов -->
|
||||
<div
|
||||
class="dark:bg-gray-800 mt-5 sm:m-5 bg-white p-4 hover:-translate-y-2 hover:shadow-2xl border-slate-100 rounded-xl transition"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleLogs"
|
||||
class="dark:bg-orange-500 hover:dark:bg-orange-600 shadow text-white bg-sky-700 w-full hover:bg-sky-900 rounded-xl px-2 min-h-11 cursor-pointer"
|
||||
>
|
||||
{{ showLogs ? 'Скрыть логи' : 'Показать логи' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showLogs" class="border border-neutral-300 p-2 mt-2 rounded-xl">
|
||||
<div v-for="(log, index) in logs" :key="index" class="font-mono text-sm">
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список задач -->
|
||||
<div
|
||||
class="dark:bg-gray-800 mt-5 sm:m-5 bg-white p-4 hover:-translate-y-2 hover:shadow-2xl border-slate-100 rounded-xl transition"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleTask"
|
||||
class="dark:bg-orange-500 hover:dark:bg-orange-600 shadow text-white bg-sky-700 w-full hover:bg-sky-900 rounded-xl px-2 min-h-11 cursor-pointer"
|
||||
>
|
||||
{{ showTask ? 'Скрыть задачи' : 'Показать задачи' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showTask" class="border border-neutral-300 p-2 mt-2 rounded-xl overflow-x-auto">
|
||||
<table class="w-full min-w-max">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-200 dark:border-neutral-600">
|
||||
<th class="px-2 py-2 text-left">Status</th>
|
||||
<th class="px-2 py-2 text-left">Source URL</th>
|
||||
<th class="px-2 py-2 text-left">Time start</th>
|
||||
<th class="px-2 py-2 text-left">Time finish</th>
|
||||
<th class="px-2 py-2 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in tasks" :key="task.id" class="border-b border-neutral-100 dark:border-neutral-700 last:border-0">
|
||||
<td class="px-2 py-2 whitespace-nowrap">{{ task.status }}</td>
|
||||
<td class="px-2 py-2 whitespace-nowrap">{{ task.source_url }}</td>
|
||||
<td class="px-2 py-2 whitespace-nowrap">{{ task.created_at }}</td>
|
||||
<td class="px-2 py-2 whitespace-nowrap">{{ task.finished_at }}</td>
|
||||
<td class="px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-500 p-1 rounded-full hover:text-red-700 cursor-pointer"
|
||||
@click="deleteTask(task.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="dark:bg-gray-800 mt-5 sm:m-5 bg-white p-4 hover:-translate-y-2 hover:shadow-2xl border-slate-100 rounded-xl transition"
|
||||
>
|
||||
<SettingsDownloads />
|
||||
</div>
|
||||
|
||||
<!-- Счётчики для выгрузки -->
|
||||
<div
|
||||
class="dark:bg-gray-800 mt-5 sm:m-5 bg-white p-4 hover:-translate-y-2 hover:shadow-2xl border-slate-100 rounded-xl transition"
|
||||
>
|
||||
<SettingsDownloadsCounts />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import {
|
||||
fetchSettings,
|
||||
saveSettings as apiSaveSettings,
|
||||
fetchLogs,
|
||||
fetchTasks,
|
||||
deleteTask as apiDeleteTask,
|
||||
startParser1 as apiStartParser1,
|
||||
startParser2 as apiStartParser2,
|
||||
} from '@/services/settingsService.js'
|
||||
import { getTodayDate } from '@/utils/date.js'
|
||||
import SettingsDownloads from './SettingsDownloads.vue'
|
||||
import SettingsDownloadsCounts from './SettingsDownloadsCounts.vue'
|
||||
import DatePicker from './DatePicker.vue'
|
||||
|
||||
const sources = ref([{ name: '', promt: '' }])
|
||||
const time = ref(getTodayDate())
|
||||
const currentSource = ref(null)
|
||||
const prompt = ref('')
|
||||
const logs = ref([])
|
||||
const tasks = ref([])
|
||||
const showLogs = ref(false)
|
||||
const showTask = ref(false)
|
||||
|
||||
let isSyncing = false
|
||||
|
||||
// Синхронизируем prompt обратно в currentSource
|
||||
watch(prompt, (val) => {
|
||||
if (currentSource.value && !isSyncing) {
|
||||
currentSource.value.promt = val
|
||||
}
|
||||
})
|
||||
|
||||
function toggleLogs() {
|
||||
showLogs.value = !showLogs.value
|
||||
if (showLogs.value) loadLogs()
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
logs.value = await fetchLogs()
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке логов:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTask() {
|
||||
showTask.value = !showTask.value
|
||||
if (showTask.value) loadTasks()
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
tasks.value = await fetchTasks()
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке задач:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
try {
|
||||
await apiDeleteTask(id)
|
||||
await loadTasks()
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении задачи:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const data = await fetchSettings()
|
||||
sources.value = data.sources
|
||||
if (sources.value.length) {
|
||||
selectSource(0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке настроек:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function selectSource(index) {
|
||||
isSyncing = true
|
||||
currentSource.value = sources.value[index]
|
||||
prompt.value = currentSource.value.promt
|
||||
isSyncing = false
|
||||
}
|
||||
|
||||
async function saveSources() {
|
||||
if (!currentSource.value) return
|
||||
try {
|
||||
await apiSaveSettings(currentSource.value.name, prompt.value)
|
||||
await loadSettings()
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении источников:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function startParser1() {
|
||||
try {
|
||||
await apiStartParser1(time.value)
|
||||
} catch (error) {
|
||||
console.error('Ошибка запроса parser_1:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function startParser2() {
|
||||
try {
|
||||
await apiStartParser2()
|
||||
} catch (error) {
|
||||
console.error('Ошибка запроса parser_2:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
58
src/components/Settings_section/SettingsDownloads.vue
Normal file
58
src/components/Settings_section/SettingsDownloads.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2 w-full justify-between">
|
||||
<div class="flex flex-col lg:flex-row items-center gap-2 flex-grow">
|
||||
<div class="flex items-center gap-2 w-full lg:w-auto">
|
||||
<span class="dark:text-neutral-300 whitespace-nowrap">с</span>
|
||||
<DatePicker v-model="dateStart" inputId="date-start" name="date_start" />
|
||||
<span class="dark:text-neutral-300 whitespace-nowrap">по</span>
|
||||
<DatePicker v-model="dateFinish" inputId="date-finish" name="date_finish" />
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedFilter"
|
||||
id="filter-select"
|
||||
name="filter"
|
||||
class="dark:bg-gray-900 border-slate-100 shadow rounded-xl h-12 p-3 mt-3 lg:mt-0 lg:ml-4 w-full lg:w-auto flex-shrink-0 cursor-pointer"
|
||||
>
|
||||
<option value="status">Избранные</option>
|
||||
<option value="tematik">Тематическая</option>
|
||||
<option value="svodka">Сводка</option>
|
||||
<option value="donesenie">Донесение</option>
|
||||
<option value="bilutene">Билутень</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="dark:bg-orange-500 hover:dark:bg-orange-600 shadow text-white bg-sky-700 hover:bg-sky-900 rounded-xl px-4 min-h-11 cursor-pointer w-full lg:w-auto md:min-w-40 flex-shrink-0"
|
||||
@click="handleDownload"
|
||||
>
|
||||
Выгрузить
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { downloadAll } from "@/services/settingsService.js";
|
||||
import { getTodayDate, getYesterdayDate } from "@/utils/date.js";
|
||||
import { downloadBlob } from "@/utils/download.js";
|
||||
import DatePicker from "./DatePicker.vue";
|
||||
|
||||
const dateStart = ref(getYesterdayDate());
|
||||
const dateFinish = ref(getTodayDate());
|
||||
const selectedFilter = ref("status");
|
||||
|
||||
async function handleDownload() {
|
||||
try {
|
||||
const response = await downloadAll(
|
||||
dateStart.value,
|
||||
dateFinish.value,
|
||||
selectedFilter.value,
|
||||
);
|
||||
const filename = `${selectedFilter.value}_${dateStart.value}_${dateFinish.value}.zip`;
|
||||
downloadBlob(response.data, filename);
|
||||
} catch (error) {
|
||||
console.error("Ошибка при выгрузке:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
88
src/components/Settings_section/SettingsDownloadsCounts.vue
Normal file
88
src/components/Settings_section/SettingsDownloadsCounts.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleCounts"
|
||||
class="dark:bg-orange-500 hover:dark:bg-orange-600 shadow text-white bg-sky-700 w-full hover:bg-sky-900 rounded-xl px-2 min-h-11 cursor-pointer"
|
||||
>
|
||||
{{ showCounts ? 'Скрыть счётчики' : 'Показать счётчики' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showCounts && !loading" class="border border-neutral-300 p-4 mt-2 rounded-xl">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div
|
||||
v-for="(count, key) in counts"
|
||||
:key="key"
|
||||
class="flex flex-col items-center p-3 rounded-xl transition-colors"
|
||||
:class="{
|
||||
'bg-green-100 dark:bg-green-900': count > 0,
|
||||
'bg-gray-100 dark:bg-gray-700': count === 0
|
||||
}"
|
||||
>
|
||||
<span class="text-2xl font-bold" :class="count > 0 ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ count }}
|
||||
</span>
|
||||
<span class="text-sm text-neutral-600 dark:text-neutral-300 mt-1">
|
||||
{{ labels[key] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Всего статей к выгрузке: <span class="font-bold">{{ totalCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showCounts && loading" class="flex justify-center items-center p-8 mt-2">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-sky-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { fetchDownloadCounts } from '@/services/settingsService.js'
|
||||
|
||||
const showCounts = ref(false)
|
||||
const loading = ref(false)
|
||||
const counts = ref({
|
||||
tematik: 0,
|
||||
svodka: 0,
|
||||
donesenie: 0,
|
||||
bilutene: 0
|
||||
})
|
||||
|
||||
const labels = {
|
||||
tematik: 'Тематическая',
|
||||
svodka: 'Сводка',
|
||||
donesenie: 'Донесение',
|
||||
bilutene: 'Билутень',
|
||||
}
|
||||
|
||||
const totalCount = computed(() => {
|
||||
return Object.values(counts.value).reduce((sum, count) => sum + count, 0)
|
||||
})
|
||||
|
||||
function toggleCounts() {
|
||||
showCounts.value = !showCounts.value
|
||||
if (showCounts.value ) { //&& !loading.value && totalCount.value === 0
|
||||
loadCounts()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCounts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchDownloadCounts()
|
||||
counts.value = data
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке счётчиков:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCounts()
|
||||
})
|
||||
</script>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>Дата и время по Гринвичу: <br />{{ currentDateTime }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
currentDateTime: "", // строка с текущими датой и временем
|
||||
timer: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
updateTime() {
|
||||
const options = {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
};
|
||||
const formatter = new Intl.DateTimeFormat("en-GB", options);
|
||||
const parts = formatter.formatToParts(new Date());
|
||||
|
||||
const dateParts = {};
|
||||
parts.forEach(({ type, value }) => {
|
||||
dateParts[type] = value;
|
||||
});
|
||||
|
||||
this.currentDateTime = `${dateParts.year}-${dateParts.month}-${dateParts.day} ${dateParts.hour}:${dateParts.minute}:${dateParts.second}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updateTime();
|
||||
this.timer = setInterval(this.updateTime, 1000);
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this.timer);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
25
src/composables/useDarkMode.js
Normal file
25
src/composables/useDarkMode.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useDarkMode() {
|
||||
const isDark = ref(false)
|
||||
let observer = null
|
||||
|
||||
const update = () => {
|
||||
isDark.value = document.documentElement.classList.contains('dark')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
update()
|
||||
observer = new MutationObserver(update)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer) observer.disconnect()
|
||||
})
|
||||
|
||||
return { isDark }
|
||||
}
|
||||
12
src/main.js
12
src/main.js
@@ -3,4 +3,16 @@ import './assets/main.css'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else if (savedTheme === 'light') {
|
||||
document.documentElement.classList.remove('dark')
|
||||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
}
|
||||
|
||||
initTheme()
|
||||
createApp(App).mount('#app')
|
||||
|
||||
50
src/services/api.js
Normal file
50
src/services/api.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import axios from 'axios'
|
||||
import { getAuthToken, logout } from '@/utils/auth.js'
|
||||
|
||||
const API_BASE_8001 = 'https://allowlgroup.ru/api/8001'
|
||||
const API_BASE_8002 = 'https://allowlgroup.ru/api/8002'
|
||||
const API_BASE_8004 = 'https://allowlgroup.ru/api/8004'
|
||||
|
||||
// Настройка с withCredentials для работы с cookie
|
||||
// Настройка без withCredentials для теста
|
||||
const commonConfig = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true, // Важно для отправки cookie
|
||||
// withCredentials: true, # Временно отключено для теста
|
||||
}
|
||||
|
||||
export const api8001 = axios.create({
|
||||
baseURL: API_BASE_8001,
|
||||
...commonConfig,
|
||||
})
|
||||
|
||||
export const api8002 = axios.create({
|
||||
baseURL: API_BASE_8002,
|
||||
...commonConfig,
|
||||
})
|
||||
|
||||
export const api8004 = axios.create({
|
||||
baseURL: API_BASE_8004,
|
||||
...commonConfig,
|
||||
})
|
||||
|
||||
// Добавляем токен из cookie в заголовки для защищённых запросов
|
||||
api8004.interceptors.request.use((config) => {
|
||||
const token = getAuthToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Обработка ошибок 401 - автоматический выход
|
||||
api8004.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
logout()
|
||||
window.location.reload()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
42
src/services/authService.js
Normal file
42
src/services/authService.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { api8004 } from './api.js'
|
||||
import { setAuthToken, setUsername, getAuthToken, getUsername } from '@/utils/auth.js'
|
||||
|
||||
export async function login(username, password) {
|
||||
const { data } = await api8004.post('/login', { username, password })
|
||||
|
||||
// Бэкенд сам устанавливает cookie, нам нужно только убедиться что токен сохранён
|
||||
if (data.message === 'Login successful' && data.token) {
|
||||
setAuthToken(data.token)
|
||||
setUsername(username)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Проверяем авторизацию через запрос к бэкенду (куки HttpOnly не читаются JS)
|
||||
export async function checkAuth() {
|
||||
try {
|
||||
// Проверяем токен в памяти
|
||||
const token = getAuthToken()
|
||||
if (!token) {
|
||||
return { authenticated: false }
|
||||
}
|
||||
|
||||
// Делаем запрос к /verify для валидации токена/куки
|
||||
const response = await api8004.get('/verify', {
|
||||
validateStatus: () => true
|
||||
})
|
||||
|
||||
if (response.status === 200 && response.data?.user?.username) {
|
||||
// Сохраняем username из ответа
|
||||
setUsername(response.data.user.username)
|
||||
return { authenticated: true, user: { username: response.data.user.username } }
|
||||
}
|
||||
|
||||
return { authenticated: false }
|
||||
} catch (error) {
|
||||
return { authenticated: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
src/services/newsService.js
Normal file
37
src/services/newsService.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { api8002 } from './api.js'
|
||||
|
||||
const DEFAULT_LIMIT = 30
|
||||
|
||||
export async function fetchRecords(filter, offset, limit = DEFAULT_LIMIT) {
|
||||
const { data } = await api8002.get('/records_all', {
|
||||
params: { item: filter, offset, limit },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchRecordsCount(filter) {
|
||||
const { data } = await api8002.get('/records_all/count', {
|
||||
params: { item: filter },
|
||||
})
|
||||
return data.count ?? 0
|
||||
}
|
||||
|
||||
export async function searchRecords(query, filter, offset, limit = DEFAULT_LIMIT) {
|
||||
const { data } = await api8002.get('/poisk', {
|
||||
params: { query, item: filter, offset, limit },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchSearchCount(query, filter) {
|
||||
const { data } = await api8002.get('/poisk/count', {
|
||||
params: { query, item: filter },
|
||||
})
|
||||
return data.count ?? 0
|
||||
}
|
||||
|
||||
export async function updateNewsStatus(url, field, value) {
|
||||
await api8002.post(`/update_${field}_status`, null, {
|
||||
params: { url, [field]: value },
|
||||
})
|
||||
}
|
||||
45
src/services/settingsService.js
Normal file
45
src/services/settingsService.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { api8001 } from './api.js'
|
||||
|
||||
export async function fetchSettings() {
|
||||
const { data } = await api8001.get('/settings')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveSettings(name, prompt) {
|
||||
await api8001.post('/settings', { name, promt: prompt })
|
||||
}
|
||||
|
||||
export async function fetchLogs() {
|
||||
const { data } = await api8001.get('/logs')
|
||||
return data.logs ?? []
|
||||
}
|
||||
|
||||
export async function fetchTasks() {
|
||||
const { data } = await api8001.get('/get_tasks_offset')
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function deleteTask(id) {
|
||||
await api8001.delete(`/delete_task/${id}`)
|
||||
}
|
||||
|
||||
export async function startParser1(time) {
|
||||
await api8001.post('/parser_1', { time })
|
||||
}
|
||||
|
||||
export async function startParser2() {
|
||||
await api8001.post('/parser_2')
|
||||
}
|
||||
|
||||
export async function downloadAll(dataStart, dataFinish, fieldName) {
|
||||
return api8001.post(
|
||||
'/download_all',
|
||||
{ data_start: dataStart, data_finish: dataFinish, field_name: fieldName },
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchDownloadCounts() {
|
||||
const { data } = await api8001.get('/download_counts')
|
||||
return data
|
||||
}
|
||||
25
src/services/sourceService.js
Normal file
25
src/services/sourceService.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { api8001 } from './api.js'
|
||||
|
||||
export async function fetchCategories() {
|
||||
const { data } = await api8001.get('/categories_promt')
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function fetchSources(category = 'all') {
|
||||
const { data } = await api8001.get('/all_sources', {
|
||||
params: { category },
|
||||
})
|
||||
return data.sources ?? []
|
||||
}
|
||||
|
||||
export async function addSource(url, prompt) {
|
||||
await api8001.post('/add_sources', { url, promt: prompt })
|
||||
}
|
||||
|
||||
export async function deleteSource(url) {
|
||||
await api8001.delete('/delete_sources', { params: { url } })
|
||||
}
|
||||
|
||||
export async function startSourceParsing(url, prompt) {
|
||||
await api8001.post('/parser_all', { url, promt: prompt })
|
||||
}
|
||||
92
src/utils/auth.js
Normal file
92
src/utils/auth.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Утилиты для работы с cookie
|
||||
* Примечание: auth_token и username устанавливаются бэкендом через Set-Cookie
|
||||
* Фронтенд только читает их через API ответы
|
||||
*/
|
||||
|
||||
const TOKEN_COOKIE_NAME = 'auth_token'
|
||||
const USERNAME_COOKIE_NAME = 'username'
|
||||
|
||||
// Временное хранилище для токена (до тех пор пока бэкенд не вернёт куки)
|
||||
let tempToken = null
|
||||
let tempUsername = null
|
||||
|
||||
/**
|
||||
* Получает cookie по имени (только для не-Httponly куки)
|
||||
* @param {string} name - имя cookie
|
||||
* @returns {string|null} значение cookie или null
|
||||
*/
|
||||
function getCookie(name) {
|
||||
const nameEQ = `${name}=`
|
||||
const ca = document.cookie.split(';')
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i]
|
||||
while (c.charAt(0) === ' ') c = c.substring(1)
|
||||
if (c.indexOf(nameEQ) === 0) {
|
||||
return decodeURIComponent(c.substring(nameEQ.length, c.length))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет токен авторизации (для совместимости)
|
||||
* @param {string} token - токен
|
||||
*/
|
||||
export function setAuthToken(token) {
|
||||
tempToken = token
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет имя пользователя
|
||||
* @param {string} username - имя пользователя
|
||||
*/
|
||||
export function setUsername(username) {
|
||||
tempUsername = username
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает токен авторизации
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getAuthToken() {
|
||||
return tempToken || getCookie(TOKEN_COOKIE_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает имя пользователя
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getUsername() {
|
||||
return tempUsername || getCookie(USERNAME_COOKIE_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, авторизован ли пользователь
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAuthenticated() {
|
||||
return getAuthToken() !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет выход из системы
|
||||
*/
|
||||
export function logout() {
|
||||
tempToken = null
|
||||
tempUsername = null
|
||||
// Очистка через API (бэкенд удалит куки)
|
||||
return fetch('/api/8004/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает все данные авторизации
|
||||
*/
|
||||
export function clearAuthData() {
|
||||
tempToken = null
|
||||
tempUsername = null
|
||||
}
|
||||
|
||||
31
src/utils/date.js
Normal file
31
src/utils/date.js
Normal file
@@ -0,0 +1,31 @@
|
||||
export function getTodayDate() {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
export function getYesterdayDate() {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - 1)
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
export function formatUtcDateTime() {
|
||||
const options = {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat('en-GB', options)
|
||||
const parts = formatter.formatToParts(new Date())
|
||||
const dateParts = {}
|
||||
|
||||
parts.forEach(({ type, value }) => {
|
||||
dateParts[type] = value
|
||||
})
|
||||
|
||||
return `${dateParts.year}-${dateParts.month}-${dateParts.day} ${dateParts.hour}:${dateParts.minute}:${dateParts.second}`
|
||||
}
|
||||
31
src/utils/download.js
Normal file
31
src/utils/download.js
Normal file
@@ -0,0 +1,31 @@
|
||||
export function downloadBlob(blob, filename) {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', filename)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function extractFilenameFromHeaders(headers, fallback) {
|
||||
try {
|
||||
const disposition = headers?.['content-disposition']
|
||||
if (!disposition) return fallback
|
||||
|
||||
const match = disposition.match(/filename\*?=([^;\n]+)/i)
|
||||
if (!match) return fallback
|
||||
|
||||
let name = match[1]
|
||||
const prefix = "utf-8''"
|
||||
if (name.toLowerCase().startsWith(prefix)) {
|
||||
name = name.substring(prefix.length)
|
||||
}
|
||||
name = decodeURIComponent(name.replace(/"/g, ''))
|
||||
if (!name.endsWith('.docx')) name += '.docx'
|
||||
return name
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user