You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

15 KiB

文章编辑页 Markdown 分屏与上传 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal:docs/superpowers/specs/2026-04-18-article-edit-markdown-design.md 实现写作优先布局、可折叠「文章设置」、md-editor-v3 分屏编辑/预览、图片批量上传插入 Markdown,并将上传单文件上限调至 10MB。

Architecture: 新增封装组件在 ClientOnly 内挂载 MdEditorpreview=true 默认分屏,用户可拖中间分隔条调比例);onUploadImg 内用 FormDatafile 字段调用现有 /api/file/upload,成功后将 url 数组交给编辑器回调;编辑/新建两页共用该组件;元数据区用 UCollapsible 包在 UCard 内;上传限制仅在 Nitro 路由中改 multer 配置。

Tech Stack: Nuxt 4.4、@nuxt/ui 4.6、Vue 3.5、md-editor-v3 6.4.2(计划中锁定;安装以 package.json 为准)、Bun、$fetch 封装 request + unwrapApiBodyuseToast

Spec: docs/superpowers/specs/2026-04-18-article-edit-markdown-design.md

测试说明: 本期以 spec 第 8 节 手动验收 为主;仓库暂无 HTTP 上传集成测试,计划中不虚构「先写失败测试」步骤。


文件结构(将创建 / 修改)

路径 职责
package.json / bun.lock 依赖 md-editor-v3@6.4.2
nuxt.config.ts bun run dev / build 报 md-editor 预构建错误,增加 vite.optimizeDeps.include(见 Task 1 可选步)
server/api/file/upload.post.ts limits.fileSize → 10MB
app/components/PostBodyMarkdownEditor.vue MdEditor + v-model + onUploadImg + Toast 错误
app/pages/me/posts/[id].vue 布局 C:顶栏、UCardUCollapsible、正文组件、表单与保存/删除
app/pages/me/posts/new.vue 与编辑页一致的布局与正文组件

Task 1: 依赖与构建兜底

Files:

  • Modify: package.json

  • Modify: bun.lock(由 bun 自动生成)

  • Step 1: 安装 md-editor-v3

Run:

cd /home/dash/projects/person-panel && bun add md-editor-v3@6.4.2

Expected: package.jsondependencies"md-editor-v3": "6.4.2"(或兼容的 6.4.x 解析版本)。

  • Step 2: 校验开发构建

Run:

cd /home/dash/projects/person-panel && bun run dev

Expected: Nuxt 启动无报错;在浏览器打开任意页确认无白屏。

若控制台出现与 md-editor-v3 / codemirror 相关的 optimizeDeps / 预构建 错误,再执行 Step 3;否则跳过 Step 3。

  • Step 3(可选): Vite 预构建包含

Modify nuxt.config.ts,在 defineNuxtConfig({ ... }) 内与 nitro 同级增加:

  vite: {
    optimizeDeps: {
      include: ['md-editor-v3'],
    },
  },

保存后重新 bun run dev,Expected: 错误消失。

  • Step 4: Commit
cd /home/dash/projects/person-panel && git add package.json bun.lock nuxt.config.ts && git commit -m "chore(deps): add md-editor-v3 for post markdown editor"

(若未改 nuxt.config.ts,从 git add 中省略该文件。)


Task 2: 上传体积 10MB

Files:

  • Modify: server/api/file/upload.post.tslimits 段)

  • Step 1: 修改 fileSize 上限

fileSize: 5 * 1024 * 1024 替换为:

        fileSize: 10 * 1024 * 1024, // 10MB

fileFilterupload.array('file', 10) 保持不变

  • Step 2: Commit
cd /home/dash/projects/person-panel && git add server/api/file/upload.post.ts && git commit -m "feat(upload): raise image upload limit to 10MB"

Task 3: 正文编辑器封装组件

Files:

  • Create: app/components/PostBodyMarkdownEditor.vue

  • Step 1: 新增组件文件

创建 app/components/PostBodyMarkdownEditor.vue,完整内容:

<script setup lang="ts">
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { request, unwrapApiBody, type ApiResponse } from '~/utils/http/factory'

const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [string]
}>()

const toast = useToast()
const editorId = `post-body-md-${useId()}`

const local = computed({
  get: () => props.modelValue,
  set: (v: string) => emit('update:modelValue', v),
})

function extractUploadError(e: unknown): string {
  if (e && typeof e === 'object') {
    const fe = e as {
      statusMessage?: string
      message?: string
      data?: { message?: string }
    }
    if (typeof fe.statusMessage === 'string' && fe.statusMessage.length) {
      return fe.statusMessage
    }
    if (typeof fe.data?.message === 'string' && fe.data.message.length) {
      return fe.data.message
    }
    if (typeof fe.message === 'string' && fe.message.length) {
      return fe.message
    }
  }
  return '图片上传失败'
}

