Skip to content

管理后台 - 内容管理

文件上传 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(),
})

为什么 contenttext 类型?

富文本编辑器输出的 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>

富文本编辑

推荐使用 TiptapQuill

bash
npm install @tiptap/vue-3 @tiptap/starter-kit
vue
<!-- 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。

基于 Nuxt 4 官方文档整理编写