管理后台 - 内容管理
文件上传 API
ts
// server/api/upload.post.ts
import { writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { nanoid } from 'nanoid'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
export default defineEventHandler(async (event) => {
// 只有管理员可以上传
if (event.context.userRole !== 'admin') {
throw createError({ statusCode: 403, statusMessage: 'Forbidden', message: '需要管理员权限' })
}
const files = await readMultipartFormData(event)
if (!files?.length) {
throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '请上传文件' })
}
const file = files[0]
// 检查文件大小
if (file.data.length > MAX_SIZE) {
throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '文件大小不能超过 5MB' })
}
// 检查 MIME 类型
if (!ALLOWED_TYPES.includes(file.type || '')) {
throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '只支持 JPG/PNG/GIF/WebP 格式' })
}
// 生成随机文件名
const ext = file.filename?.split('.').pop()?.toLowerCase() ?? 'png'
const ALLOWED_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
if (!ALLOWED_EXTS.includes(ext)) {
throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '文件扩展名不允许' })
}
const filename = `${nanoid()}.${ext}`
// 保存到 uploads 目录
const uploadDir = join(process.cwd(), 'uploads')
await mkdir(uploadDir, { recursive: true })
await writeFile(join(uploadDir, filename), file.data)
return {
url: `/uploads/${filename}`,
filename: file.filename,
size: file.data.length,
}
})上传安全清单
| 安全措施 | 代码位置 | 原因 |
|---|---|---|
| 权限检查 | userRole !== 'admin' | 防止普通用户上传文件 |
| 大小限制 | MAX_SIZE = 5MB | 防止大文件耗尽磁盘 |
| MIME 检查 | ALLOWED_TYPES | 防止上传可执行文件 |
| 扩展名白名单 | ALLOWED_EXTS | 防止 .php、.exe 等伪装 |
| 随机文件名 | nanoid() | 防止路径遍历和文件覆盖 |
上传目录不在 public/ | join(process.cwd(), 'uploads') | 防止直接访问执行 |
为什么上传目录不在 public/?
public/ 下的文件可以通过 URL 直接访问,如果有人上传了 .php 文件,可能在服务器上被执行。uploads/ 目录需要通过 Nginx 或 API 代理访问,可以添加安全控制。
生产环境建议
使用对象存储(如腾讯云 COS、阿里云 OSS)替代本地文件存储。对象存储有天然的安全隔离,且不占用服务器磁盘。
内容管理 API
ts
// server/api/admin/content.get.ts
export default defineEventHandler(async (event) => {
if (event.context.userRole !== 'admin') {
throw createError({ statusCode: 403 })
}
const query = getQuery(event)
const page = Number(query.page) || 1
const pageSize = Number(query.pageSize) || 20
// 根据实际业务实现内容列表
// ...
return { list: [], total: 0, page, pageSize }
})ts
// server/api/admin/content.post.ts
import { z } from 'zod'
const schema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
type: z.enum(['article', 'notice', 'faq']),
status: z.number().min(0).max(1).default(1),
})
export default defineEventHandler(async (event) => {
if (event.context.userRole !== 'admin') {
throw createError({ statusCode: 403 })
}
const data = await readValidatedBody(event, schema.parse)
// 创建内容
// ...
return { message: '创建成功' }
})内容模型的数据库表
当前 schema.ts 没有定义内容表,需要补充:
ts
// server/database/schema.ts 新增
export const contents = pgTable('contents', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 200 }),
content: text('content'), // 存储富文本 HTML
type: varchar('type', { length: 20 }), // article / notice / faq
authorId: integer('author_id').references(() => users.id),
status: smallint('status').default(1), // 0草稿/1发布
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
})为什么 content 用 text 类型?
富文本编辑器输出的 HTML 可能很长(含内联图片 base64),varchar 有长度限制。PostgreSQL 的 text 类型没有长度限制。
内容管理页面
vue
<!-- app/pages/admin/content/index.vue -->
<script setup>
definePageMeta({
layout: 'admin',
middleware: 'admin-auth',
})
const page = ref(1)
const { data, refresh } = await useFetch('/api/admin/content', {
query: { page },
})
</script>
<template>
<div>
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">内容管理</h2>
<UButton to="/admin/content/create" icon="i-heroicons-plus">新建内容</UButton>
</div>
<UTable :rows="data?.list ?? []" :columns="[
{ key: 'title', label: '标题' },
{ key: 'type', label: '类型' },
{ key: 'status', label: '状态' },
{ key: 'createdAt', label: '创建时间' },
{ key: 'actions', label: '操作' },
]" />
</div>
</template>富文本编辑
bash
npm install @tiptap/vue-3 @tiptap/starter-kitvue
<!-- app/components/admin/ContentEditor.vue -->
<script setup>
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
const model = defineModel<string>()
const editor = useEditor({
content: model.value,
onUpdate: ({ editor }) => {
model.value = editor.getHTML()
},
extensions: [StarterKit],
})
// 组件销毁时销毁编辑器
onBeforeUnmount(() => {
editor.value?.destroy()
})
</script>
<template>
<div class="border rounded-lg">
<div class="border-b p-2 flex gap-2">
<UButton size="xs" @click="editor?.chain().focus().toggleBold().run()" :variant="editor?.isActive('bold') ? 'solid' : 'ghost'">B</UButton>
<UButton size="xs" @click="editor?.chain().focus().toggleItalic().run()" :variant="editor?.isActive('italic') ? 'solid' : 'ghost'">I</UButton>
<UButton size="xs" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">H2</UButton>
<UButton size="xs" @click="editor?.chain().focus().toggleBulletList().run()">列表</UButton>
</div>
<EditorContent :editor="editor" class="prose p-4 min-h-[300px]" />
</div>
</template>富文本的 XSS 防护
富文本编辑器输出的是 HTML,存储和展示时需要注意 XSS:
ts
// 存储前:服务端不做过滤(保留原始 HTML)
// 用户在编辑器中写的格式需要完整保存
// 展示时:使用 DOMPurify 过滤
import DOMPurify from 'isomorphic-dompurify'
const safeHtml = computed(() => DOMPurify.sanitize(article.content))为什么存储不过滤?
DOMPurify 可能会移除某些合法的自定义样式或标签。存储原始内容,展示时过滤,可以随时调整过滤规则而不会丢失原始数据。
图片上传集成
富文本编辑器中插入图片需要与上传 API 配合:
ts
// Tiptap 图片上传扩展
const editor = useEditor({
extensions: [
StarterKit,
Image,
],
})
async function handleImageUpload(file: File) {
const formData = new FormData()
formData.append('file', file)
const { url } = await $fetch('/api/upload', {
method: 'POST',
body: formData,
})
editor.value?.chain().focus().setImage({ src: url }).run()
}注意
Tiptap 的 Image 扩展需要单独安装:npm install @tiptap/extension-image。
常见问题
上传文件后访问 404?
上传目录 uploads/ 不在 public/ 下,Nuxt 不会自动提供静态文件服务。需要在 Nginx 中配置:
nginx
location /uploads/ {
alias /app/flutter-api-server/uploads/;
expires 30d;
}或者用 Nitro 的 publicAssets 配置:
ts
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
publicAssets: [{
dir: '../uploads',
baseURL: '/uploads',
maxAge: 30 * 24 * 60 * 60, // 30 天缓存
}],
},
})内容列表 API 是空的?
当前实现返回空数组(return { list: [], total: 0 }),因为还没有创建 contents 表和实现查询逻辑。需要先在 schema.ts 中添加内容表定义,然后实现完整的 CRUD。