chore: 初始化项目仓库
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { siteConfig } from '~/config/site'
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const container = ref<HTMLElement>()
|
||||
|
||||
function loadGiscus() {
|
||||
if (!container.value) return
|
||||
container.value.innerHTML = ''
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://giscus.app/client.js'
|
||||
script.setAttribute('data-repo', siteConfig.giscus.repo)
|
||||
script.setAttribute('data-repo-id', siteConfig.giscus.repoId)
|
||||
script.setAttribute('data-category', siteConfig.giscus.category)
|
||||
script.setAttribute('data-category-id', siteConfig.giscus.categoryId)
|
||||
script.setAttribute('data-mapping', 'pathname')
|
||||
script.setAttribute('data-strict', '0')
|
||||
script.setAttribute('data-reactions-enabled', '1')
|
||||
script.setAttribute('data-emit-metadata', '0')
|
||||
script.setAttribute('data-input-position', 'bottom')
|
||||
script.setAttribute('data-theme', colorMode.value === 'dark' ? 'dark' : 'light')
|
||||
script.setAttribute('data-lang', 'zh-CN')
|
||||
script.setAttribute('data-loading', 'lazy')
|
||||
script.setAttribute('crossorigin', 'anonymous')
|
||||
script.async = true
|
||||
|
||||
container.value.appendChild(script)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGiscus()
|
||||
})
|
||||
|
||||
watch(() => colorMode.value, () => {
|
||||
const frame = document.querySelector<HTMLIFrameElement>('iframe.giscus-frame')
|
||||
if (frame?.contentWindow) {
|
||||
frame.contentWindow.postMessage(
|
||||
{ giscus: { setConfig: { theme: colorMode.value === 'dark' ? 'dark' : 'light' } } },
|
||||
'https://giscus.app'
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-4">评论</h2>
|
||||
<div ref="container" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
post: any
|
||||
matchedLines?: string[]
|
||||
highlight?: (text: string) => string
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
function formatDate(date: string) {
|
||||
return new Date(date).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function getWordCount(post: any): number {
|
||||
const text = JSON.stringify(post.body || '').replace(/<[^>]*>/g, '')
|
||||
const chinese = text.match(/[一-龥]/g) || []
|
||||
const english = text.match(/[a-zA-Z]+/g) || []
|
||||
return chinese.length + english.length
|
||||
}
|
||||
|
||||
function getReadTime(count: number): number {
|
||||
return Math.max(1, Math.ceil(count / 300))
|
||||
}
|
||||
|
||||
const wordCount = computed(() => getWordCount(props.post))
|
||||
const readTime = computed(() => getReadTime(wordCount.value))
|
||||
|
||||
function renderTitle(): string {
|
||||
const text = props.post.title || '无标题'
|
||||
return props.highlight ? props.highlight(text) : text
|
||||
}
|
||||
|
||||
function renderDesc(): string {
|
||||
if (!props.post.description) return ''
|
||||
return props.highlight ? props.highlight(props.post.description) : props.post.description
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="post.path" class="block">
|
||||
<div class="group transition-all hover:shadow-lg rounded-2xl py-6 overflow-hidden" style="border: 1px solid var(--color-border);">
|
||||
<div class="px-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row">
|
||||
<div v-if="post.image" class="md:w-48 md:flex-shrink-0">
|
||||
<img
|
||||
:src="post.image"
|
||||
:alt="post.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-48 w-full rounded-md object-cover md:h-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-0">
|
||||
<span
|
||||
v-if="post.pinned"
|
||||
class="shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style="background: var(--color-text); color: var(--color-bg);"
|
||||
>
|
||||
置顶
|
||||
</span>
|
||||
<time v-if="post.published" class="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{{ formatDate(post.published) }}
|
||||
</time>
|
||||
<span class="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
|
||||
· {{ wordCount }} 字
|
||||
</span>
|
||||
<span class="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
|
||||
· 约 {{ readTime }} 分钟
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="mb-2 text-2xl font-semibold group-hover:opacity-70 transition-opacity"
|
||||
v-html="renderTitle()"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="post.description"
|
||||
class="text-muted-foreground"
|
||||
v-html="renderDesc()"
|
||||
/>
|
||||
|
||||
<div v-if="matchedLines && matchedLines.length > 0" class="mt-3 space-y-1.5">
|
||||
<div
|
||||
v-for="(line, idx) in (expanded ? matchedLines : matchedLines.slice(0, 3))"
|
||||
:key="idx"
|
||||
class="text-sm text-muted-foreground border-l-2 pl-3"
|
||||
style="border-color: var(--color-border);"
|
||||
v-html="highlight ? highlight(line) : line"
|
||||
/>
|
||||
<button
|
||||
v-if="matchedLines.length > 3"
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:opacity-70 transition-opacity mt-1"
|
||||
@click.prevent.stop="expanded = !expanded"
|
||||
>
|
||||
{{ expanded ? '收起' : `展开 (还有 ${matchedLines.length - 3} 条)` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { getHeadingsFromBody } from '~/utils/content-ast'
|
||||
import type { Heading } from '~/utils/content-ast'
|
||||
|
||||
const props = defineProps<{
|
||||
body?: Record<string, any> | null
|
||||
}>()
|
||||
|
||||
const headings = ref<Heading[]>([])
|
||||
const activeId = ref('')
|
||||
let observer: IntersectionObserver | undefined
|
||||
|
||||
const NAV_HEIGHT = 40
|
||||
|
||||
const minLevel = computed(() => {
|
||||
if (!headings.value.length) return 1
|
||||
return Math.min(...headings.value.map((h) => h.level))
|
||||
})
|
||||
|
||||
function indentClass(level: number): string {
|
||||
const depth = Math.max(0, level - minLevel.value)
|
||||
return ['pl-3', 'pl-6', 'pl-9', 'pl-12', 'pl-14', 'pl-16'][depth] || 'pl-16'
|
||||
}
|
||||
|
||||
function buildHeadings() {
|
||||
headings.value = getHeadingsFromBody(props.body)
|
||||
}
|
||||
|
||||
function startObserver() {
|
||||
observer?.disconnect()
|
||||
if (!headings.value.length) return
|
||||
nextTick(() => {
|
||||
const els = headings.value
|
||||
.map((h) => document.getElementById(h.id))
|
||||
.filter(Boolean) as HTMLElement[]
|
||||
if (!els.length) return
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||
if (visible.length > 0) {
|
||||
activeId.value = (visible[0].target as HTMLElement).id
|
||||
}
|
||||
},
|
||||
{ rootMargin: `${-(NAV_HEIGHT + 24)}px 0px -60% 0px`, threshold: 0 }
|
||||
)
|
||||
for (const el of els) observer.observe(el)
|
||||
const firstVisible = els.find((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.bottom > NAV_HEIGHT + 24
|
||||
})
|
||||
activeId.value = (firstVisible || els[0]).id
|
||||
})
|
||||
}
|
||||
|
||||
buildHeadings()
|
||||
|
||||
onMounted(() => { startObserver() })
|
||||
|
||||
watch(() => props.body, () => {
|
||||
buildHeadings()
|
||||
if (headings.value.length) startObserver()
|
||||
}, { deep: true })
|
||||
|
||||
onUnmounted(() => { observer?.disconnect() })
|
||||
|
||||
function handleClick(e: MouseEvent, id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
e.preventDefault()
|
||||
activeId.value = id
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - (NAV_HEIGHT + 16)
|
||||
window.scrollTo({ top, behavior: 'instant' })
|
||||
history.replaceState(null, '', `#${id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
v-if="headings.length > 0"
|
||||
class="hidden xl:block fixed top-24 right-[max(1rem,calc((100vw-48rem)/2-18rem))] w-56 max-h-[calc(100vh-8rem)] overflow-y-auto pr-2 text-sm z-30"
|
||||
aria-label="目录"
|
||||
>
|
||||
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">目录</div>
|
||||
<ul class="space-y-1 border-l border-border">
|
||||
<li v-for="h in headings" :key="h.id">
|
||||
<a
|
||||
:href="`#${h.id}`"
|
||||
class="block py-1 pr-2 border-l-2 -ml-px transition-colors"
|
||||
:class="[
|
||||
indentClass(h.level),
|
||||
activeId === h.id
|
||||
? 'border-primary text-foreground font-medium'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/40',
|
||||
]"
|
||||
@click="handleClick($event, h.id)"
|
||||
>
|
||||
{{ h.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
src?: string
|
||||
alt?: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const resolvedSrc = computed(() => {
|
||||
if (!props.src) return ''
|
||||
// 绝对路径直接用
|
||||
if (props.src.startsWith('/') || props.src.startsWith('http')) return props.src
|
||||
// 相对路径:基于当前路由拼接
|
||||
const base = route.path.replace(/\/$/, '')
|
||||
return `${base}/${props.src}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="resolvedSrc" :alt="alt" loading="lazy" decoding="async" />
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { siteConfig } from '~/config/site'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="py-6 text-center text-sm border-t" style="color: var(--color-text-secondary); border-color: var(--color-border);">
|
||||
<p>
|
||||
© {{ new Date().getFullYear() }} {{ siteConfig.siteName }}
|
||||
·
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" class="hover:opacity-70" style="border-bottom: 1px solid var(--color-text-secondary); padding-bottom: 1px;">蜀ICP备2026028044号</a>
|
||||
</p>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { siteConfig } from '~/config/site'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const path = route.path
|
||||
if (path === '/') return []
|
||||
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
const result: Array<{ label: string; path: string }> = []
|
||||
|
||||
let currentPath = ''
|
||||
for (const part of parts) {
|
||||
currentPath += `/${part}`
|
||||
result.push({
|
||||
label: part,
|
||||
path: currentPath,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="sticky top-0 z-40 w-full backdrop-blur-sm border-b"
|
||||
style="background-color: color-mix(in srgb, var(--color-bg) 80%, transparent); border-color: var(--color-border);">
|
||||
<div class="relative flex h-10 items-center justify-between px-2">
|
||||
<!-- 最左边:头像 + 面包屑 -->
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<NuxtLink to="/" class="shrink-0 hover:opacity-80 transition-opacity">
|
||||
<img
|
||||
:src="siteConfig.avatar"
|
||||
:alt="siteConfig.siteName"
|
||||
class="h-6 w-6 rounded-full nav-avatar"
|
||||
/>
|
||||
</NuxtLink>
|
||||
<template v-for="(crumb, i) in breadcrumbs" :key="crumb.path">
|
||||
<span class="shrink-0 text-muted-foreground/40">/</span>
|
||||
<NuxtLink
|
||||
:to="crumb.path"
|
||||
class="text-xs font-medium truncate shrink min-w-0 hover:opacity-80 transition-opacity"
|
||||
:class="i === breadcrumbs.length - 1 ? 'text-foreground' : 'text-muted-foreground'"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 最右边:主题切换 -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<LayoutThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.nav-avatar {
|
||||
animation: spin 2s linear infinite;
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.nav-avatar:hover {
|
||||
animation-play-state: running;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const modes = ['light', 'dark', 'system'] as const
|
||||
type Mode = typeof modes[number]
|
||||
|
||||
const iconMap: Record<Mode, string> = {
|
||||
light: 'mdi:weather-sunny',
|
||||
dark: 'mdi:weather-night',
|
||||
system: 'mdi:monitor',
|
||||
}
|
||||
|
||||
const labelMap: Record<Mode, string> = {
|
||||
light: '浅色模式',
|
||||
dark: '深色模式',
|
||||
system: '跟随系统',
|
||||
}
|
||||
|
||||
const currentMode = computed(() => colorMode.preference as Mode)
|
||||
const currentIcon = computed(() => iconMap[currentMode.value] || iconMap.system)
|
||||
const currentLabel = computed(() => labelMap[currentMode.value] || labelMap.system)
|
||||
|
||||
function cycle() {
|
||||
const idx = modes.indexOf(currentMode.value)
|
||||
colorMode.preference = modes[(idx + 1) % modes.length]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
@click="cycle"
|
||||
class="inline-flex items-center justify-center rounded-md w-8 h-8 transition-colors"
|
||||
style="color: var(--color-text-secondary)"
|
||||
:class="[
|
||||
'hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]',
|
||||
]"
|
||||
:aria-label="currentLabel"
|
||||
:title="currentLabel"
|
||||
>
|
||||
<Icon :icon="currentIcon" class="w-5 h-5" />
|
||||
</button>
|
||||
</template>
|
||||
Reference in New Issue
Block a user