news-classifier/client/src/views/home/DashboardView.vue

951 lines
21 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { newsApi, categoryApi } from '@/api'
import type { NewsStatistics, CategoryDto, NewsDto } from '@/types/api'
import { usePagination } from '@/composables/usePagination'
const router = useRouter()
// 统计数据
const statistics = ref<NewsStatistics | null>(null)
const loadingStats = ref(false)
// 分类列表
const categories = ref<CategoryDto[]>([])
// 最新新闻
const {
data: latestNews,
loading: newsLoading,
fetch: fetchLatestNews
} = usePagination(newsApi.getLatest.bind(newsApi), { page: 1, size: 5 })
// 分类统计(计算属性)
const categoryStats = computed(() => {
if (!statistics.value) return []
return Object.entries(statistics.value.categoryStats)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
})
// 来源统计(计算属性)
const sourceStats = computed(() => {
if (!statistics.value) return []
return Object.entries(statistics.value.sourceStats)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
})
// 格式化数字
function formatNumber(num: number): string {
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k'
}
return num.toString()
}
// 格式化时间
function formatTime(timeStr: string): string {
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN')
}
// 跳转到新闻详情
function goToNewsDetail(id: number) {
router.push(`/news/${id}`)
}
// 初始化数据
onMounted(async () => {
// 获取统计数据
loadingStats.value = true
try {
statistics.value = await newsApi.getStatistics()
} catch (error) {
console.error('获取统计数据失败:', error)
} finally {
loadingStats.value = false
}
// 获取分类列表
try {
categories.value = await categoryApi.getWithCount()
} catch (error) {
console.error('获取分类列表失败:', error)
}
// 获取最新新闻
try {
await fetchLatestNews()
} catch (error) {
console.error('获取最新新闻失败:', error)
}
})
</script>
<template>
<div class="dashboard">
<!-- 统计卡片网格 -->
<div class="stats-grid">
<div class="stat-card" data-aos="fade-up" data-aos-delay="0">
<div class="stat-decoration"></div>
<div class="stat-header">
<span class="stat-label">总新闻数</span>
<div class="stat-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div class="stat-value">{{ statistics ? formatNumber(statistics.totalNews) : '-' }}</div>
<div class="stat-icon-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
</div>
</div>
<div class="stat-card" data-aos="fade-up" data-aos-delay="100">
<div class="stat-decoration"></div>
<div class="stat-header">
<span class="stat-label">分类数量</span>
<div class="stat-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div class="stat-value">{{ categories.length }}</div>
<div class="stat-icon-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 12h10"/>
<path d="M12 7v10"/>
</svg>
</div>
</div>
<div class="stat-card" data-aos="fade-up" data-aos-delay="200">
<div class="stat-decoration"></div>
<div class="stat-header">
<span class="stat-label">活跃来源</span>
<div class="stat-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div class="stat-value">{{ sourceStats.length }}</div>
<div class="stat-icon-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
</div>
</div>
<div class="stat-card" data-aos="fade-up" data-aos-delay="300">
<div class="stat-decoration"></div>
<div class="stat-header">
<span class="stat-label">最近更新</span>
<div class="stat-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div class="stat-value small">{{ latestNews && latestNews.length > 0 && latestNews[0] ? formatTime(latestNews[0].createdAt) : '-' }}</div>
<div class="stat-icon-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8v4l3 3"/>
<circle cx="12" cy="12" r="10"/>
</svg>
</div>
</div>
</div>
<div class="dashboard-content">
<!-- 分类分布 -->
<div class="chart-section" data-aos="fade-up" data-aos-delay="400">
<div class="section-header">
<h3 class="section-title">分类分布</h3>
<div class="corner-bracket"></div>
</div>
<div class="category-list">
<div v-for="(item, index) in categoryStats" :key="item.name" class="category-item" data-aos="slide-right" :data-aos-delay="500 + index * 50">
<div class="category-info">
<span class="category-index">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="category-name">{{ item.name }}</span>
<span class="category-count">{{ item.count }}</span>
</div>
<div class="category-bar">
<div class="category-bar-fill" :style="{ width: `${(item.count / statistics!.totalNews * 100)}%` }">
<div class="bar-pattern"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 来源分布 -->
<div class="chart-section" data-aos="fade-up" data-aos-delay="450">
<div class="section-header">
<h3 class="section-title">来源分布</h3>
<div class="corner-bracket"></div>
</div>
<div class="source-list">
<div v-for="(item, index) in sourceStats.slice(0, 6)" :key="item.name" class="source-item" data-aos="fade-in" :data-aos-delay="500 + index * 50">
<div class="source-rank">{{ index + 1 }}</div>
<div class="source-name">{{ item.name }}</div>
<div class="source-count">{{ formatNumber(item.count) }}</div>
</div>
</div>
</div>
</div>
<!-- 最新新闻 -->
<div class="latest-section" data-aos="fade-up" data-aos-delay="600">
<div class="section-header">
<h3 class="section-title">最新新闻</h3>
<a class="section-link" @click="router.push('/news')">
查看全部
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</a>
</div>
<div v-if="newsLoading" class="loading-state">
<div class="spinner"></div>
</div>
<div v-else-if="latestNews.length > 0" class="news-list">
<div v-for="(news, index) in latestNews" :key="news.id" class="news-item" data-aos="slide-up" :data-aos-delay="700 + index * 100" @click="goToNewsDetail(news.id)">
<div class="news-decoration"></div>
<div class="news-meta">
<span class="news-category">{{ news.categoryName || '未分类' }}</span>
<span class="news-divider">/</span>
<span class="news-source">{{ news.source || '未知来源' }}</span>
</div>
<h4 class="news-title">{{ news.title }}</h4>
<div class="news-footer">
<span class="news-time">{{ formatTime(news.createdAt) }}</span>
<svg class="news-arrow" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</div>
</div>
</div>
<div v-else class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
<p>暂无新闻数据</p>
</div>
</div>
</div>
</template>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
:root {
--font-display: 'Archivo Black', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--color-black: #000000;
--color-white: #ffffff;
--color-gray-50: #fafafa;
--color-gray-100: #f5f5f5;
--color-gray-200: #e5e5e5;
--color-gray-300: #d4d4d4;
--color-gray-400: #a3a3a3;
--color-gray-500: #737373;
--color-gray-600: #525252;
--color-gray-700: #404040;
--color-gray-800: #262626;
--color-gray-900: #171717;
}
.dark {
--color-black: #ffffff;
--color-white: #000000;
--color-gray-50: #0a0a0a;
--color-gray-100: #171717;
--color-gray-200: #262626;
--color-gray-300: #404040;
--color-gray-400: #525252;
--color-gray-500: #737373;
--color-gray-600: #a3a3a3;
--color-gray-700: #d4d4d4;
--color-gray-800: #e5e5e5;
--color-gray-900: #f5f5f5;
}
.dashboard {
display: flex;
flex-direction: column;
gap: 2rem;
font-family: var(--font-mono);
}
/* 统计卡片网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.25rem;
}
.stat-card {
position: relative;
display: flex;
flex-direction: column;
padding: 1.75rem;
background: var(--color-white);
border: 2px solid var(--color-black);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: repeating-linear-gradient(
90deg,
var(--color-black) 0px,
var(--color-black) 2px,
transparent 2px,
transparent 8px
);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 8px 8px 0 var(--color-black);
}
.dark .stat-card {
background: var(--color-gray-100);
border-color: var(--color-white);
}
.dark .stat-card::before {
background: repeating-linear-gradient(
90deg,
var(--color-white) 0px,
var(--color-white) 2px,
transparent 2px,
transparent 8px
);
}
.dark .stat-card:hover {
box-shadow: 8px 8px 0 var(--color-white);
}
.stat-decoration {
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 4px,
var(--color-gray-200) 4px,
var(--color-gray-200) 8px
);
opacity: 0.5;
transition: opacity 0.3s;
}
.stat-card:hover .stat-decoration {
opacity: 1;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.stat-label {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-gray-600);
}
.stat-dots {
display: flex;
gap: 4px;
}
.stat-dots span {
width: 4px;
height: 4px;
background: var(--color-black);
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
.stat-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.stat-dots span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.dark .stat-dots span {
background: var(--color-white);
}
.stat-value {
font-family: var(--font-display);
font-size: 3rem;
font-weight: 400;
line-height: 1;
color: var(--color-black);
margin-bottom: 1rem;
}
.stat-value.small {
font-size: 1.5rem;
line-height: 1.2;
}
.dark .stat-value {
color: var(--color-white);
}
.stat-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: var(--color-black);
color: var(--color-white);
border-radius: 8px;
transition: transform 0.3s;
}
.stat-card:hover .stat-icon-wrapper {
transform: rotate(5deg);
}
.dark .stat-icon-wrapper {
background: var(--color-white);
color: var(--color-black);
}
/* 仪表板内容区 */
.dashboard-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 1.25rem;
}
.chart-section {
background: var(--color-white);
border: 2px solid var(--color-black);
padding: 1.75rem;
position: relative;
overflow: hidden;
}
.chart-section::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 40px;
height: 40px;
background: linear-gradient(135deg, transparent 50%, var(--color-gray-200) 50%);
}
.dark .chart-section {
background: var(--color-gray-100);
border-color: var(--color-white);
}
.dark .chart-section::before {
background: linear-gradient(135deg, transparent 50%, var(--color-gray-300) 50%);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
position: relative;
}
.section-title {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 400;
color: var(--color-black);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dark .section-title {
color: var(--color-white);
}
.corner-bracket {
position: absolute;
right: 0;
top: -8px;
width: 20px;
height: 20px;
border-right: 3px solid var(--color-black);
border-bottom: 3px solid var(--color-black);
}
.dark .corner-bracket {
border-color: var(--color-white);
}
.section-link {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-black);
cursor: pointer;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.1em;
transition: all 0.3s;
}
.section-link:hover {
gap: 0.75rem;
}
.dark .section-link {
color: var(--color-white);
}
/* 分类列表 */
.category-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.category-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
transition: transform 0.3s;
}
.category-item:hover {
transform: translateX(4px);
}
.category-info {
display: flex;
align-items: center;
gap: 1rem;
}
.category-index {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 400;
color: var(--color-gray-400);
min-width: 32px;
}
.dark .category-index {
color: var(--color-gray-600);
}
.category-name {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-black);
}
.dark .category-name {
color: var(--color-white);
}
.category-count {
font-family: var(--font-display);
font-size: 1rem;
color: var(--color-black);
}
.dark .category-count {
color: var(--color-white);
}
.category-bar {
height: 6px;
background: var(--color-gray-200);
border-radius: 3px;
overflow: hidden;
position: relative;
}
.dark .category-bar {
background: var(--color-gray-300);
}
.category-bar-fill {
height: 100%;
background: var(--color-black);
border-radius: 3px;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.dark .category-bar-fill {
background: var(--color-white);
}
.bar-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
90deg,
transparent,
transparent 4px,
rgba(255, 255, 255, 0.3) 4px,
rgba(255, 255, 255, 0.3) 8px
);
}
.dark .bar-pattern {
background: repeating-linear-gradient(
90deg,
transparent,
transparent 4px,
rgba(0, 0, 0, 0.3) 4px,
rgba(0, 0, 0, 0.3) 8px
);
}
/* 来源列表 */
.source-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.source-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--color-gray-100);
border: 1px solid var(--color-gray-300);
transition: all 0.3s;
}
.source-item:hover {
background: var(--color-gray-200);
transform: translateX(4px);
border-color: var(--color-black);
}
.dark .source-item {
background: var(--color-gray-200);
border-color: var(--color-gray-400);
}
.dark .source-item:hover {
background: var(--color-gray-300);
border-color: var(--color-white);
}
.source-rank {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 400;
color: var(--color-gray-400);
min-width: 32px;
}
.dark .source-rank {
color: var(--color-gray-600);
}
.source-name {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-black);
}
.dark .source-name {
color: var(--color-white);
}
.source-count {
font-family: var(--font-mono);
font-size: 0.875rem;
font-weight: 600;
color: var(--color-black);
}
.dark .source-count {
color: var(--color-white);
}
/* 最新新闻 */
.latest-section {
background: var(--color-white);
border: 2px solid var(--color-black);
padding: 1.75rem;
position: relative;
}
.dark .latest-section {
background: var(--color-gray-100);
border-color: var(--color-white);
}
.news-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.news-item {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
background: var(--color-gray-50);
border: 1px solid var(--color-gray-300);
cursor: pointer;
transition: all 0.3s;
overflow: hidden;
}
.news-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0;
background: var(--color-black);
transition: width 0.3s;
}
.news-item:hover {
transform: translateX(4px);
border-color: var(--color-black);
padding-left: 2rem;
}
.news-item:hover::before {
width: 4px;
}
.dark .news-item {
background: var(--color-gray-200);
border-color: var(--color-gray-400);
}
.dark .news-item:hover {
border-color: var(--color-white);
}
.dark .news-item::before {
background: var(--color-white);
}
.news-decoration {
position: absolute;
top: 8px;
right: 8px;
width: 40px;
height: 40px;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 2px,
var(--color-gray-300) 2px,
var(--color-gray-300) 4px
);
opacity: 0;
transition: opacity 0.3s;
}
.news-item:hover .news-decoration {
opacity: 1;
}
.news-meta {
display: flex;
align-items: center;
gap: 0.75rem;
}
.news-category {
font-size: 0.7rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
background: var(--color-black);
color: var(--color-white);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dark .news-category {
background: var(--color-white);
color: var(--color-black);
}
.news-divider {
color: var(--color-gray-500);
}
.news-source {
font-size: 0.75rem;
color: var(--color-gray-600);
}
.dark .news-source {
color: var(--color-gray-400);
}
.news-title {
font-family: var(--font-display);
font-size: 1.125rem;
font-weight: 400;
color: var(--color-black);
margin: 0;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dark .news-title {
color: var(--color-white);
}
.news-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.news-time {
font-size: 0.75rem;
color: var(--color-gray-600);
}
.dark .news-time {
color: var(--color-gray-400);
}
.news-arrow {
color: var(--color-black);
transition: transform 0.3s;
}
.news-item:hover .news-arrow {
transform: translateX(4px);
}
.dark .news-arrow {
color: var(--color-white);
}
/* 加载状态 */
.loading-state {
display: flex;
justify-content: center;
padding: 3rem;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid var(--color-gray-300);
border-top-color: var(--color-black);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.dark .spinner {
border-color: var(--color-gray-600);
border-top-color: var(--color-white);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
color: var(--color-gray-600);
}
.empty-state svg {
margin-bottom: 1.5rem;
opacity: 0.3;
}
.empty-state p {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
}
</style>