async function onUploadImg(files: File[], callback: (urls: string[]) => void) {
  const form = new FormData()
  for (const f of files) {
    form.append('file', f)
  }
  try {
    const res = await request<ApiResponse<{ files: { url: string }[] }>>('/api/file/upload', {
      method: 'POST',
      body: form,
    })
    const { files: uploaded } = unwrapApiBody(res)
    callback(uploaded.map((x) => x.url))
  } catch (e: unknown) {
    toast.add({ title: extractUploadError(e), color: 'error' })
    callback([])
  }
}
</script>

<template>
  <ClientOnly>
    <MdEditor
      :id="editorId"
      v-model="local"
      language="zh-CN"
      :preview="true"
      preview-theme="github"
      theme="light"
      :on-upload-img="onUploadImg"
      :style="{ height: 'min(72vh, 720px)' }"
      class="w-full rounded-lg overflow-hidden ring ring-default"
    />
    <template #fallback>
      <div class="text-muted text-sm py-12 text-center border border-default rounded-lg">
        编辑器加载中
      </div>
    </template>
  </ClientOnly>
</template>

说明:preview 默认为 true(见 md-editor-v3 类型定义),即左侧编辑、右侧预览;库内分隔条可拖调宽度。onUploadImg 签名:(files: File[], callback: (urls: string[]) => void),多文件一次请求与 multer.array('file', 10) 对齐。

  • Step 2: Commit
cd /home/dash/projects/person-panel && git add app/components/PostBodyMarkdownEditor.vue && git commit -m "feat(editor): add PostBodyMarkdownEditor with md-editor-v3 and upload"

Task 4: 编辑页 [id].vue 布局与接入

Files:

  • Modify: app/pages/me/posts/[id].vue

  • Step 1: 替换模板与容器

<script setup> 保持不变stateloadsaveremoveshareUrl 逻辑不改)。

<template> 整体替换为:

<template>
  <UContainer class="py-8 max-w-6xl space-y-6">
    <div class="flex flex-wrap items-center justify-between gap-3">
      <h1 class="text-2xl font-semibold tracking-tight">
        编辑文章
      </h1>
      <UButton to="/me/posts" variant="ghost" color="neutral">
        返回列表
      </UButton>
    </div>

    <div v-if="loading" class="text-muted">
      加载中…
    </div>

    <template v-else>
      <UForm :state="state" class="space-y-6" @submit.prevent="save">
        <UCard :ui="{ body: 'p-4 sm:p-6' }">
          <PostBodyMarkdownEditor v-model="state.bodyMarkdown" />
          <UFormField label="正文" name="bodyMarkdown" required class="sr-only" />
        </UCard>

        <UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }">
          <UCollapsible :unmount-on-hide="false">
            <UButton
              type="button"
              color="neutral"
              variant="subtle"
              block
              class="justify-between font-medium"
              label="文章设置"
              trailing
              trailing-icon="i-lucide-chevron-down"
            />
            <template #content>
              <div class="pt-4 space-y-4 border-t border-default mt-4">
                <UAlert
                  v-if="shareUrl"
                  title="仅链接分享"
                  :description="shareUrl"
                />
                <UFormField label="标题" name="title" required>
                  <UInput v-model="state.title" />
                </UFormField>
                <UFormField label="slug" name="slug" required>
                  <UInput v-model="state.slug" />
                </UFormField>
                <UFormField label="摘要" name="excerpt" required>
                  <UInput v-model="state.excerpt" />
                </UFormField>
                <UFormField label="可见性" name="visibility">
                  <USelect
                    v-model="state.visibility"
                    :items="[
                      { label: '私密', value: 'private' },
                      { label: '公开', value: 'public' },
                      { label: '仅链接', value: 'unlisted' },
                    ]"
                  />
                </UFormField>
              </div>
            </template>
          </UCollapsible>
        </UCard>

        <div class="flex flex-wrap gap-2">
          <UButton type="submit" :loading="saving">
            保存
          </UButton>
          <UButton color="error" variant="soft" type="button" @click="remove">
            删除
          </UButton>
        </div>
      </UForm>
    </template>
  </UContainer>
</template>

UButtontrailing / trailing-iconUCollapsible 组合异常,以 Nuxt UI ButtonCollapsible 为准调整,保持「type="button"(避免在 UForm 内误提交)+ 块级触发器」

UFormField 对正文使用 sr-only:满足无障碍 name 关联,且不在视觉上重复「正文」标题(编辑器工具栏已表达含义)。

  • Step 2: 手动烟测(编辑页)

