1 changed files with 447 additions and 0 deletions
@ -0,0 +1,447 @@ |
|||||
|
# 文章编辑页 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` 内挂载 `MdEditor`(`preview=true` 默认分屏,用户可拖中间分隔条调比例);`onUploadImg` 内用 `FormData` 多 `file` 字段调用现有 `/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` + `unwrapApiBody`、`useToast`。 |
||||
|
|
||||
|
**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:顶栏、`UCard`、`UCollapsible`、正文组件、表单与保存/删除 | |
||||
|
| `app/pages/me/posts/new.vue` | 与编辑页一致的布局与正文组件 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 1: 依赖与构建兜底 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `package.json` |
||||
|
- Modify: `bun.lock`(由 bun 自动生成) |
||||
|
|
||||
|
- [ ] **Step 1: 安装 md-editor-v3** |
||||
|
|
||||
|
Run: |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel && bun add md-editor-v3@6.4.2 |
||||
|
``` |
||||
|
|
||||
|
Expected: `package.json` 的 `dependencies` 含 `"md-editor-v3": "6.4.2"`(或兼容的 6.4.x 解析版本)。 |
||||
|
|
||||
|
- [ ] **Step 2: 校验开发构建** |
||||
|
|
||||
|
Run: |
||||
|
|
||||
|
```bash |
||||
|
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` 同级增加: |
||||
|
|
||||
|
```typescript |
||||
|
vite: { |
||||
|
optimizeDeps: { |
||||
|
include: ['md-editor-v3'], |
||||
|
}, |
||||
|
}, |
||||
|
``` |
||||
|
|
||||
|
保存后重新 `bun run dev`,Expected: 错误消失。 |
||||
|
|
||||
|
- [ ] **Step 4: Commit** |
||||
|
|
||||
|
```bash |
||||
|
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.ts`(`limits` 段) |
||||
|
|
||||
|
- [ ] **Step 1: 修改 fileSize 上限** |
||||
|
|
||||
|
将 `fileSize: 5 * 1024 * 1024` 替换为: |
||||
|
|
||||
|
```typescript |
||||
|
fileSize: 10 * 1024 * 1024, // 10MB |
||||
|
``` |
||||
|
|
||||
|
`fileFilter` 与 `upload.array('file', 10)` **保持不变**。 |
||||
|
|
||||
|
- [ ] **Step 2: Commit** |
||||
|
|
||||
|
```bash |
||||
|
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`,完整内容: |
||||
|
|
||||
|
```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** |
||||
|
|
||||
|
```bash |
||||
|
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>` **保持不变**(`state`、`load`、`save`、`remove`、`shareUrl` 逻辑不改)。 |
||||
|
|
||||
|
将 `<template>` 整体替换为: |
||||
|
|
||||
|
```vue |
||||
|
<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> |
||||
|
``` |
||||
|
|
||||
|
若 `UButton` 的 `trailing` / `trailing-icon` 与 `UCollapsible` 组合异常,以 [Nuxt UI Button](https://ui.nuxt.com/docs/components/button) 与 [Collapsible](https://ui.nuxt.com/docs/components/collapsible) 为准调整,**保持「`type="button"`(避免在 `UForm` 内误提交)+ 块级触发器」**。 |
||||
|
|
||||
|
`UFormField` 对正文使用 `sr-only`:满足无障碍 `name` 关联,且不在视觉上重复「正文」标题(编辑器工具栏已表达含义)。 |
||||
|
|
||||
|
- [ ] **Step 2: 手动烟测(编辑页)** |
||||
|
|
||||
|
Run `bun run dev`,登录后打开 `/me/posts/:id`:折叠/展开「文章设置」、分屏拖动、工具栏上传图片、保存后刷新内容一致。 |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
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>` 替换为: |
||||
|
|
||||
|
```vue |
||||
|
<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> |
||||
|
``` |
||||
|
|
||||
|
新建页无 `shareUrl`,`UCollapsible` 使用 `:default-open="true"` 降低首次填写门槛;编辑页默认折叠以突出正文(可按产品再调,与 spec「写作优先」一致即可)。 |
||||
|
|
||||
|
- [ ] **Step 2: 手动烟测(新建页)** |
||||
|
|
||||
|
`/me/posts/new`:填写元数据、正文、上传图片、创建后跳转编辑页且内容保留。 |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
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** |
||||
|
|
||||
|
```bash |
||||
|
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。 |
||||
|
**类型一致:** `onUploadImg` 与 `md-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。** |
||||
Loading…
Reference in new issue