полный рефакторинг всей системы
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-05-08 17:01:37 +10:00
parent f7b91ed75e
commit d10a74eea5
24 changed files with 1170 additions and 1490 deletions

View File

@@ -1,66 +1,28 @@
<script setup> <script setup>
import { onMounted, ref, watch } from "vue"; import { ref } from 'vue'
import All_new_section from "./components/Naw_bar/All_new_section.vue"; import NavBar from './components/Naw_bar/All_new_section.vue'
import General_section from "./components/News_section/General_section.vue"; import NewsFeed from './components/News_section/General_section.vue'
import Setings from "./components/Settings_section/Setings.vue"; import Settings from './components/Settings_section/Settings.vue'
import Authe from "./components/Autherization/Authe.vue"; import Auth from './components/Autherization/Authe.vue'
import Istochnik from "./components/Istochnik_section/Istochnik.vue"; import Sources from './components/Istochnik_section/Istochnik.vue'
// Инициализация темы при загрузке приложения const currentPage = ref('admin-panel')
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");
}
}
});
// Состояния страницы и данные для входа function navigateTo(page) {
const currentPage = ref("admin-panel"); currentPage.value = page
function handleUpdate(newValue) {
currentPage.value = newValue; // изменение значения в родителе
} }
</script> </script>
<template> <template>
<Authe :currentPage="currentPage" @update="handleUpdate" /> <Auth :currentPage="currentPage" @update="navigateTo" />
<div v-if="currentPage === 'rezylt'">
<div class="sm:flex">
<All_new_section :currentPage="currentPage" @update="handleUpdate" />
<General_section filter="all" />
</div>
</div>
<div v-if="currentPage === 'setings'"> <template v-if="currentPage !== 'admin-panel'">
<div class="sm:flex"> <div class="sm:flex">
<All_new_section :currentPage="currentPage" @update="handleUpdate" /> <NavBar :currentPage="currentPage" @update="navigateTo" />
<Setings /> <NewsFeed v-if="currentPage === 'rezylt'" filter="all" />
</div> <Settings v-else-if="currentPage === 'setings'" />
</div> <Sources v-else-if="currentPage === 'istochnik'" />
<!-- <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> </div>
</template>
</template> </template>
<style>
</style>

View File

@@ -1,140 +1,93 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch } from "vue"; import { ref } from 'vue'
import axios from "axios"; import { login as apiLogin } from '@/services/authService.js'
// Получение props
const props = defineProps({ const props = defineProps({
currentPage: String, currentPage: String,
}); })
// Объявление события для обновления страницы const emit = defineEmits(['update'])
const emit = defineEmits(["update"]);
const login = ref(""); const login = ref('')
const password = ref(""); const password = ref('')
const authError = ref(false); const authError = ref(false)
const isLoggedIn = ref(false); const isLoading = ref(false)
// // Показать badge reCAPTCHA async function handleLogin() {
// const showRecaptchaBadge = () => { isLoading.value = true
// setTimeout(() => { authError.value = false
// 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 { try {
// Получение токена reCAPTCHA const data = await apiLogin(login.value, password.value)
// const recaptchaToken = await grecaptcha.execute( if (data.message === 'Login successful') {
// "6LdfSo8sAAAAAGGhbgIGO51nHgMUALYjcAMOxnOg", emit('update', 'rezylt')
// { 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 { } else {
authError.value = true; authError.value = true
} }
} catch (err) { } catch (err) {
authError.value = true; authError.value = true
console.log(err); console.error(err)
} finally {
isLoading.value = false
} }
};
// Обработка глобального нажатия 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 function handleKeyDown(event) {
// watch( if (event.key === 'Enter') {
// () => props.currentPage, handleLogin()
// (newPage) => { }
// if (newPage === "admin-panel" && !isLoggedIn.value) { }
// showRecaptchaBadge();
// } function showStartPage() {
// }, emit('update', 'admin-panel')
// ); login.value = ''
password.value = ''
authError.value = false
}
</script> </script>
<template> <template>
<div <div
v-if="props.currentPage === 'admin-panel'" v-if="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" class="p-6 bg-white rounded-xl shadow max-w-md mx-auto mt-10 dark:bg-gray-800 dark:text-neutral-300"
> >
<h2>Вход для администратора</h2> <h2 class="text-xl font-semibold mb-4">Вход для администратора</h2>
<div class="mb-2"> <div class="mb-3">
<label>Логин:</label> <label class="block mb-1 text-sm">Логин</label>
<input v-model="login" type="text" class="border p-1 w-full" /> <input
v-model="login"
type="text"
class="border rounded-lg p-2 w-full dark:bg-gray-900 dark:border-gray-600"
@keydown="handleKeyDown"
/>
</div> </div>
<div class="mb-2"> <div class="mb-4">
<label>Пароль:</label> <label class="block mb-1 text-sm">Пароль</label>
<input v-model="password" type="password" class="border p-1 w-full" /> <input
v-model="password"
type="password"
class="border rounded-lg p-2 w-full dark:bg-gray-900 dark:border-gray-600"
@keydown="handleKeyDown"
/>
</div> </div>
<button <button
@click="auth_my" type="button"
class="mr-2 bg-teal-600 hover:bg-teal-800 text-white px-4 py-2 rounded cursor-pointer" :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>
<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" @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> </button>
<div v-if="authError" class="mt-2 text-red-600"> <div v-if="authError" class="mt-3 text-red-600 text-sm">
Неверный логин или пароль Неверный логин или пароль
</div> </div>
</div> </div>
</template> </template>
<!-- <style scoped>
.grecaptcha-badge {
display: block !important;
}
</style> -->

