laobinghu ce208df092 feat: 实现 Nuxt4 + Nuxt UI 博客前端完整功能
核心功能:
- 项目初始化 (Nuxt 4 + Nuxt UI + Pinia + ofetch)
- TypeScript 类型定义 (User, Article, Comment, API 响应)
- 认证系统 (登录/登出、Cookie 支持、权限中间件)
- 文章列表页 (筛选、分页、响应式布局)
- 文章详情页 (Markdown 渲染、评论系统)
- 文章编辑器 (左右分栏、实时预览、Markdown 工具栏)

管理后台:
- 侧边栏布局、权限检查
- 数据分析 (数据统计卡片、热门文章、评论审核统计)
- 文章管理 (表格、筛选、删除)
- 评论管理 (审核通过/拒绝、删除)
- 用户管理 (角色管理、删除)

全局组件:
- 导航栏 (暗色模式切换、移动端菜单)
- 页脚
- 403/404 错误页

配置文件:
- .env.example 环境变量模板
- nuxt.config.ts 完整配置
- 自定义 CSS 样式
2026-03-28 15:56:50 +08:00

155 lines
4.3 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="loading" class="min-h-screen">
<USkeleton class="h-10 w-3/4 mb-4" />
<USkeleton class="h-6 w-1/2 mb-8" />
<USkeleton class="h-64 w-full mb-8" />
<USkeleton class="h-4 w-full mb-2" />
<USkeleton class="h-4 w-full mb-2" />
<USkeleton class="h-4 w-2/3" />
</div>
<div v-else-if="!article" class="text-center py-16">
<p class="text-gray-600 dark:text-gray-400">文章不存在</p>
<UButton to="/" color="indigo" class="mt-4">返回首页</UButton>
</div>
<div v-else class="max-w-4xl mx-auto">
<article>
<header class="mb-8">
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ article.title }}
</h1>
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex items-center space-x-4">
<UAvatar
:src="article.author.avatarUrl"
:alt="article.author.displayName || article.author.username"
size="md"
/>
<div>
<p class="font-medium text-gray-900 dark:text-white">
{{ article.author.displayName || article.author.username }}
</p>
<time
:datetime="article.createdAt"
class="text-sm text-gray-500 dark:text-gray-400"
>
{{ formatDate(article.createdAt) }}
</time>
</div>
</div>
<div class="flex items-center space-x-4 text-gray-500 dark:text-gray-400">
<span class="flex items-center">
<UIcon name="i-heroicons-eye" class="w-5 h-5 mr-1" />
{{ article.viewCount }}
</span>
<UBadge
:label="article.status"
:color="article.status === 'published' ? 'green' : 'gray'"
variant="subtle"
/>
</div>
</div>
</header>
<img
v-if="article.coverImageUrl"
:src="article.coverImageUrl"
:alt="article.title"
class="w-full h-96 object-cover rounded-lg mb-8"
/>
<div class="prose prose-indigo dark:prose-invert max-w-none mb-8">
<div v-html="renderedContent" />
</div>
<footer class="border-t border-gray-200 dark:border-gray-700 pt-8">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
最后更新{{ formatDate(article.updatedAt) }}
</span>
</div>
<div class="flex items-center space-x-2">
<UButton
variant="ghost"
icon="i-heroicons-share"
size="sm"
@click="copyLink"
>
分享
</UButton>
</div>
</div>
</footer>
</article>
<section class="mt-12">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">
评论
</h2>
<CommentList :article-id="article.id" />
</section>
</div>
</template>
<script setup lang="ts">
import { marked } from 'marked'
const route = useRoute()
const { fetchArticleBySlug } = useArticles()
const article = ref<any>(null)
const loading = ref(true)
const slug = route.params.slug as string
const renderedContent = computed(() => {
if (!article.value?.content) return ''
return marked.parse(article.value.content, {
breaks: true,
gfm: true,
})
})
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const copyLink = () => {
const url = window.location.href
navigator.clipboard.writeText(url)
// TODO: 显示提示
}
const loadArticle = async () => {
loading.value = true
try {
article.value = await fetchArticleBySlug(slug)
} catch (error) {
console.error('Failed to load article:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
loadArticle()
})
useSeoMeta({
title: () => article.value ? `${article.value.title} - LinkShare Blog` : '文章详情',
description: () => article.value?.excerpt || '',
ogTitle: () => article.value?.title || '',
ogDescription: () => article.value?.excerpt || '',
ogType: 'article',
})
</script>