添加最近阅读的记录功能
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-08-12 17:51:28 +08:00
parent 3df57cfed6
commit 44b199798a
8 changed files with 153 additions and 29 deletions

View File

@@ -1,37 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink } from 'vue-router'
const year = new Date().getFullYear() const year = new Date().getFullYear()
const links = [
{ to: '/chanlun', label: '缠论 108 课' },
{ to: '/articles', label: '杂谈随笔' },
{ to: '/about', label: '关于本站' },
]
</script> </script>
<template> <template>
<footer class="border-t border-line bg-app"> <footer class="border-t border-line bg-app">
<div <div
class="mx-auto flex max-w-[1400px] flex-col items-center justify-between gap-4 px-4 py-8 sm:flex-row sm:px-6" class="mx-auto flex max-w-[1400px] flex-col items-center justify-center gap-4 px-4 py-8 sm:flex-row sm:px-6"
> >
<div class="flex items-center gap-2.5">
<div class="text-sm">
<span class="font-medium text-foreground">缠论研习社</span>
<span class="ml-2 text-subtle">专注缠论的系统性研习</span>
</div>
</div>
<nav class="flex items-center gap-5">
<RouterLink
v-for="l in links"
:key="l.to"
:to="l.to"
class="text-sm text-muted transition-colors hover:text-foreground"
>
{{ l.label }}
</RouterLink>
</nav>
<p class="text-xs text-subtle"> <p class="text-xs text-subtle">
© {{ year }} 缠论研习社 · 内容仅供学习研究 © {{ year }} 缠论研习社 · 内容仅供学习研究

View File

@@ -4,6 +4,7 @@ import App from './App.vue'
import router from './router' import router from './router'
import { useThemeStore } from './stores/theme' import { useThemeStore } from './stores/theme'
import { useReadingStore } from './stores/reading' import { useReadingStore } from './stores/reading'
import { useRecentsStore } from './stores/recents'
import 'remixicon/fonts/remixicon.css' import 'remixicon/fonts/remixicon.css'
import './style.css' import './style.css'
@@ -11,8 +12,9 @@ const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
// 在挂载前初始化持久化状态(主题、阅读偏好) // 在挂载前初始化持久化状态(主题、阅读偏好、最近阅读
useThemeStore().init() useThemeStore().init()
useReadingStore().init() useReadingStore().init()
useRecentsStore().init()
app.mount('#app') app.mount('#app')

50
src/stores/recents.ts Normal file
View File

@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ArticleCategory } from '@/types/article'
/** 最近阅读记录(持久化到 localStorage供首页 / 目录页快速跳转) */
export interface RecentEntry {
category: ArticleCategory
slug: string
/** 访问时间戳ms */
visitedAt: number
}
const STORAGE_KEY = 'chan-recents'
/**
* 最近阅读:只记最近一次打开的文章。
* 仅缠论课程会写入(由调用方 ArticleReaderView 按分类过滤),所以展示时直接取 latest。
*/
export const useRecentsStore = defineStore('recents', () => {
/** 最近一次打开的文章;首次访问或被清空时为 null */
const latest = ref<RecentEntry | null>(null)
function init() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) latest.value = JSON.parse(raw) as RecentEntry
} catch {
latest.value = null
}
}
function persist() {
if (latest.value) localStorage.setItem(STORAGE_KEY, JSON.stringify(latest.value))
else localStorage.removeItem(STORAGE_KEY)
}
/** 记录一次访问(调用方负责仅写入缠论分类) */
function visit(category: ArticleCategory, slug: string) {
if (!category || !slug) return
latest.value = { category, slug, visitedAt: Date.now() }
persist()
}
function clear() {
latest.value = null
persist()
}
return { latest, init, visit, clear }
})

View File

@@ -146,6 +146,18 @@
transition: background-color 0.3s var(--ease-soft), color 0.3s var(--ease-soft); transition: background-color 0.3s var(--ease-soft), color 0.3s var(--ease-soft);
} }
/* 按钮与可点击元素:悬停统一为手型,禁用时为禁止符
(原生 <button> 默认光标是 default此处兜底为 pointer */
button:not(:disabled),
[role="button"]:not([disabled]) {
cursor: pointer;
}
button:disabled,
[role="button"][disabled] {
cursor: not-allowed;
}
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: -0.01em;

View File