View File

@@ -3,28 +3,30 @@
<div <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" 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="flex flex-row w-full lg:w-auto">
<div class="relative w-2/3"> <div class="relative w-2/3">
<img <img
v-if="isDarkMode" v-if="isDark"
src="https://img.icons8.com/?size=100&id=WwWusvLMTFd7&format=png&color=000000" 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" class="absolute top-1/2 -translate-y-1/2 left-3 h-5"
alt=""
/> />
<img <img
v-else v-else
src="https://img.icons8.com/?size=100&id=zR5EBMqZTIBz&format=png&color=000000" 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" class="absolute top-1/2 -translate-y-1/2 left-3 h-5"
alt=""
/> />
<input <input
v-model="poisk" v-model="searchQuery"
type="text" type="text"
placeholder="Поиск..." placeholder="Поиск..."
class="w-full h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 pl-11" class="w-full h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 pl-11"
/> />
</div> </div>
<select <select
@change="onfilterItems($event.target.value)" v-model="activeCategory"
@change="onFilterChange"
class="w-1/3 h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 ml-2" class="w-1/3 h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 ml-2"
> >
<option value="all">Все</option> <option value="all">Все</option>
@@ -38,7 +40,6 @@
</select> </select>
</div> </div>
<!-- Блок 2: URL + Категория + Кнопка (всегда в ряд) -->
<div <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" 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"
> >
@@ -64,6 +65,7 @@
</select> </select>
<button <button
type="button"
@click="addSource" @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" class="w-20 h-12 bg-green-600 text-white px-2 rounded-xl shadow hover:bg-green-700 cursor-pointer whitespace-nowrap"
> >
@@ -72,10 +74,13 @@
</div> </div>
</div> </div>
<!-- Список источников -->
<div class="p-4 grid gap-2 grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3"> <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 hover:-translate-y-2 hover:shadow-2xl transition"> <div
<Istochnik_one_kard v-for="source in filteredSources"
:key="source.url"
class="mb-4 hover:-translate-y-2 hover:shadow-2xl transition"
>
<SourceCard
:source="source" :source="source"
@sourceStarted="handleSourceStarted" @sourceStarted="handleSourceStarted"
@sourceDeleted="handleSourceDeleted" @sourceDeleted="handleSourceDeleted"
@@ -86,92 +91,77 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from "vue"; import { ref, onMounted, computed } from 'vue'
import axios from "axios"; import SourceCard from './Istochnik_one_kard.vue'
import Istochnik_one_kard 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 { isDark } = useDarkMode()
const newSourceUrl = ref("");
const newSourceCategory = ref("");
const categories = ref([]);
const poisk = ref("");
const sources = ref([]);
const newSourceUrl = ref('')
const fetchCategories = async () => { const newSourceCategory = ref('')
try { const categories = ref([])
const data = await axios.get( const searchQuery = ref('')
"https://allowlgroup.ru/api/8001/categories_promt", const sources = ref([])
); const activeCategory = ref('all')
categories.value = data.data;
} catch (err) {
console.error("Ошибка загрузки категорий:", err);
}
};
const fetchSources = async (category) => {
try {
const data = await axios.get(`https://allowlgroup.ru/api/8001/all_sources`, {
params: { category }
});
sources.value = data.data.sources || [];
} catch (err) {
console.error("Ошибка загрузки источников:", err);
}
};
const filteredSources = computed(() => { const filteredSources = computed(() => {
if (!poisk.value) return sources.value; if (!searchQuery.value) return sources.value
const q = searchQuery.value.toLowerCase()
return sources.value.filter( return sources.value.filter(
(source) => (source) =>
source.url.toLowerCase().includes(poisk.value.toLowerCase()) || source.url.toLowerCase().includes(q) ||
source.promt.toLowerCase().includes(poisk.value.toLowerCase()), 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) { if (!newSourceUrl.value.trim() || !newSourceCategory.value) {
alert("Заполните все поля"); alert('Заполните все поля')
return; return
} }
try { try {
await axios.post("https://allowlgroup.ru/api/8001/add_sources", { await apiAddSource(newSourceUrl.value, newSourceCategory.value)
url: newSourceUrl.value, newSourceUrl.value = ''
promt: newSourceCategory.value, newSourceCategory.value = ''
}); await loadSources(activeCategory.value)
newSourceUrl.value = "";
newSourceCategory.value = "";
fetchSources(); // Обновляем список после добавления
} catch (err) { } catch (err) {
console.error("Ошибка добавления источника:", err); console.error('Ошибка добавления источника:', err)
alert("Ошибка при добавлении источника"); alert('Ошибка при добавлении источника')
} }
}; }
const onfilterItems = (filterValue) => { function onFilterChange() {
fetchSources(filterValue); loadSources(activeCategory.value)
}; }
const handleSourceStarted = (url) => { function handleSourceStarted(url) {
console.log("Парсинг запущен для:", url); console.log('Парсинг запущен для:', url)
}; }
const handleSourceDeleted = (deletedUrl) => { function handleSourceDeleted(deletedUrl) {
sources.value = sources.value.filter((source) => source.url !== deletedUrl); sources.value = sources.value.filter((source) => source.url !== deletedUrl)
}; }
onMounted(() => { onMounted(() => {
isDarkMode.value = document.documentElement.classList.contains("dark"); loadCategories()
const mutationObserver = new MutationObserver(() => { loadSources('all')
isDarkMode.value = document.documentElement.classList.contains("dark"); })
});
mutationObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
fetchCategories();
fetchSources("all");
});
</script> </script>

View File

@@ -1,124 +1,86 @@
<template> <template>
<div <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="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': isStatus }" :class="{ 'opacity-50': props.source.status }"
> >
<div class=""> <div class="flex-1 flex gap-2 min-w-0">
<input <input
v-model="displayUrl" :value="props.source.url"
type="text" type="text"
placeholder="URL источника" placeholder="URL источника"
readonly readonly
class="flex-1 sm:mr-1 h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 w-2/3 sm:min-w-0" class="flex-1 sm:mr-1 h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 min-w-0"
/> />
<input <input
v-model="displayPromt" :value="props.source.promt"
type="text" type="text"
placeholder="Промт" placeholder="Промт"
readonly readonly
class="flex-1 h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 w-1/3 sm:max-w-20" class="h-12 dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 w-1/3 sm:max-w-20"
/> />
</div> </div>
<div class="min-w-40"> <div class="min-w-40 flex gap-1">
<button <button
type="button"
@click="deleteParsing" @click="deleteParsing"
:disabled="isDeleting" :disabled="isDeleting"
class="w-1/3 sm:w-9 h-11 sm:mr-1 bg-rose-600 hover:bg-rose-800 shadow text-white rounded-xl cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" 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"
> >
X ×
</button> </button>
<button <button
type="button"
@click="startParsing" @click="startParsing"
:disabled="isLoading" :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" 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" }} {{ isLoading ? '...' : 'Start' }}
</button> </button>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed } from "vue"; import { ref } from 'vue'
import axios from "axios"; import { startSourceParsing, deleteSource } from '@/services/sourceService.js'
const props = defineProps({ const props = defineProps({
source: { source: {
type: Object, type: Object,
required: true, required: true,
default: () => ({ url: "", promt: "", status: false}), default: () => ({ url: '', promt: '', status: false }),
}, },
}); })
const emit = defineEmits(["sourceStarted", "sourceDeleted"]); const emit = defineEmits(['sourceStarted', 'sourceDeleted'])
const isLoading = ref(false); const isLoading = ref(false)
const isDeleting = ref(false); const isDeleting = ref(false)
const isStatus = ref(props.source.status);
const displayUrl = computed({ async function startParsing() {
get: () => props.source.url || "", if (!props.source.url) return
set: (val) => {
// Только для чтения
},
});
const displayPromt = computed({
get: () => props.source.promt || "",
set: (val) => {
// Только для чтения
},
});
const startParsing = async () => {
if (!props.source.url) {
// alert("URL источника не указан");
return;
}
isLoading.value = true;
isLoading.value = true
try { try {
await axios.post("https://allowlgroup.ru/api/8001/parser_all", { await startSourceParsing(props.source.url, props.source.promt)
// await axios.post("http://127.0.0.1:8001/parser_all", { emit('sourceStarted', props.source.url)
url: props.source.url,
promt: props.source.promt,
});
emit("sourceStarted", props.source.url);
// alert(`Парсинг для ${props.source.url} запущен`);
} catch (err) { } catch (err) {
console.error("Ошибка запуска парсинга:", err); console.error('Ошибка запуска парсинга:', err)
alert("Ошибка при запуске парсинга"); alert('Ошибка при запуске парсинга')
} finally { } finally {
isLoading.value = false; isLoading.value = false
} }
}; }
const deleteParsing = async () => {
// if (!props.source.url) {
// alert("URL источника не указан");
// return;
// }
// if (!confirm(`Вы уверены, что хотите удалить источник "${props.source.url}"?`)) {
// return;
// }
isDeleting.value = true;
async function deleteParsing() {
isDeleting.value = true
try { try {
await axios.delete( await deleteSource(props.source.url)
"https://allowlgroup.ru/api/8001/delete_sources", emit('sourceDeleted', props.source.url)
// "http://127.0.0.1:8001/delete_sources",
{ params: { url: props.source.url } }
);
emit("sourceDeleted", props.source.url);
} catch (error) { } catch (error) {
console.error("Ошибка при удалении задачи:", error); console.error('Ошибка при удалении источника:', error)
} finally { } finally {
isDeleting.value = false; isDeleting.value = false
} }
}; }
</script> </script>

