diff --git a/src/components/StatsChart.astro b/src/components/StatsChart.astro new file mode 100644 index 0000000..23b544e --- /dev/null +++ b/src/components/StatsChart.astro @@ -0,0 +1,138 @@ +--- +type ChartItem = { + label: string; + value: number; + href?: string; +}; + +interface Props { + title: string; + subtitle?: string; + type: 'pie' | 'line'; + items: ChartItem[]; +} + +const { title, subtitle, type, items } = Astro.props; +const colors = ['#3eb8be', '#f2a65a', '#7f9cf5', '#68b684', '#f07f7f', '#b18ce8', '#e0b34f', '#5ca4d3']; +const safeItems = items.filter((item) => item.value > 0); +const total = safeItems.reduce((sum, item) => sum + item.value, 0); +const circumference = 100; +let offset = 0; + +const pieItems = safeItems.map((item, index) => { + const percent = total ? item.value / total : 0; + const dash = percent * circumference; + const segment = { + ...item, + color: colors[index % colors.length], + dash, + offset, + percentText: `${Math.round(percent * 100)}%`, + }; + offset -= dash; + return segment; +}); + +const lineWidth = 640; +const lineHeight = 240; +const padding = { top: 24, right: 24, bottom: 42, left: 40 }; +const plotWidth = lineWidth - padding.left - padding.right; +const plotHeight = lineHeight - padding.top - padding.bottom; +const maxValue = Math.max(1, ...safeItems.map((item) => item.value)); +const linePoints = safeItems.map((item, index) => { + const x = safeItems.length === 1 ? padding.left + plotWidth / 2 : padding.left + (index / (safeItems.length - 1)) * plotWidth; + const y = padding.top + plotHeight - (item.value / maxValue) * plotHeight; + return { ...item, x, y }; +}); +const points = linePoints.map((point) => `${point.x},${point.y}`).join(' '); +const areaPoints = linePoints.length + ? `${padding.left},${padding.top + plotHeight} ${points} ${padding.left + plotWidth},${padding.top + plotHeight}` + : ''; +const labelStep = Math.max(1, Math.ceil(linePoints.length / 6)); +--- + +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ + {type === 'pie' ? ( +
+ + + {pieItems.map((item) => ( + + ))} + {total} + 篇文章 + + +
+ {pieItems.map((item, index) => { + const content = ( + <> + + {item.label} + {item.value} + {item.percentText} + + ); + + return item.href ? ( + + {content} + + ) : ( +
+ {content} +
+ ); + })} +
+
+ ) : ( +
+ + + + {[0, 0.5, 1].map((ratio) => ( + <> + + + {Math.round(maxValue * ratio)} + + + ))} + {linePoints.length > 0 && } + {linePoints.length > 0 && } + {linePoints.map((point, index) => ( + <> + + {`${point.label}: ${point.value} 篇`} + {index % labelStep === 0 && ( + + {point.label} + + )} + + ))} + +
+ )} +
diff --git a/src/pages/archives/index.astro b/src/pages/archives/index.astro index 5a7898f..b787c9d 100644 --- a/src/pages/archives/index.astro +++ b/src/pages/archives/index.astro @@ -2,16 +2,25 @@ import BaseLayout from '@/layouts/BaseLayout.astro'; import Pagination from '@/components/Pagination.astro'; import PageHeaderCard from '@/components/PageHeaderCard.astro'; +import StatsChart from '@/components/StatsChart.astro'; import { getAllPosts } from '@/lib/posts'; import { getPageSlice } from '@/lib/pagination'; const posts = await getAllPosts(); const pageData = getPageSlice(posts, 1); +const archiveStats = [...posts.reduce((months, post) => { + const key = `${post.date.getFullYear()}-${String(post.date.getMonth() + 1).padStart(2, '0')}`; + months.set(key, (months.get(key) ?? 0) + 1); + return months; +}, new Map())] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([label, value]) => ({ label, value })); ---
+
{pageData.items.map((post) => ( diff --git a/src/pages/archives/page/[page].astro b/src/pages/archives/page/[page].astro index b09cfcc..4353bbd 100644 --- a/src/pages/archives/page/[page].astro +++ b/src/pages/archives/page/[page].astro @@ -2,6 +2,7 @@ import Pagination from '@/components/Pagination.astro'; import BaseLayout from '@/layouts/BaseLayout.astro'; import PageHeaderCard from '@/components/PageHeaderCard.astro'; +import StatsChart from '@/components/StatsChart.astro'; import { getAllPosts } from '@/lib/posts'; import { getPageSlice, getPaginationPaths } from '@/lib/pagination'; @@ -17,11 +18,19 @@ export async function getStaticPaths() { const { page } = Astro.props; const posts = await getAllPosts(); const pageData = getPageSlice(posts, page); +const archiveStats = [...posts.reduce((months, post) => { + const key = `${post.date.getFullYear()}-${String(post.date.getMonth() + 1).padStart(2, '0')}`; + months.set(key, (months.get(key) ?? 0) + 1); + return months; +}, new Map())] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([label, value]) => ({ label, value })); ---
+
{pageData.items.map((post) => ( diff --git a/src/pages/categories/index.astro b/src/pages/categories/index.astro index 3e8e0ed..27513a2 100644 --- a/src/pages/categories/index.astro +++ b/src/pages/categories/index.astro @@ -1,20 +1,27 @@ --- import BaseLayout from '@/layouts/BaseLayout.astro'; import PageHeaderCard from '@/components/PageHeaderCard.astro'; +import StatsChart from '@/components/StatsChart.astro'; import { getAllCategories, getAllPosts, getCategoryHref } from '@/lib/posts'; const posts = await getAllPosts(); const categories = await getAllCategories(); +const categoryStats = categories.map((category) => ({ + label: category, + value: posts.filter((post) => post.categories.includes(category)).length, + href: getCategoryHref(category), +})); ---
+ diff --git a/src/pages/tags/index.astro b/src/pages/tags/index.astro index 583397a..80c490a 100644 --- a/src/pages/tags/index.astro +++ b/src/pages/tags/index.astro @@ -1,20 +1,27 @@ --- import BaseLayout from '@/layouts/BaseLayout.astro'; import PageHeaderCard from '@/components/PageHeaderCard.astro'; +import StatsChart from '@/components/StatsChart.astro'; import { getAllPosts, getAllTags } from '@/lib/posts'; const posts = await getAllPosts(); const tags = await getAllTags(); +const tagStats = tags.map((tag) => ({ + label: tag, + value: posts.filter((post) => post.tags.includes(tag)).length, + href: `/tags/${encodeURIComponent(tag)}/`, +})); ---
+ diff --git a/src/styles/site.css b/src/styles/site.css index aa5b6ec..bb9d87a 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -2154,6 +2154,184 @@ time { background: rgba(255, 253, 248, 0.9); } +.stats-chart { + margin-top: 24px; + border: 1px solid rgba(222, 216, 204, 0.78); + border-radius: 8px; + padding: 22px; + background: rgba(255, 253, 248, 0.88); + box-shadow: 0 14px 36px rgba(58, 51, 38, 0.08); +} + +.stats-chart-head { + display: grid; + gap: 6px; + margin-bottom: 18px; +} + +.stats-chart-head h2, +.stats-chart-head p { + margin: 0; +} + +.stats-chart-head h2 { + color: #27313a; + font-size: 1.18rem; + line-height: 1.25; +} + +.stats-chart-head p { + color: var(--muted); + font-size: 0.92rem; +} + +.stats-chart-body { + display: grid; + grid-template-columns: minmax(180px, 240px) 1fr; + align-items: center; + gap: 24px; +} + +.pie-chart { + width: 100%; + max-width: 240px; + margin: 0 auto; + overflow: visible; +} + +.pie-chart-track, +.pie-chart-segment { + fill: none; + stroke-width: 18; +} + +.pie-chart-track { + stroke: rgba(222, 216, 204, 0.52); +} + +.pie-chart-segment { + transform: rotate(-90deg); + transform-origin: 60px 60px; + transition: stroke-width 0.2s ease; +} + +.pie-chart-segment:hover { + stroke-width: 20; +} + +.pie-chart-total { + fill: #27313a; + font-size: 1.28rem; + font-weight: 800; +} + +.pie-chart-label { + fill: var(--muted); + font-size: 0.68rem; +} + +.chart-legend { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.chart-legend-item { + display: grid; + grid-template-columns: 12px minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + border: 1px solid rgba(222, 216, 204, 0.68); + border-radius: 8px; + padding: 10px 12px; + color: var(--text); + background: rgba(255, 255, 255, 0.58); +} + +a.chart-legend-item { + transition: + border-color 0.2s ease, + transform 0.2s ease, + box-shadow 0.2s ease; +} + +a.chart-legend-item:hover { + border-color: rgba(62, 184, 190, 0.42); + box-shadow: 0 10px 24px rgba(58, 51, 38, 0.08); + transform: translateY(-1px); +} + +.chart-legend-color { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--chart-color); +} + +.chart-legend-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chart-legend-item strong { + color: #27313a; + font-size: 1rem; +} + +.chart-legend-percent { + min-width: 38px; + color: var(--muted); + font-size: 0.84rem; + text-align: right; +} + +.line-chart-wrap { + width: 100%; + overflow-x: auto; +} + +.line-chart { + display: block; + width: 100%; + min-width: 520px; + height: 260px; +} + +.line-chart-axis { + stroke: rgba(104, 111, 120, 0.32); + stroke-width: 1.4; +} + +.line-chart-grid { + stroke: rgba(104, 111, 120, 0.16); + stroke-width: 1; +} + +.line-chart-area { + fill: rgba(62, 184, 190, 0.18); +} + +.line-chart-line { + fill: none; + stroke: #3eb8be; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 4; +} + +.line-chart-dot { + fill: #fffdf8; + stroke: #3eb8be; + stroke-width: 3; +} + +.line-chart-x-label, +.line-chart-y-label { + fill: var(--muted); + font-size: 0.78rem; +} + .shuoshuo-page { margin-top: 40px; } @@ -2909,6 +3087,7 @@ meting-js { .post-copyright-card, .twikoo-comments, .table-scroll, + .stats-chart, .term-grid a, .pagination-link, .pagination-number @@ -2932,6 +3111,9 @@ meting-js { .page-header-card h1, .content-wrap .page-header-card h1, .sidebar-card h2, + .stats-chart-head h2, + .chart-legend-item strong, + .pie-chart-total, .profile-name, .calendar-summary strong, .schedule-left strong, @@ -2958,6 +3140,19 @@ meting-js { color: var(--muted); } +:root[data-theme='dark'] .chart-legend-item { + border-color: rgba(174, 205, 197, 0.16); + background: rgba(255, 255, 255, 0.05); +} + +:root[data-theme='dark'] .pie-chart-track { + stroke: rgba(174, 205, 197, 0.16); +} + +:root[data-theme='dark'] .line-chart-dot { + fill: #112023; +} + :root[data-theme='dark'] .site-info-list div { border-top-color: rgba(174, 205, 197, 0.16); } @@ -3249,6 +3444,22 @@ meting-js { gap: 2px; } + .stats-chart { + padding: 18px; + } + + .stats-chart-body { + grid-template-columns: 1fr; + } + + .chart-legend { + grid-template-columns: 1fr; + } + + .line-chart { + min-width: 460px; + } + .article { margin-top: 32px; }