328 lines
14 KiB
Vue
328 lines
14 KiB
Vue
<script setup>
|
|
import NewsService from '@/service/NewsService';
|
|
import Button from 'primevue/button';
|
|
import Card from 'primevue/card';
|
|
import ProgressSpinner from 'primevue/progressspinner';
|
|
import Tag from 'primevue/tag';
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
|
|
// Реактивные данные
|
|
const news = ref(null);
|
|
const loading = ref(false);
|
|
const error = ref(null);
|
|
|
|
// Вычисляемые свойства
|
|
const hasAnalytics = computed(() => {
|
|
return (
|
|
news.value?.analytics &&
|
|
(news.value.analytics.summary || news.value.analytics.sentiment || (news.value.analytics.tags && news.value.analytics.tags.length > 0) || (news.value.analytics.entities && Object.keys(news.value.analytics.entities).length > 0))
|
|
);
|
|
});
|
|
|
|
// Методы
|
|
const loadNews = async () => {
|
|
const newsId = route.params.id;
|
|
if (!newsId) {
|
|
error.value = 'ID новости не указан';
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
error.value = null;
|
|
|
|
try {
|
|
// Поскольку у нас нет отдельного API для получения одной новости,
|
|
// мы будем использовать общий API с фильтром по ID
|
|
// В реальном приложении здесь должен быть отдельный эндпоинт
|
|
const response = await NewsService.getNewsItems({
|
|
page: 0,
|
|
size: 1
|
|
// Здесь можно добавить фильтр по ID, если API это поддерживает
|
|
});
|
|
|
|
if (response.success && response.data.content.length > 0) {
|
|
// Найдем новость по ID (временное решение)
|
|
const foundNews = response.data.content.find((item) => item.id === newsId);
|
|
if (foundNews) {
|
|
news.value = foundNews;
|
|
} else {
|
|
error.value = 'Новость не найдена';
|
|
}
|
|
} else {
|
|
error.value = 'Не удалось загрузить новость';
|
|
}
|
|
} catch (err) {
|
|
console.error('Ошибка при загрузке новости:', err);
|
|
error.value = 'Ошибка при загрузке новости';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const goBack = () => {
|
|
router.push('/news');
|
|
};
|
|
|
|
const openOriginalUrl = () => {
|
|
if (news.value?.url) {
|
|
window.open(news.value.url, '_blank');
|
|
}
|
|
};
|
|
|
|
const shareNews = async () => {
|
|
if (navigator.share && news.value) {
|
|
try {
|
|
await navigator.share({
|
|
title: news.value.title,
|
|
text: news.value.rawText?.substring(0, 200) + '...',
|
|
url: news.value.url
|
|
});
|
|
} catch (err) {
|
|
console.log('Поделиться отменено или не поддерживается');
|
|
}
|
|
} else {
|
|
// Fallback - копирование URL в буфер обмена
|
|
if (news.value?.url) {
|
|
try {
|
|
await navigator.clipboard.writeText(news.value.url);
|
|
// Здесь можно показать уведомление об успешном копировании
|
|
console.log('URL скопирован в буфер обмена');
|
|
} catch (err) {
|
|
console.error('Ошибка при копировании URL:', err);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const getSentimentSeverity = (sentiment) => {
|
|
switch (sentiment?.toLowerCase()) {
|
|
case 'positive':
|
|
case 'позитивный':
|
|
return 'success';
|
|
case 'negative':
|
|
case 'негативный':
|
|
return 'danger';
|
|
case 'neutral':
|
|
case 'нейтральный':
|
|
return 'info';
|
|
default:
|
|
return 'secondary';
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString) => {
|
|
return NewsService.formatDate(dateString);
|
|
};
|
|
|
|
const formatDateShort = (dateString) => {
|
|
return NewsService.formatDateShort(dateString);
|
|
};
|
|
|
|
const truncateUrl = (url) => {
|
|
if (!url) return '';
|
|
return url.length > 50 ? url.substring(0, 50) + '...' : url;
|
|
};
|
|
|
|
const truncateHash = (hash) => {
|
|
if (!hash) return '';
|
|
return hash.length > 20 ? hash.substring(0, 20) + '...' : hash;
|
|
};
|
|
|
|
// Жизненный цикл
|
|
onMounted(() => {
|
|
loadNews();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="card">
|
|
<!-- Загрузка -->
|
|
<div v-if="loading" class="text-center py-6">
|
|
<ProgressSpinner />
|
|
<p class="mt-3 text-color-secondary">Загрузка новости...</p>
|
|
</div>
|
|
|
|
<!-- Ошибка -->
|
|
<div v-else-if="error" class="text-center py-6">
|
|
<i class="pi pi-exclamation-triangle text-6xl text-red-500 mb-3"></i>
|
|
<h3 class="text-xl font-semibold mb-2">Ошибка загрузки</h3>
|
|
<p class="text-color-secondary mb-4">{{ error }}</p>
|
|
<Button label="Попробовать снова" icon="pi pi-refresh" @click="loadNews" severity="secondary" />
|
|
</div>
|
|
|
|
<!-- Новость не найдена -->
|
|
<div v-else-if="!news" class="text-center py-6">
|
|
<i class="pi pi-inbox text-6xl text-color-secondary mb-3"></i>
|
|
<h3 class="text-xl font-semibold mb-2">Новость не найдена</h3>
|
|
<p class="text-color-secondary mb-4">Запрашиваемая новость не существует или была удалена</p>
|
|
<Button label="Вернуться к новостям" icon="pi pi-arrow-left" @click="goBack" severity="info" />
|
|
</div>
|
|
|
|
<!-- Контент новости -->
|
|
<div v-else>
|
|
<!-- Заголовок и метаданные -->
|
|
<div class="mb-4">
|
|
<div class="flex justify-content-between align-items-start mb-3">
|
|
<Button icon="pi pi-arrow-left" label="Назад к новостям" @click="goBack" severity="secondary" text />
|
|
<div class="flex gap-2">
|
|
<Button icon="pi pi-external-link" label="Оригинал" @click="openOriginalUrl" severity="info" size="small" />
|
|
<Button icon="pi pi-share" @click="shareNews" severity="secondary" size="small" v-tooltip.top="'Поделиться'" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap align-items-center gap-2 mb-3">
|
|
<Tag :value="news.sourceName" severity="info" class="text-sm" />
|
|
<Tag v-if="news.category" :value="news.category" severity="success" class="text-sm" />
|
|
<div class="flex align-items-center text-sm text-color-secondary">
|
|
<i class="pi pi-calendar mr-1"></i>
|
|
{{ formatDate(news.publishedAt) }}
|
|
</div>
|
|
<div class="flex align-items-center text-sm text-color-secondary">
|
|
<i class="pi pi-clock mr-1"></i>
|
|
Добавлено: {{ formatDateShort(news.addedAt) }}
|
|
</div>
|
|
</div>
|
|
|
|
<h1 class="text-3xl font-bold line-height-3 mb-4">{{ news.title }}</h1>
|
|
</div>
|
|
|
|
<!-- Аналитика (если доступна) -->
|
|
<div v-if="hasAnalytics" class="mb-4">
|
|
<Card>
|
|
<template #title>
|
|
<div class="flex align-items-center">
|
|
<i class="pi pi-chart-line mr-2"></i>
|
|
Аналитика
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<div class="grid">
|
|
<div v-if="news.analytics.summary" class="col-12">
|
|
<h4 class="font-semibold mb-2">Краткое содержание:</h4>
|
|
<p class="text-color-secondary line-height-3">{{ news.analytics.summary }}</p>
|
|
</div>
|
|
<div v-if="news.analytics.sentiment" class="col-12 md:col-6">
|
|
<h4 class="font-semibold mb-2">Тональность:</h4>
|
|
<Tag :value="news.analytics.sentiment" :severity="getSentimentSeverity(news.analytics.sentiment)" />
|
|
</div>
|
|
<div v-if="news.analytics.tags && news.analytics.tags.length > 0" class="col-12 md:col-6">
|
|
<h4 class="font-semibold mb-2">Теги:</h4>
|
|
<div class="flex flex-wrap gap-1">
|
|
<Tag v-for="tag in news.analytics.tags" :key="tag" :value="tag" severity="secondary" class="text-xs" />
|
|
</div>
|
|
</div>
|
|
<div v-if="news.analytics.entities && Object.keys(news.analytics.entities).length > 0" class="col-12">
|
|
<h4 class="font-semibold mb-2">Сущности:</h4>
|
|
<div class="flex flex-wrap gap-2">
|
|
<div v-for="(count, entity) in news.analytics.entities" :key="entity" class="p-2 border-round bg-gray-50 text-sm">
|
|
<span class="font-medium">{{ entity }}</span>
|
|
<span class="text-color-secondary ml-1">({{ count }})</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Основной текст -->
|
|
<Card>
|
|
<template #title>
|
|
<div class="flex align-items-center">
|
|
<i class="pi pi-file-text mr-2"></i>
|
|
Содержание
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<div class="prose max-w-none">
|
|
<div v-if="news.rawText" class="whitespace-pre-wrap line-height-3">
|
|
{{ news.rawText }}
|
|
</div>
|
|
<div v-else class="text-center py-4 text-color-secondary">
|
|
<i class="pi pi-file-o text-4xl mb-2"></i>
|
|
<p>Текст новости недоступен</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Card>
|
|
|
|
<!-- Техническая информация -->
|
|
<Card class="mt-4">
|
|
<template #title>
|
|
<div class="flex align-items-center">
|
|
<i class="pi pi-info-circle mr-2"></i>
|
|
Техническая информация
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<div class="grid">
|
|
<div class="col-12 md:col-6">
|
|
<div class="space-y-2">
|
|
<div class="flex justify-content-between">
|
|
<span class="font-medium text-sm">ID:</span>
|
|
<span class="text-sm font-mono">{{ news.id }}</span>
|
|
</div>
|
|
<div class="flex justify-content-between">
|
|
<span class="font-medium text-sm">URL:</span>
|
|
<a :href="news.url" target="_blank" class="text-sm text-blue-600 hover:text-blue-800">
|
|
{{ truncateUrl(news.url) }}
|
|
</a>
|
|
</div>
|
|
<div class="flex justify-content-between">
|
|
<span class="font-medium text-sm">Хеш:</span>
|
|
<span class="text-sm font-mono">{{ truncateHash(news.hash) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 md:col-6">
|
|
<div class="space-y-2">
|
|
<div class="flex justify-content-between">
|
|
<span class="font-medium text-sm">Дата публикации:</span>
|
|
<span class="text-sm">{{ formatDate(news.publishedAt) }}</span>
|
|
</div>
|
|
<div class="flex justify-content-between">
|
|
<span class="font-medium text-sm">Дата добавления:</span>
|
|
<span class="text-sm">{{ formatDate(news.addedAt) }}</span>
|
|
</div>
|
|
<div class="flex justify-content-between">
|
|
<span class="font-medium text-sm">Источник:</span>
|
|
<span class="text-sm">{{ news.sourceName }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Card>
|
|
|
|
<!-- Действия -->
|
|
<div class="flex justify-content-center gap-2 mt-4">
|
|
<Button label="Вернуться к новостям" icon="pi pi-arrow-left" @click="goBack" severity="secondary" />
|
|
<Button label="Открыть оригинал" icon="pi pi-external-link" @click="openOriginalUrl" severity="info" />
|
|
<Button label="Поделиться" icon="pi pi-share" @click="shareNews" severity="success" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.prose {
|
|
max-width: none;
|
|
}
|
|
|
|
.space-y-2 > * + * {
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.whitespace-pre-wrap {
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.line-height-3 {
|
|
line-height: 1.75;
|
|
}
|
|
</style>
|