Browse Source

feat(component): add ArticleMetaBar with reading-time estimate and meta icons on article pages

main
Mohan 1 month ago
parent
commit
7522a9f809
  1. 19
      src/components/ArticleCard.vue
  2. 74
      src/components/ArticleMetaBar.vue
  3. 12
      src/utils/readingTime.ts
  4. 24
      src/views/ArticleDetailView.vue

19
src/components/ArticleCard.vue

@ -1,16 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ArticleListItem } from '@/generated/api' import type { ArticleListItem } from '@/generated/api'
import ArticleMetaBar from '@/components/ArticleMetaBar.vue'
const props = defineProps<{ const props = defineProps<{
article: ArticleListItem article: ArticleListItem
}>() }>()
function formatDate(iso: string): string {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', { hour12: false })
}
function coverSrc(): string | undefined { function coverSrc(): string | undefined {
const url = props.article.coverImage const url = props.article.coverImage
if (!url) return undefined if (!url) return undefined
@ -27,7 +22,11 @@ function coverSrc(): string | undefined {
<p v-if="article.summary" class="article-summary">{{ article.summary }}</p> <p v-if="article.summary" class="article-summary">{{ article.summary }}</p>
<div class="article-meta"> <div class="article-meta">
<span class="meta-author">{{ article.authorName }}</span> <span class="meta-author">{{ article.authorName }}</span>
<span class="meta-date">{{ formatDate(article.publishedAt || article.createdAt) }}</span>
<ArticleMetaBar
mode="card"
:created-at="article.publishedAt || article.createdAt"
:content="article.summary"
/>
</div> </div>
<div class="article-tags"> <div class="article-tags">
<span v-for="tag in article.tagNames" :key="tag" class="tag">{{ tag }}</span> <span v-for="tag in article.tagNames" :key="tag" class="tag">{{ tag }}</span>
@ -63,6 +62,8 @@ function coverSrc(): string | undefined {
.article-title { .article-title {
margin: 0 0 8px; margin: 0 0 8px;
font-family: 'Noto Serif SC', 'SimSun', 'STSong', serif;
font-weight: 600;
font-size: 18px; font-size: 18px;
color: var(--ac-text); color: var(--ac-text);
} }
@ -81,7 +82,7 @@ function coverSrc(): string | undefined {
} }
.meta-author { .meta-author {
color: var(--ac-primary);
color: var(--ac-primary-ink);
} }
.article-tags { .article-tags {
@ -93,7 +94,7 @@ function coverSrc(): string | undefined {
margin-right: 8px; margin-right: 8px;
padding: 2px 10px; padding: 2px 10px;
background: var(--ac-primary-bg); background: var(--ac-primary-bg);
color: var(--ac-primary-strong);
color: var(--ac-primary-ink);
border-radius: var(--song-radius-m); border-radius: var(--song-radius-m);
font-size: 12px; font-size: 12px;
} }

74
src/components/ArticleMetaBar.vue

@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue'
import { estimateReadingTime, formatDay } from '@/utils/readingTime'
const props = withDefaults(
defineProps<{
createdAt?: string
viewCount?: number
content?: string
mode?: 'card' | 'detail'
}>(),
{ createdAt: '', viewCount: 0, content: '', mode: 'card' }
)
const day = computed(() => (props.createdAt ? formatDay(props.createdAt) : ''))
const duration = computed(() => estimateReadingTime(props.content ?? ''))
const showView = computed(() => props.mode === 'detail')
</script>
<template>
<span class="meta-bar" :class="`meta-bar--${mode}`">
<span v-if="day" class="meta-item">
<svg class="meta-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="3" width="12" height="11" rx="1.5" />
<line x1="2" y1="7" x2="14" y2="7" />
<line x1="5.5" y1="1.5" x2="5.5" y2="4.5" />
<line x1="10.5" y1="1.5" x2="10.5" y2="4.5" />
</svg>
<span>{{ day }}</span>
</span>
<span v-if="showView" class="meta-item">
<svg class="meta-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M1.5 8C3 5.1 5.3 3.5 8 3.5S13 5.1 14.5 8C13 10.9 10.7 12.5 8 12.5S3 10.9 1.5 8Z" />
<circle cx="8" cy="8" r="2.1" />
</svg>
<span class="tnum">{{ viewCount }}</span>
</span>
<span v-if="duration" class="meta-item">
<svg class="meta-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="6" />
<path d="M8 5V8L10 9.2" />
</svg>
<span>{{ duration }}</span>
</span>
</span>
</template>
<style scoped>
.meta-bar {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 14px;
color: var(--ac-text-disabled);
font-size: 13px;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.meta-icon {
width: 14px;
height: 14px;
opacity: 0.85;
}
.meta-bar--card .meta-icon {
width: 13px;
height: 13px;
}
</style>

12
src/utils/readingTime.ts

@ -0,0 +1,12 @@
export function estimateReadingTime(content: string = ''): string {
const noWhitespace = content.replace(/\s/g, '').length
const minutes = Math.ceil(noWhitespace / 400)
return `${minutes} 分钟`
}
export function formatDay(iso: string): string {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}

24
src/views/ArticleDetailView.vue

@ -6,6 +6,7 @@ import { renderMarkdown } from '@/utils/markdown'
import { setPageMeta } from '@/utils/seo' import { setPageMeta } from '@/utils/seo'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import CommentSection from '@/components/CommentSection.vue' import CommentSection from '@/components/CommentSection.vue'
import ArticleMetaBar from '@/components/ArticleMetaBar.vue'
import type { ArticleDetail, ResponseArticleDetail } from '@/generated/api' import type { ArticleDetail, ResponseArticleDetail } from '@/generated/api'
const route = useRoute() const route = useRoute()
@ -22,13 +23,6 @@ const renderedContent = computed(() => {
return renderMarkdown(article.value.content) return renderMarkdown(article.value.content)
}) })
function formatDate(iso?: string): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', { hour12: false })
}
function applyMeta(data: ArticleDetail) { function applyMeta(data: ArticleDetail) {
setPageMeta({ setPageMeta({
title: data.title, title: data.title,
@ -59,8 +53,12 @@ watch(slug, load, { immediate: true })
<p v-if="article.summary" class="detail-summary">{{ article.summary }}</p> <p v-if="article.summary" class="detail-summary">{{ article.summary }}</p>
<div class="detail-meta"> <div class="detail-meta">
<span>作者{{ article.authorName }}</span> <span>作者{{ article.authorName }}</span>
<span>发布于{{ formatDate(article.publishedAt || article.createdAt) }}</span>
<span>浏览{{ article.viewCount }}</span>
<ArticleMetaBar
mode="detail"
:created-at="article.publishedAt || article.createdAt"
:view-count="article.viewCount"
:content="article.content"
/>
</div> </div>
<div class="detail-tags"> <div class="detail-tags">
<span v-for="tag in article.tagNames" :key="tag" class="tag">{{ tag }}</span> <span v-for="tag in article.tagNames" :key="tag" class="tag">{{ tag }}</span>
@ -79,11 +77,11 @@ watch(slug, load, { immediate: true })
} }
.detail-title { .detail-title {
font-family: var(--song-font-display);
font-weight: 400;
font-family: 'Noto Serif SC', 'SimSun', 'STSong', serif;
font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
margin: 0 0 12px; margin: 0 0 12px;
font-size: 26px;
font-size: 28px;
} }
.detail-summary { .detail-summary {
@ -108,7 +106,7 @@ watch(slug, load, { immediate: true })
margin-right: 8px; margin-right: 8px;
padding: 2px 10px; padding: 2px 10px;
background: var(--ac-primary-bg); background: var(--ac-primary-bg);
color: var(--ac-primary-strong);
color: var(--ac-primary-ink);
border-radius: var(--song-radius-m); border-radius: var(--song-radius-m);
font-size: 12px; font-size: 12px;
} }

Loading…
Cancel
Save