Run bun run dev,登录后打开 /me/posts/:id:折叠/展开「文章设置」、分屏拖动、工具栏上传图片、保存后刷新内容一致。

  • Step 3: Commit
cd /home/dash/projects/person-panel && git add app/pages/me/posts/\[id\].vue && git commit -m "feat(posts): writing-first layout and markdown editor on edit page"

Task 5: 新建页 new.vue 对齐

Files:

  • Modify: app/pages/me/posts/new.vue

  • Step 1: 对齐布局与组件

loading 重命名为 submitting(可选,避免与「加载」语义混淆),或将 ref(false) 保留为 loading 仅用于提交按钮 — 与下列模板一致即可。

<script setup>submit / state 与 API 调用逻辑保持不变,仅确保 state.bodyMarkdown 仍绑定到新编辑器。

<template> 替换为:

<template>
  <UContainer class="py-8 max-w-6xl space-y-6">
    <div class="flex flex-wrap items-center justify-between gap-3">
      <h1 class="text-2xl font-semibold tracking-tight">
        新建文章
      </h1>
      <UButton to="/me/posts" variant="ghost" color="neutral">
        返回列表
      </UButton>
    </div>

    <UForm :state="state" class="space-y-6" @submit.prevent="submit">
      <UCard :ui="{ body: 'p-4 sm:p-6' }">
        <PostBodyMarkdownEditor v-model="state.bodyMarkdown" />
        <UFormField label="正文" name="bodyMarkdown" required class="sr-only" />
      </UCard>

      <UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }">
        <UCollapsible :unmount-on-hide="false" :default-open="true">
          <UButton
            type="button"
            color="neutral"
            variant="subtle"
            block
            class="justify-between font-medium"
            label="文章设置"
            trailing
            trailing-icon="i-lucide-chevron-down"
          />
          <template #content>
            <div class="pt-4 space-y-4 border-t border-default mt-4">
              <UFormField label="标题" name="title" required>
                <UInput v-model="state.title" />
              </UFormField>
              <UFormField label="slug" name="slug" required>
                <UInput v-model="state.slug" />
              </UFormField>
              <UFormField label="摘要" name="excerpt" required>
                <UInput v-model="state.excerpt" />
              </UFormField>
              <UFormField label="可见性" name="visibility">
                <USelect
                  v-model="state.visibility"
                  :items="[
                    { label: '私密', value: 'private' },
                    { label: '公开', value: 'public' },
                    { label: '仅链接', value: 'unlisted' },
                  ]"
                />
              </UFormField>
            </div>
          </template>
        </UCollapsible>
      </UCard>

      <div class="flex flex-wrap gap-2">
        <UButton type="submit" :loading="loading">
          创建
        </UButton>
      </div>
    </UForm>
  </UContainer>
</template>

新建页无 shareUrlUCollapsible 使用 :default-open="true" 降低首次填写门槛;编辑页默认折叠以突出正文(可按产品再调,与 spec「写作优先」一致即可)。

  • Step 2: 手动烟测(新建页)

/me/posts/new:填写元数据、正文、上传图片、创建后跳转编辑页且内容保留。

  • Step 3: Commit
cd /home/dash/projects/person-panel && git add app/pages/me/posts/new.vue && git commit -m "feat(posts): align new post page with markdown editor layout"

Task 6: 生产构建与 spec 对照

  • Step 1: 运行 build
cd /home/dash/projects/person-panel && bun run build

Expected: 成功完成 nuxt build;若有 SSR 与 md-editor-v3 冲突,确保正文仅出现在 ClientOnly 内且页面无服务端引用浏览器专有 API。

  • Step 2: Spec 自检勾选

对照 docs/superpowers/specs/2026-04-18-article-edit-markdown-design.md 第 2、6、7、8 节逐项勾选;缺口回到 Task 3–5 修补。


计划自检(撰写后核对)

Spec 章节 对应任务
布局 C、分屏 B、图片上传与 10MB Task 2–5
新建/编辑共用编辑器 Task 3–5
错误 Toast、不静默 Task 3 onUploadImg
非目标(公开页渲染 Markdown 等) 未包含任何修改

占位符扫描: 无 TBD/TODO。
类型一致: onUploadImgmd-editor-v3 6.4.2 类型一致。


Plan 已保存至 docs/superpowers/plans/2026-04-18-article-edit-markdown-implementation-plan.md。执行方式可选:

  1. Subagent-Driven(推荐) — 每任务派生子代理并在任务间复核。
  2. Inline Execution — 本会话内按任务执行,批量变更并设检查点。

需要的话请回复选用 1 或 2。