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

164 lines
5.0 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>
<h1 class="text-2xl font-bold mb-6">{{ isEdit ? '编辑文章' : '写文章' }}</h1>
<UForm :state="form" @submit="onSubmit" class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 编辑区 -->
<div class="space-y-4">
<UFormField label="标题" name="title" required>
<UInput v-model="form.title" placeholder="请输入文章标题" class="w-full" />
</UFormField>
<UFormField label="Slug" name="slug">
<UInput v-model="form.slug" placeholder="自动生成" class="w-full" />
</UFormField>
<UFormField label="摘要" name="excerpt">
<UTextarea v-model="form.excerpt" placeholder="请输入文章摘要(可选)" class="w-full" />
</UFormField>
<UFormField label="封面图 URL" name="coverImageUrl">
<UInput v-model="form.coverImageUrl" placeholder="输入封面图 URL可选" class="w-full" />
</UFormField>
<MarkdownToolbar @insert="insertMarkdown" />
<UFormField label="内容" name="content" required>
<UTextarea
v-model="form.content"
:minrows="15"
:maxrows="30"
placeholder="在此输入 Markdown 内容..."
class="w-full font-mono"
ref="contentRef"
/>
</UFormField>
<div class="grid grid-cols-2 gap-4">
<UFormField label="状态" name="status">
<USelect v-model="form.status" :options="statusOptions" class="w-full" />
</UFormField>
<UFormField label="可见性" name="visibility">
<USelect v-model="form.visibility" :options="visibilityOptions" class="w-full" />
</UFormField>
</div>
<div class="flex gap-4">
<UButton type="submit" color="indigo" :loading="saving">
{{ isEdit ? '更新' : '发布' }}
</UButton>
<UButton variant="outline" @click="$router.back()">
取消
</UButton>
</div>
</div>
<!-- 预览区 -->
<div class="space-y-4">
<h3 class="font-medium text-gray-700 dark:text-gray-300">实时预览</h3>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-[600px] max-h-[800px] overflow-auto">
<div v-if="form.content" class="prose prose-indigo dark:prose-invert max-w-none" v-html="renderedContent" />
<div v-else class="text-gray-400 text-center py-12">
<UIcon name="i-heroicons-eye" class="w-12 h-12 mx-auto mb-4" />
<p>实时预览内容</p>
</div>
</div>
</div>
</div>
</UForm>
</div>
</template>
<script setup lang="ts">
import { marked } from 'marked'
definePageMeta({
middleware: ['auth'],
})
const route = useRoute()
const router = useRouter()
const { post, put } = useApi()
const isEdit = computed(() => !!route.params.id)
const contentRef = ref<HTMLTextAreaElement | null>(null)
const form = ref({
title: '',
slug: '',
content: '',
excerpt: '',
coverImageUrl: '',
status: 'draft' as const,
visibility: 'public' as const,
})
const saving = ref(false)
const statusOptions = [
{ label: '草稿', value: 'draft' },
{ label: '已发布', value: 'published' },
{ label: '归档', value: 'archived' },
]
const visibilityOptions = [
{ label: '公开', value: 'public' },
{ label: '未列出', value: 'unlisted' },
{ label: '私密', value: 'private' },
]
const renderedContent = computed(() => {
return marked.parse(form.value.content || '', { breaks: true, gfm: true })
})
const insertMarkdown = (before: string, after: string) => {
const textarea = contentRef.value?.querySelector('textarea')
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const text = form.value.content
const selected = text.substring(start, end)
form.value.content = text.substring(0, start) + before + selected + after + text.substring(end)
setTimeout(() => {
textarea.focus()
const newCursor = start + before.length + selected.length
textarea.setSelectionRange(newCursor, newCursor)
}, 0)
}
const onSubmit = async () => {
if (!form.value.title.trim() || !form.value.content.trim()) return
saving.value = true
try {
const data = { ...form.value }
if (!data.slug) {
data.slug = data.title.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
}
let article
if (isEdit.value) {
article = await put(`/articles/${route.params.id}`, data)
} else {
article = await post('/articles', data)
}
router.push(`/article/${article.slug}`)
} catch (error) {
console.error('Failed to save article:', error)
// TODO: 显示错误提示
} finally {
saving.value = false
}
}
useSeoMeta({
title: isEdit.value ? '编辑文章 - LinkShare Blog' : '写文章 - LinkShare Blog',
})
</script>