Compare commits

..

29 Commits

Author SHA1 Message Date
bf83ed8770 start_ist
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-20 22:48:23 +10:00
45551a64f1 добавлено отслеживание выгрузок
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-19 23:13:55 +10:00
d10a74eea5 полный рефакторинг всей системы
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-08 17:01:37 +10:00
f7b91ed75e убрано ограничение по длине названия
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-08 12:42:12 +10:00
e3d9eedbab длина сохранения 100
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-07 21:26:14 +10:00
148518d1ef фиксим
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-07 21:18:11 +10:00
c19ec3b232 правка
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-07 19:26:57 +10:00
9318821099 передел
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-07 19:14:19 +10:00
cb4ca3a570 Обновить src/components/News_section/One_kard.vue
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-06 00:20:23 +00:00
27839d3fae status
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-05 22:27:52 +10:00
2e7d6ac7c1 jib,jxyst ccskrb
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-05 22:17:03 +10:00
e9129667ed фильтрация источников
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-03 14:00:16 +10:00
e20f2a95df akfu 50
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-02 23:42:19 +10:00
d4150045e2 поправил выгрузку
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-02 11:45:33 +10:00
82ff923256 collore
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-02 11:39:01 +10:00
686daa1a37 Внесение правок и перенос выгрузки
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-02 01:03:39 +10:00
e61634e396 адаптация
All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 00:10:36 +10:00
49361d3561 расширение выгрузки
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-01 23:34:18 +10:00
5b7ab3bf5a отладил добавление кнопок
All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 20:16:46 +10:00
a05e02f5df добавил сводку и тп.
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-01 19:33:12 +10:00
729006359c корекция размеров
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-26 16:22:31 +10:00
9388703b04 Флаги
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-26 16:13:27 +10:00
094df8e802 Сортировка по странам
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-25 15:56:57 +10:00
11dcd11a3e - reCAPTCHA
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-23 19:08:06 +10:00
da02da6661 bag remyf
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-20 20:30:10 +10:00
c134708b79 flfgnfwbz
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-19 19:26:06 +10:00
c62b151926 IP
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-19 19:16:14 +10:00
6e58337490 группировка по папкам + удоление источников
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-19 19:14:46 +10:00
796769d270 yjd
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-16 22:26:04 +10:00
31 changed files with 1508 additions and 1431 deletions

View File

@@ -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>

View File

@@ -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 />
</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>
<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>
</template>
</template>
<style>
</style>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,93 @@
<script setup>
import { ref } from 'vue'
import { login as apiLogin } from '@/services/authService.js'
const props = defineProps({
currentPage: String,
})
const emit = defineEmits(['update'])
const login = ref('')
const password = ref('')
const authError = ref(false)
const isLoading = ref(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
}
</script>
<template>
<div
v-if="currentPage === 'admin-panel'"
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 class="block mb-1 text-sm">Логин</label>
<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 class="mb-4">
<label class="block mb-1 text-sm">Пароль</label>
<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>
<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>
</template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -3,28 +3,30 @@
<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="Поиск..."
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"
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 +40,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"
>
@@ -64,6 +65,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 +74,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>

View File

@@ -0,0 +1,86 @@
<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 источника"
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="Промт"
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>

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

View File

@@ -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>

View File

@@ -0,0 +1,237 @@
<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="Поиск..."
class="dark:bg-gray-900 border-slate-100 shadow rounded-xl p-3 pl-11"
/>
</div>
<select
v-model="activeFilter"
@change="onFilterChange"
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>

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

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,14 @@
</template>
<script setup>
const model = defineModel({ type: String, default: '' })
defineProps({
modelValue: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "",
default: '',
},
});
defineEmits(["update:modelValue"]);
})
</script>
<style scoped>

View File

@@ -0,0 +1,256 @@
<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="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"
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>

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,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>

View File

@@ -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>

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 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')

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

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