View File

@@ -1,79 +1,50 @@
<template> <template>
<div <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" 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 gap-2 sm:gap-0">
<div class="flex sm:flex-col">
<button <button
@click="ValueRezylt" v-for="item in navItems"
:class="getButtonClass('rezylt')" :key="item.page"
type="button"
:class="getButtonClass(item.page)"
class="sm:mt-3 first:mt-0"
@click="navigate(item.page)"
> >
Результат {{ item.label }}
</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> </button>
</div> </div>
</div>
<!-- Переключатель темы --> <div class="hidden sm:block mt-auto">
<div class="hidden sm:block">
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </nav>
</template> </template>
<script setup> <script setup>
import ThemeToggle from "./ThemeToggle.vue"; import ThemeToggle from './ThemeToggle.vue'
const props = defineProps({ const props = defineProps({
currentPage: String, currentPage: String,
}); })
const emit = defineEmits(["update"]); const emit = defineEmits(['update'])
const navItems = [
{ page: 'rezylt', label: 'Результат' },
{ page: 'setings', label: 'Настройка' },
{ page: 'istochnik', label: 'Источники' },
]
function getButtonClass(page) { function getButtonClass(page) {
const baseClass = "w-full min-w-30 px-4 py-2 rounded cursor-pointer"; 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 activeClass = 'bg-gray-600 text-white'
const inactiveClass = "bg-gray-300 hover:text-white hover:bg-gray-600"; 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}`; return `${baseClass} ${props.currentPage === page ? activeClass : inactiveClass}`
} }
function ValueSeting() { function navigate(page) {
emit("update", "setings"); emit('update', page)
}
function ValueRezylt() {
emit("update", "rezylt");
}
// function Valuestatus() {
// emit("update", "status");
// }
function Valueistochnik() {
emit("update", "istochnik");
} }
</script> </script>

View File

@@ -1,40 +1,23 @@
<script setup> <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)
// Инициализация темы при загрузке watch(isDark, (val) => {
onMounted(() => { localDark.value = val
// Проверяем localStorage })
const savedTheme = localStorage.getItem("theme");
if (savedTheme) { function toggleTheme() {
// Если есть сохранённая тема - используем её localDark.value = !localDark.value
isDark.value = savedTheme === "dark"; if (localDark.value) {
document.documentElement.classList.add('dark')
} else { } else {
// Нет сохранённой темы - берём из системных настроек браузера document.documentElement.classList.remove('dark')
isDark.value = window.matchMedia("(prefers-color-scheme: dark)").matches;
} }
applyTheme(); localStorage.setItem('theme', localDark.value ? 'dark' : 'light')
}); }
// Применение темы
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);
</script> </script>
<template> <template>

View File

@@ -1,29 +1,32 @@
<template> <template>
<div class="w-full sm:w-4/5 dark:text-neutral-300"> <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="bg-white p-3 lg:p-5 dark:bg-gray-800">
<div class="flex justify-between"> <div class="flex justify-between items-start">
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row gap-3 md:gap-4">
<div class="relative"> <div class="relative">
<img <img
v-if="isDarkMode" v-if="isDark"
src="https://img.icons8.com/?size=100&id=WwWusvLMTFd7&format=png&color=000000" src="https://img.icons8.com/?size=100&id=WwWusvLMTFd7&format=png&color=000000"
class="absolute top-4 left-3 h-5" class="absolute top-1/2 -translate-y-1/2 left-3 h-5"
alt=""
/> />
<img <img
v-else v-else
src="https://img.icons8.com/?size=100&id=zR5EBMqZTIBz&format=png&color=000000" src="https://img.icons8.com/?size=100&id=zR5EBMqZTIBz&format=png&color=000000"
class="absolute top-4 left-3 h-5" class="absolute top-1/2 -translate-y-1/2 left-3 h-5"
alt=""
/> />
<input <input
v-model="poisk" v-model="searchQuery"
type="text" type="text"
placeholder="Поиск..." placeholder="Поиск..."
class="dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 pl-11" class="dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 pl-11"
/> />
</div> </div>
<select <select
@change="onfilterItems($event.target.value)" v-model="activeFilter"
class="dark:bg-gray-900 border-slate-100 shadow rounded-xl h-12 p-3 mt-3 md:mt-0 md:ml-4" @change="onFilterChange"
class="dark:bg-gray-900 border-slate-100 shadow rounded-xl h-12 p-3"
> >
<option value="all">Все</option> <option value="all">Все</option>
<option value="time">По времени</option> <option value="time">По времени</option>
@@ -42,38 +45,25 @@
<Time /> <Time />
</div> </div>
</div> </div>
<div class="dark:bg-gray-800 bg-white p-3 hidden lg:block"> <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"> <div class="mx-2 p-3 bg-gray-100 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600">
<Setings_downloads /> <SettingsDownloads />
</div> </div>
</div> </div>
<div ref="scrollContainer" class="p-4"> <div ref="scrollContainer" class="p-4">
<Stat <NewsCard
v-for="item in items" v-for="item in items"
:key="item.url" :key="item.url"
:url="item.url" v-bind="item"
:title="item.title" @update:viewed="handleFieldChange('viewed', $event)"
:article_date="item.article_date" @update:status="handleFieldChange('status', $event)"
:short_text="item.short_text" @update:tematik="handleFieldChange('tematik', $event)"
:category="item.category" @update:svodka="handleFieldChange('svodka', $event)"
:parsed_at="item.parsed_at" @update:donesenie="handleFieldChange('donesenie', $event)"
:status="item.status" @update:bilutene="handleFieldChange('bilutene', $event)"
:viewed="item.viewed"
:tematik="item.tematik"
:svodka="item.svodka"
:donesenie="item.donesenie"
:bilutene="item.bilutene"
:original_text="item.original_text"
:translation_text="item.translation_text"
:other="item.other"
@update:viewed="handleViewedChange"
@update:status="handleStatusChange"
@update:tematik="handleTematikChange"
@update:svodka="handleSvodkaChange"
@update:donesenie="handleDonesenieChange"
@update:bilutene="handleBiluteneChange"
/> />
<!-- Sentinel для бесконечного скролла -->
<div ref="sentinel" class="h-4"></div> <div ref="sentinel" class="h-4"></div>
<div v-if="isLoading" class="text-center p-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 v-if="!hasMore && items.length > 0" class="text-center p-4">
@@ -84,272 +74,164 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from "vue"; import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import Stat from "./One_kard.vue"; import NewsCard from './NewsCard.vue'
import Time from "./Time.vue"; import Time from './Time.vue'
import axios from "axios"; import SettingsDownloads from '../Settings_section/SettingsDownloads.vue'
import Setings_downloads from "../Settings_section/Setings_downloads.vue"; import { useDarkMode } from '@/composables/useDarkMode.js'
import {
fetchRecords,
fetchRecordsCount,
searchRecords,
fetchSearchCount,
} from '@/services/newsService.js'
const props = defineProps({ const props = defineProps({
filter: { type: String, default: "all" }, filter: { type: String, default: 'all' },
}); })
// Константы const LIMIT = 30
const LIMIT = 30; const POLL_INTERVAL = 60 * 60000 // 60 минут
const POLL_INTERVAL = 60 * 60000; // 15 секунд
// Состояния const { isDark } = useDarkMode()
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("");
// Пагинация const sentinel = ref(null)
let currentFilter = "default"; const scrollContainer = ref(null)
let currentOffset = 0; const isLoading = ref(false)
let pollTimer = null; const hasMore = ref(true)
let lastScrollTop = 0; // Сохраняем позицию скролла const items = ref([])
const searchQuery = ref('')
const activeFilter = ref('all')
const fetchData = async (url) => { let currentFilter = 'default'
try { let currentOffset = 0
const { data } = await axios.get(url); let pollTimer = null
return data; let observer = null
} catch (err) { let debounceTimer = null
console.error(`Ошибка при получении данных:`, err);
return [];
}
};
const fetchTotalCount = async (filterValue) => { async function loadItems(filterValue, append = false) {
try { if (isLoading.value) return
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) => { isLoading.value = true
try { currentFilter = filterValue
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) { if (!append) {
items.value = []; items.value = []
currentOffset = 0; currentOffset = 0
hasMore.value = true; hasMore.value = true
} }
currentFilter = filterValue;
try { try {
const url = poisk.value.trim() const isSearch = searchQuery.value.trim().length > 0
? `https://allowlgroup.ru/api/8002/poisk?query=${poisk.value}&item=${filterValue}&offset=${currentOffset}&limit=${LIMIT}` const totalCount = isSearch
: `https://allowlgroup.ru/api/8002/records_all?item=${filterValue}&offset=${currentOffset}&limit=${LIMIT}`; ? await fetchSearchCount(searchQuery.value, filterValue)
: await fetchRecordsCount(filterValue)
const totalCount = poisk.value.trim() const data = isSearch
? await fetchSearchCount(poisk.value, filterValue) ? await searchRecords(searchQuery.value, filterValue, currentOffset, LIMIT)
: await fetchTotalCount(filterValue); : await fetchRecords(filterValue, currentOffset, LIMIT)
const data = await fetchData(url); items.value = append ? [...items.value, ...data] : data
currentOffset += LIMIT
if (append) { hasMore.value = currentOffset < totalCount
items.value = [...items.value, ...data]; } catch (err) {
} else { console.error('Ошибка загрузки данных:', err)
items.value = data;
}
currentOffset += LIMIT;
hasMore.value = currentOffset < totalCount;
} finally { } finally {
isLoading.value = false; isLoading.value = false
} }
}; }
// === Polling - проверка новых данных === async function checkForUpdates() {
if (isLoading.value || searchQuery.value.trim()) return
const checkForUpdates = async () => {
// Не проверяем при активном поиске или загрузке
if (isLoading.value || (poisk.value && poisk.value.trim())) {
return;
}
try { try {
const totalCount = await fetchTotalCount(currentFilter); const data = await fetchRecords(currentFilter, 0, LIMIT)
const data = await fetchData( if (!data.length) return
`https://allowlgroup.ru/api/8002/records_all?item=${currentFilter}&offset=0&limit=${LIMIT}`,
);
if (!data.length) return; const existingMap = new Map(items.value.map((item) => [item.url, item]))
const newItems = []
// Создаём Map существующих URL для быстрого поиска const changedFields = ['viewed', 'status', 'tematik', 'svodka', 'donesenie', 'bilutene']
const existingUrls = new Map(items.value.map((item) => [item.url, item]));
const newItems = [];
let hasNew = false;
for (const item of data) { for (const item of data) {
const existing = existingUrls.get(item.url); const existing = existingMap.get(item.url)
if (!existing) { if (!existing) {
// Новая запись - добавляем в начало newItems.push(item)
newItems.push(item); } else if (changedFields.some((f) => existing[f] !== item[f])) {
hasNew = true; Object.assign(existing, item)
} else if (
existing.viewed !== item.viewed ||
existing.status !== item.status ||
existing.tematik !== item.tematik ||
existing.svodka !== item.svodka ||
existing.donesenie !== item.donesenie ||
existing.bilutene !== item.bilutene
) {
// Изменились viewed/status - обновляем
const index = items.value.indexOf(existing);
items.value[index] = { ...item };
hasNew = true;
} }
} }
// Добавляем новые записи в начало if (newItems.length) {
if (newItems.length > 0) { items.value = [...newItems, ...items.value]
items.value = [...newItems, ...items.value];
} }
} catch (err) { } catch (err) {
console.error("Ошибка при проверке обновлений:", err); console.error('Ошибка при проверке обновлений:', err)
} }
}; }
const startPolling = () => { function startPolling() {
stopPolling(); stopPolling()
pollTimer = setInterval(checkForUpdates, POLL_INTERVAL); pollTimer = setInterval(checkForUpdates, POLL_INTERVAL)
}; }
const stopPolling = () => { function stopPolling() {
if (pollTimer) { if (pollTimer) {
clearInterval(pollTimer); clearInterval(pollTimer)
pollTimer = null; pollTimer = null
} }
}; }
// === Обработчики событий от Stat === function handleFieldChange(field, { url, [field]: value }) {
const item = items.value.find((i) => i.url === url)
if (item) item[field] = value
}
const handleViewedChange = ({ url, viewed }) => { function onFilterChange() {
const item = items.value.find((i) => i.url === url); const filter = activeFilter.value === 'all' ? 'default' : activeFilter.value
if (item) item.viewed = viewed; loadItems(filter)
}; }
const handleStatusChange = ({ url, status }) => { watch(searchQuery, () => {
const item = items.value.find((i) => i.url === url); clearTimeout(debounceTimer)
if (item) item.status = status;
};
const handleTematikChange = ({ url, tematik }) => {
const item = items.value.find((i) => i.url === url);
if (item) item.tematik = tematik;
};
const handleSvodkaChange = ({ url, svodka }) => {
const item = items.value.find((i) => i.url === url);
if (item) item.svodka = svodka;
};
const handleDonesenieChange = ({ url, donesenie }) => {
const item = items.value.find((i) => i.url === url);
if (item) item.donesenie = donesenie;
};
const handleBiluteneChange = ({ url, bilutene }) => {
const item = items.value.find((i) => i.url === url);
if (item) item.bilutene = bilutene;
};
// === Watch ===
let debounceTimer = null;
watch(poisk, (newVal) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
currentOffset = 0; currentOffset = 0
loadItems(currentFilter); loadItems(currentFilter)
}, 800); }, 800)
}); })
watch( watch(
() => props.filter, () => props.filter,
(newFilter) => { (newFilter) => {
const filterValue = newFilter === "all" ? "default" : newFilter; const filterValue = newFilter === 'all' ? 'default' : newFilter
currentFilter = filterValue; activeFilter.value = newFilter === 'all' ? 'all' : newFilter
poisk.value = ""; currentFilter = filterValue
loadItems(filterValue); searchQuery.value = ''
loadItems(filterValue)
}, },
); { immediate: true },
)
const onfilterItems = (filterValue) => {
const filter = filterValue === "all" ? "default" : filterValue;
loadItems(filter);
};
// === Lifecycle ===
let observer = null;
onMounted(() => { onMounted(() => {
// Тема startPolling()
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(() => { nextTick(() => {
observer = new IntersectionObserver( observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting && hasMore.value) { if (entries[0].isIntersecting && hasMore.value) {
loadItems(currentFilter, true); // append = true loadItems(currentFilter, true)
} }
}, },
{ rootMargin: "100px" }, { rootMargin: '100px' },
); )
if (sentinel.value) { if (sentinel.value) {
observer.observe(sentinel.value); observer.observe(sentinel.value)
} }
}); })
}); })
onUnmounted(() => { onUnmounted(() => {
stopPolling(); stopPolling()
if (observer) observer.disconnect(); if (observer) observer.disconnect()
}); })
</script> </script>