@@ -25,3 +25,20 @@ export function formatDateShort(iso: string): string {
const day = String(d.getDate()).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0')
return `${m}-${day}` return `${m}-${day}`
} }
/** 相对时间:刚刚 / N 分钟前 / N 小时前 / 昨天 / N 天前 / M月D日 */
export function formatRelativeTime(timestamp: number): string {
const diff = Date.now() - timestamp
if (diff < 0) return '刚刚'
const min = 60_000
const hour = 3_600_000
const day = 86_400_000
if (diff < min) return '刚刚'
if (diff < hour) return `${Math.max(1, Math.round(diff / min))} 分钟前`
if (diff < day) return `${Math.max(1, Math.round(diff / hour))} 小时前`
const days = Math.floor(diff / day)
if (days === 1) return '昨天'
if (diff < 7 * day) return `${days} 天前`
const d = new Date(timestamp)
return `${d.getMonth() + 1}${d.getDate()}`
}

View File

@@ -6,12 +6,14 @@ import type { ArticleCategory, ArticleMeta } from '@/types/article'
import { useArticles } from '@/composables/useArticles' import { useArticles } from '@/composables/useArticles'
import { renderMarkdown } from '@/composables/useMarkdown' import { renderMarkdown } from '@/composables/useMarkdown'
import { useReadingStore } from '@/stores/reading' import { useReadingStore } from '@/stores/reading'
import { useRecentsStore } from '@/stores/recents'
import { cn, formatDate } from '@/utils/cn' import { cn, formatDate } from '@/utils/cn'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
const route = useRoute() const route = useRoute()
const { find, neighbors } = useArticles() const { find, neighbors } = useArticles()
const reading = useReadingStore() const reading = useReadingStore()
const recents = useRecentsStore()
const article = computed(() => const article = computed(() =>
find(route.params.category as ArticleCategory, route.params.slug as string), find(route.params.category as ArticleCategory, route.params.slug as string),
@@ -43,6 +45,15 @@ function readerRoute(meta: ArticleMeta) {
return { name: 'reader' as const, params: { category: meta.category, slug: meta.slug } } return { name: 'reader' as const, params: { category: meta.category, slug: meta.slug } }
} }
// 记录最近阅读的缠论课程(仅缠论分类),供目录页「最近阅读」快速跳转
watch(
article,
(a) => {
if (a && a.category === 'chanlun') recents.visit(a.category, a.slug)
},
{ immediate: true },
)
// 切换文章时滚回顶部 // 切换文章时滚回顶部
watch( watch(
() => route.params.slug, () => route.params.slug,

View File

@@ -1,13 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { BookOpen, GraduationCap, SearchX } from 'lucide-vue-next' import { RouterLink } from 'vue-router'
import { ArrowRight, BookOpen, GraduationCap, History, SearchX } from 'lucide-vue-next'
import { useArticles } from '@/composables/useArticles' import { useArticles } from '@/composables/useArticles'
import { useRecentsStore } from '@/stores/recents'
import { categories } from '@/data/categories' import { categories } from '@/data/categories'
import { formatRelativeTime } from '@/utils/cn'
import ArticleCard from '@/components/ArticleCard.vue' import ArticleCard from '@/components/ArticleCard.vue'
import Badge from '@/components/ui/Badge.vue'
import SearchInput from '@/components/ui/SearchInput.vue' import SearchInput from '@/components/ui/SearchInput.vue'
import BaseButton from '@/components/ui/BaseButton.vue' import BaseButton from '@/components/ui/BaseButton.vue'
import type { ArticleMeta } from '@/types/article'
const { chanlun, search } = useArticles() const { chanlun, find, search } = useArticles()
const recents = useRecentsStore()
const query = ref('') const query = ref('')
@@ -20,6 +26,21 @@ const filtered = computed(() =>
function clearQuery() { function clearQuery() {
query.value = '' query.value = ''
} }
// 最近阅读:解析上次打开的缠论课程,供快速跳转
const recentArticle = computed<ArticleMeta | undefined>(() => {
const entry = recents.latest
if (!entry) return undefined
return find(entry.category, entry.slug)
})
const recentRelative = computed(() =>
recents.latest ? formatRelativeTime(recents.latest.visitedAt) : '',
)
const recentRoute = computed(() => {
const a = recentArticle.value
if (!a) return { name: 'chanlun' as const }
return { name: 'reader' as const, params: { category: a.category, slug: a.slug } }
})
</script> </script>
<template> <template>
@@ -40,6 +61,41 @@ function clearQuery() {
</p> </p>
</header> </header>
<!-- 最近阅读快速回到上次阅读的缠论课程 -->
<section v-if="recentArticle" class="recent-read mt-6">
<RouterLink
:to="recentRoute"
class="recent-read__link group flex items-center gap-4 rounded-lg border border-line bg-background p-4 transition-[box-shadow,border-color,transform] duration-200 hover:-translate-y-0.5 hover:border-line-strong hover:shadow-md active:scale-[0.99] sm:p-5"
>
<div
class="recent-read__icon flex size-11 shrink-0 items-center justify-center rounded-md bg-primary-soft text-primary"
>
<History class="size-5" />
</div>
<div class="recent-read__body min-w-0 flex-1">
<div class="recent-read__meta flex items-center gap-2 text-xs">
<span class="font-medium text-primary">最近阅读</span>
<span aria-hidden="true" class="text-faint">·</span>
<span class="text-subtle">{{ recentRelative }}</span>
</div>
<div class="recent-read__title mt-1.5 flex min-w-0 items-center gap-2">
<Badge v-if="recentArticle?.lesson" tone="primary">
{{ recentArticle.lesson }}
</Badge>
<h2
class="min-w-0 truncate font-serif text-lg font-semibold text-foreground transition-colors group-hover:text-primary"
>
{{ recentArticle?.title }}
</h2>
</div>
<p class="recent-read__hint mt-0.5 text-xs text-muted">点击继续阅读</p>
</div>
<ArrowRight
class="recent-read__arrow hidden size-5 shrink-0 text-faint transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-muted sm:block"
/>
</RouterLink>
</section>
<!-- 学习路径提示 --> <!-- 学习路径提示 -->
<section <section
class="mt-6 flex items-start gap-3 rounded-lg border border-line bg-primary-soft p-4" class="mt-6 flex items-start gap-3 rounded-lg border border-line bg-primary-soft p-4"

View File

@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/composables/usearticles.ts","./src/composables/usemarkdown.ts","./src/data/categories.ts","./src/data/chanlun.sample.ts","./src/data/chanlun.ts","./src/data/essays.ts","./src/router/index.ts","./src/stores/reading.ts","./src/stores/theme.ts","./src/types/article.ts","./src/utils/cn.ts","./src/utils/slug.ts","./src/app.vue","./src/components/articlecard.vue","./src/components/readersettings.vue","./src/components/readingprogress.vue","./src/components/themetoggle.vue","./src/components/layout/appfooter.vue","./src/components/layout/appheader.vue","./src/components/layout/appsidebar.vue","./src/components/ui/badge.vue","./src/components/ui/basebutton.vue","./src/components/ui/card.vue","./src/components/ui/searchinput.vue","./src/layouts/defaultlayout.vue","./src/layouts/readerlayout.vue","./src/views/aboutview.vue","./src/views/articlereaderview.vue","./src/views/chanlunlistview.vue","./src/views/homeview.vue","./src/views/otherarticlesview.vue","./env.d.ts"],"version":"5.7.2"} {"root":["./src/main.ts","./src/vite-env.d.ts","./src/composables/usearticles.ts","./src/composables/usemarkdown.ts","./src/data/categories.ts","./src/data/chanlun.sample.ts","./src/data/chanlun.ts","./src/data/essays.ts","./src/router/index.ts","./src/stores/reading.ts","./src/stores/recents.ts","./src/stores/theme.ts","./src/types/article.ts","./src/utils/cn.ts","./src/utils/slug.ts","./src/app.vue","./src/components/articlecard.vue","./src/components/readersettings.vue","./src/components/readingprogress.vue","./src/components/themetoggle.vue","./src/components/layout/appfooter.vue","./src/components/layout/appheader.vue","./src/components/ui/badge.vue","./src/components/ui/basebutton.vue","./src/components/ui/card.vue","./src/components/ui/searchinput.vue","./src/layouts/defaultlayout.vue","./src/layouts/readerlayout.vue","./src/views/aboutview.vue","./src/views/articlereaderview.vue","./src/views/chanlunlistview.vue","./src/views/homeview.vue","./src/views/otherarticlesview.vue","./env.d.ts"],"version":"5.7.2"}