chore: 初始化项目仓库

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 21:27:39 +08:00
commit b7addb5c7a
50 changed files with 15964 additions and 0 deletions
+51
View File
@@ -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>
+111
View File
@@ -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>
+104
View File
@@ -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>