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