核心功能: - 项目初始化 (Nuxt 4 + Nuxt UI + Pinia + ofetch) - TypeScript 类型定义 (User, Article, Comment, API 响应) - 认证系统 (登录/登出、Cookie 支持、权限中间件) - 文章列表页 (筛选、分页、响应式布局) - 文章详情页 (Markdown 渲染、评论系统) - 文章编辑器 (左右分栏、实时预览、Markdown 工具栏) 管理后台: - 侧边栏布局、权限检查 - 数据分析 (数据统计卡片、热门文章、评论审核统计) - 文章管理 (表格、筛选、删除) - 评论管理 (审核通过/拒绝、删除) - 用户管理 (角色管理、删除) 全局组件: - 导航栏 (暗色模式切换、移动端菜单) - 页脚 - 403/404 错误页 配置文件: - .env.example 环境变量模板 - nuxt.config.ts 完整配置 - 自定义 CSS 样式
155 lines
4.3 KiB
Vue
155 lines
4.3 KiB
Vue
<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>
|