This commit is contained in:
@@ -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 }} 缠论研习社 · 内容仅供学习研究
|
||||||
|
|||||||
@@ -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
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);
|
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;
|
||||||
|
|||||||
@@ -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()}日`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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