This commit is contained in:
@@ -1,37 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
const links = [
|
||||
{ to: '/chanlun', label: '缠论 108 课' },
|
||||
{ to: '/articles', label: '杂谈随笔' },
|
||||
{ to: '/about', label: '关于本站' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="border-t border-line bg-app">
|
||||
<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">
|
||||
© {{ year }} 缠论研习社 · 内容仅供学习研究
|
||||
|
||||
@@ -4,6 +4,7 @@ import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
import { useReadingStore } from './stores/reading'
|
||||
import { useRecentsStore } from './stores/recents'
|
||||
import 'remixicon/fonts/remixicon.css'
|
||||
import './style.css'
|
||||
|
||||
@@ -11,8 +12,9 @@ const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
// 在挂载前初始化持久化状态(主题、阅读偏好)
|
||||
// 在挂载前初始化持久化状态(主题、阅读偏好、最近阅读)
|
||||
useThemeStore().init()
|
||||
useReadingStore().init()
|
||||
useRecentsStore().init()
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
50
src/stores/recents.ts
Normal file
50
src/stores/recents.ts
Normal 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 }
|
||||
})
|
||||
@@ -146,6 +146,18 @@
|
||||
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 {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
|
||||
@@ -25,3 +25,20 @@ export function formatDateShort(iso: string): string {
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
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()}日`
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import type { ArticleCategory, ArticleMeta } from '@/types/article'
|
||||
import { useArticles } from '@/composables/useArticles'
|
||||
import { renderMarkdown } from '@/composables/useMarkdown'
|
||||
import { useReadingStore } from '@/stores/reading'
|
||||
import { useRecentsStore } from '@/stores/recents'
|
||||
import { cn, formatDate } from '@/utils/cn'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { find, neighbors } = useArticles()
|
||||
const reading = useReadingStore()
|
||||
const recents = useRecentsStore()
|
||||
|
||||
const article = computed(() =>
|
||||
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 } }
|
||||
}
|
||||
|
||||
// 记录最近阅读的缠论课程(仅缠论分类),供目录页「最近阅读」快速跳转
|
||||
watch(
|
||||
article,
|
||||
(a) => {
|
||||
if (a && a.category === 'chanlun') recents.visit(a.category, a.slug)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 切换文章时滚回顶部
|
||||
watch(
|
||||
() => route.params.slug,
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
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 { useRecentsStore } from '@/stores/recents'
|
||||
import { categories } from '@/data/categories'
|
||||
import { formatRelativeTime } from '@/utils/cn'
|
||||
import ArticleCard from '@/components/ArticleCard.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import SearchInput from '@/components/ui/SearchInput.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('')
|
||||
|
||||
@@ -20,6 +26,21 @@ const filtered = computed(() =>
|
||||
function clearQuery() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -40,6 +61,41 @@ function clearQuery() {
|
||||
</p>
|
||||
</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
|
||||
class="mt-6 flex items-start gap-3 rounded-lg border border-line bg-primary-soft p-4"
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user