View File

@@ -0,0 +1,269 @@
<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,
});
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>

View File

@@ -1,434 +0,0 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import axios from "axios";
// Добавляем emit для уведомления родителя об изменениях
const emit = defineEmits([
"update:viewed",
"update:status",
"update:tematik",
"update:svodka",
"update:donesenie",
"update:bilutene",
]);
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,
tematik: Boolean,
svodka: Boolean,
donesenie: Boolean,
bilutene: Boolean,
});
// Делаем viewed и status реактивными для локального обновления
const localViewed = ref(props.viewed);
const localStatus = ref(props.status);
// Делаем tematik, svodka, donesenie, bilutene реактивными для локального обновления
const localTematik = ref(props.tematik);
const localSvodka = ref(props.svodka);
const localDonesenie = ref(props.donesenie);
const localBilutene = ref(props.bilutene);
// Следим за изменениями props и обновляем локальные значения
watch(
() => props.viewed,
(val) => {
localViewed.value = val;
},
);
watch(
() => props.status,
(val) => {
localStatus.value = val;
},
);
watch(
() => props.tematik,
(val) => {
localTematik.value = val;
},
);
watch(
() => props.svodka,
(val) => {
localSvodka.value = val;
},
);
watch(
() => props.donesenie,
(val) => {
localDonesenie.value = val;
},
);
watch(
() => props.bilutene,
(val) => {
localBilutene.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 tematik_b = async () => {
const newValue = !localTematik.value;
localTematik.value = newValue;
emit("update:tematik", { url: props.url, tematik: newValue });
try {
await axios.post(
"https://allowlgroup.ru/api/8002/update_tematik_status",
null,
{ params: { url: props.url, tematik: newValue } },
);
} catch (err) {
console.log(err);
localTematik.value = !newValue;
}
};
const svodka_b = async () => {
const newValue = !localSvodka.value;
localSvodka.value = newValue;
emit("update:svodka", { url: props.url, svodka: newValue });
try {
await axios.post(
"https://allowlgroup.ru/api/8002/update_svodka_status",
null,
{ params: { url: props.url, svodka: newValue } },
);
} catch (err) {
console.log(err);
localSvodka.value = !newValue;
}
};
const donesenie_b = async () => {
const newValue = !localDonesenie.value;
localDonesenie.value = newValue;
emit("update:donesenie", { url: props.url, donesenie: newValue });
try {
await axios.post(
"https://allowlgroup.ru/api/8002/update_donesenie_status",
null,
{ params: { url: props.url, donesenie: newValue } },
);
} catch (err) {
console.log(err);
localDonesenie.value = !newValue;
}
};
const bilutene_b = async () => {
const newValue = !localBilutene.value;
localBilutene.value = newValue;
emit("update:bilutene", { url: props.url, bilutene: newValue });
try {
await axios.post(
"https://allowlgroup.ru/api/8002/update_bilutene_status",
null,
{ params: { url: props.url, bilutene: newValue } },
);
} catch (err) {
console.log(err);
localBilutene.value = !newValue;
}
};
const download = async () => {
try {
// Обрезаем заголовок до 200 символов для совместимости с именем файла на сервере
// const truncatedTitle = props.title.slice(0, 100);
const rez = await axios.get(
"https://allowlgroup.ru/api/8001/file_download",
// "http://127.0.0.1:8001/file_download",
{
params: {
// path: props.parsed_at.split(" ")[0].replace("-", "/"),
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">
<div class="flex">
<p>{{ article_date }}</p>
<span class="text-sm ml-2">
<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"
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"
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
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>{{ 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>{{ original_text }}</p>
</div>
</div>
</div>
<div class="flex justify-between">
<div class="ml-4 mb-4 flex items-end gap-2">
<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>
<!-- Кнопки Т, С, Д, Б -->
<button
@click="tematik_b"
class="w-10 h-10 rounded-lg font-bold text-sm hover:opacity-75 active:opacity-75 transition-colors cursor-pointer"
:class="
localTematik
? '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
@click="svodka_b"
class="w-10 h-10 rounded-lg font-bold text-sm hover:opacity-75 active:opacity-75 transition-colors cursor-pointer"
:class="
localSvodka
? '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
@click="donesenie_b"
class="w-10 h-10 rounded-lg font-bold text-sm hover:opacity-75 active:opacity-75 transition-colors cursor-pointer"
:class="
localDonesenie
? '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
@click="bilutene_b"
class="w-10 h-10 rounded-lg font-bold text-sm hover:opacity-75 active:opacity-75 transition-colors cursor-pointer"
:class="
localBilutene
? '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
class="hover:opacity-75 active:opacity-75 mx-5 rounded-xl cursor-pointer"
@click="download"
>
<img src="/src/assets/word.png" class="h-10 mb-2" />
</button>
</div>
</div>
</template>

View File

@@ -4,42 +4,23 @@
</div> </div>
</template> </template>
<script> <script setup>
export default { import { ref, onMounted, onBeforeUnmount } from 'vue'
data() { import { formatUtcDateTime } from '@/utils/date.js'
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 = {}; const currentDateTime = ref('')
parts.forEach(({ type, value }) => { let timer = null
dateParts[type] = value;
});
this.currentDateTime = `${dateParts.year}-${dateParts.month}-${dateParts.day} ${dateParts.hour}:${dateParts.minute}:${dateParts.second}`; function updateTime() {
}, currentDateTime.value = formatUtcDateTime()
}, }
mounted() {
this.updateTime(); onMounted(() => {
this.timer = setInterval(this.updateTime, 1000); updateTime()
}, timer = setInterval(updateTime, 1000)
beforeDestroy() { })
clearInterval(this.timer);
}, onBeforeUnmount(() => {
}; clearInterval(timer)
})
</script> </script>

View File

@@ -1,8 +1,7 @@
<template> <template>
<div class="date-input-container relative flex-grow"> <div class="date-input-container relative flex-grow">
<input <input
:value="modelValue" v-model="model"
@input="$emit('update:modelValue', $event.target.value)"
type="date" type="date"
:placeholder="placeholder" :placeholder="placeholder"
class="date-input dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 cursor-pointer w-full appearance-none" class="date-input dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 cursor-pointer w-full appearance-none"
@@ -27,18 +26,14 @@
</template> </template>
<script setup> <script setup>
const model = defineModel({ type: String, default: '' })
defineProps({ defineProps({
modelValue: {
type: String,
default: "",
},
placeholder: { placeholder: {
type: String, type: String,
default: "", default: '',
}, },
}); })
defineEmits(["update:modelValue"]);
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,273 +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>
<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"
>
<Setings_downloads />
</div>
</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}`,
);
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>

View File

@@ -1,92 +0,0 @@
<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="data_start" />
<span class="dark:text-neutral-300 whitespace-nowrap">по</span>
<DatePicker v-model="data_finish" />
</div>
<select
v-model="selectedFilter"
@change="onfilterItems($event.target.value)"
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
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 lg:w-auto md:min-w-40 flex-shrink-0"
@click="downloadAll"
>
Выгрузить
</button>
</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());
const selectedFilter = ref("status"); // По умолчанию "Избранные"
// Функция для получения вчерашней даты в формате 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 onfilterItems = (value) => {
selectedFilter.value = value;
};
// Выгрузка
const downloadAll = async () => {
try {
const response = await axios.post(
"https://allowlgroup.ru/api/8001/download_all",
// "http://127.0.0.1:8001/download_all",
{
data_start: data_start.value,
data_finish: data_finish.value,
field_name: selectedFilter.value,
},
{
responseType: "blob",
},
);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute(
"download",
`${selectedFilter.value}_${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>

View File

@@ -0,0 +1,248 @@
<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 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"
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>
</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 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>

View File

@@ -0,0 +1,56 @@
<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" />
<span class="dark:text-neutral-300 whitespace-nowrap">по</span>
<DatePicker v-model="dateFinish" />
</div>
<select
v-model="selectedFilter"
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>

View 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 }
}

View File

@@ -3,4 +3,16 @@ import './assets/main.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.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') createApp(App).mount('#app')

20
src/services/api.js Normal file
View File

@@ -0,0 +1,20 @@
import axios from 'axios'
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'
export const api8001 = axios.create({
baseURL: API_BASE_8001,
headers: { 'Content-Type': 'application/json' },
})
export const api8002 = axios.create({
baseURL: API_BASE_8002,
headers: { 'Content-Type': 'application/json' },
})
export const api8004 = axios.create({
baseURL: API_BASE_8004,
headers: { 'Content-Type': 'application/json' },
})

View File

@@ -0,0 +1,6 @@
import { api8004 } from './api.js'
export async function login(username, password) {
const { data } = await api8004.post('/login', { username, password })
return data
}

View 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 },
})
}

View File

@@ -0,0 +1,40 @@
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' },
)
}

View 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 })
}

31
src/utils/date.js Normal file
View 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
View 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
}
}