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.
 
 
 
 
 

14 KiB

Quick Note Modal 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: 在顶部“写文章”按钮左侧新增“速记”入口,提供可拖拽/可全屏的轻量速记弹框,支持上传、手动保存、每用户唯一内容和未保存离开保护。

Architecture: 前端在 AppShell 挂载速记入口与弹框,弹框拆分为容器层(拖拽、全屏、未保存拦截)和编辑器层(轻量 Vditor + 上传复用)。后端新增用户唯一 quick_notes 存储与 GET/PUT /api/me/quick-note 接口,服务层统一处理 upsert、长度校验和换行标准化。全链路以 TDD 驱动,按“先失败测试 -> 最小实现 -> 通过测试 -> 提交”小步推进。

Tech Stack: Nuxt 4、Vue 3、TypeScript、Bun test、Drizzle ORM(SQLite)


File Structure

Create

  • app/components/QuickNoteModal.vue:速记弹框容器(近全屏、全屏切换、拖拽、未保存拦截、beforeunload)
  • app/components/QuickNoteEditor.vue:轻量编辑器(复用 Vditor 与上传配置)
  • app/components/quick-note-editor-vditor-config.ts:速记编辑器 Vditor 轻量配置
  • app/components/quick-note-modal-state.ts:速记 dirty/save 状态与交互纯函数
  • app/components/quick-note-modal-state.test.ts:状态纯函数测试
  • server/service/quick-note/index.ts:速记服务(get/upsert/normalize/validate)
  • server/service/quick-note/index.test.ts:速记服务测试
  • server/api/me/quick-note.get.ts:获取当前用户速记
  • server/api/me/quick-note.put.ts:手动保存当前用户速记
  • packages/drizzle-pkg/migrations/0012_quick_notes.sql:新增 quick_notes 表与唯一索引

Modify

  • app/components/AppShell.vue:新增“速记”按钮并挂载弹框
  • packages/drizzle-pkg/database/sqlite/schema/content.ts:新增 quickNotes 表定义
  • packages/drizzle-pkg/lib/schema/content.ts:导出 quickNotes
  • packages/drizzle-pkg/migrations/meta/_journal.json:登记新 migration
  • server/api/me 下路由索引(若项目需要)用于自动发现 quick-note 接口

Test

  • app/components/quick-note-modal-state.test.ts
  • app/components/post-body-markdown-editor-vditor-config.test.ts(可补一条“复用上传格式兼容”断言)
  • server/service/quick-note/index.test.ts

Task 1: 数据层建模(每用户唯一 quick note)

Files:

  • Modify: packages/drizzle-pkg/database/sqlite/schema/content.ts

  • Modify: packages/drizzle-pkg/lib/schema/content.ts

  • Create: packages/drizzle-pkg/migrations/0012_quick_notes.sql

  • Modify: packages/drizzle-pkg/migrations/meta/_journal.json

  • Step 1: 写失败检查(确认 quick_notes 尚未存在)

rg "quick_notes|quickNotes" packages/drizzle-pkg/database/sqlite/schema/content.ts packages/drizzle-pkg/lib/schema/content.ts

Expected: 无匹配结果。

  • Step 2: 运行检查确认失败基线

Run: rg "quick_notes|quickNotes" packages/drizzle-pkg/database/sqlite/schema/content.ts packages/drizzle-pkg/lib/schema/content.ts
Expected: Exit code 1(无命中)

  • Step 3: 增加表定义与导出
// packages/drizzle-pkg/database/sqlite/schema/content.ts
export const quickNotes = sqliteTable(
  "quick_notes",
  {
    id: integer().primaryKey(),
    userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
    content: text().notNull().default(""),
    createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
    updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().$onUpdate(() => new Date()).notNull(),
  },
  (table) => [uniqueIndex("quick_notes_user_id_unique").on(table.userId)],
);
// packages/drizzle-pkg/lib/schema/content.ts
export { quickNotes } from "../../database/sqlite/schema/content";
-- packages/drizzle-pkg/migrations/0012_quick_notes.sql
CREATE TABLE `quick_notes` (
  `id` integer PRIMARY KEY NOT NULL,
  `user_id` integer NOT NULL,
  `content` text NOT NULL DEFAULT '',
  `created_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
  `updated_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
  FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
CREATE UNIQUE INDEX `quick_notes_user_id_unique` ON `quick_notes` (`user_id`);
  • Step 4: 生成并校验 schema/migration 一致性

Run: cd packages/drizzle-pkg && bun run generate
Expected: 生成结果无冲突,migrations 元信息可追踪 0012_quick_notes.sql

  • Step 5: 提交
git add packages/drizzle-pkg/database/sqlite/schema/content.ts packages/drizzle-pkg/lib/schema/content.ts packages/drizzle-pkg/migrations/0012_quick_notes.sql packages/drizzle-pkg/migrations/meta/_journal.json
git commit -m "feat(db): add per-user quick note table"

Task 2: 服务层实现(读取、upsert、校验)

Files:

  • Create: server/service/quick-note/index.ts

  • Create: server/service/quick-note/index.test.ts

  • Step 1: 写失败测试(无记录返回空、保存后可读取、超长报错)

import { describe, expect, test } from "bun:test";
import { normalizeQuickNoteContent, validateQuickNoteContentLength } from "./index";

describe("quick-note content guards", () => {
  test("normalizes CRLF to LF", () => {
    expect(normalizeQuickNoteContent("a\r\nb")).toBe("a\nb");
  });
  test("throws when exceeds limit", () => {
    expect(() => validateQuickNoteContentLength("x".repeat(200001))).toThrow("速记内容过长");
  });
});
  • Step 2: 运行测试确认失败

Run: bun test server/service/quick-note/index.test.ts
Expected: FAIL(函数/模块尚未实现)

  • Step 3: 写最小实现
import { dbGlobal } from "drizzle-pkg/lib/db";
import { quickNotes } from "drizzle-pkg/lib/schema/content";
import { eq } from "drizzle-orm";
import { nextIntegerId } from "#server/utils/sqlite-id";

export const QUICK_NOTE_MAX_LENGTH = 200_000;

export function normalizeQuickNoteContent(content: string) {
  return content.replace(/\r\n/g, "\n");
}

export function validateQuickNoteContentLength(content: string) {
  if (content.length > QUICK_NOTE_MAX_LENGTH) {
    throw createError({ statusCode: 400, statusMessage: "速记内容过长" });
  }
}

并补充 getQuickNoteByUserIdupsertQuickNoteByUserId,用 userId 查询,不存在则 insert,存在则 update。

  • Step 4: 运行测试确认通过

Run: bun test server/service/quick-note/index.test.ts
Expected: PASS

  • Step 5: 提交
git add server/service/quick-note/index.ts server/service/quick-note/index.test.ts
git commit -m "feat(server): add quick note service with validation"

Task 3: API 层实现(GET/PUT /api/me/quick-note)

Files:

  • Create: server/api/me/quick-note.get.ts

  • Create: server/api/me/quick-note.put.ts

  • Modify: server/service/quick-note/index.ts(若需补返回 DTO)

  • Step 1: 写失败检查(接口文件不存在)

ls server/api/me/quick-note.get.ts server/api/me/quick-note.put.ts

Expected: 报不存在。

  • Step 2: 运行检查确认失败

Run: ls server/api/me/quick-note.get.ts server/api/me/quick-note.put.ts
Expected: No such file or directory

  • Step 3: 写最小接口实现
// server/api/me/quick-note.get.ts
import { getQuickNoteByUserId } from "#server/service/quick-note";
export default defineWrappedResponseHandler(async (event) => {
  const user = await event.context.auth.requireUser();
  const row = await getQuickNoteByUserId(user.id);
  return R.success({ quickNote: { content: row?.content ?? "", updatedAt: row?.updatedAt ?? null } });
});
// server/api/me/quick-note.put.ts
import { upsertQuickNoteByUserId } from "#server/service/quick-note";
export default defineWrappedResponseHandler(async (event) => {
  const user = await event.context.auth.requireUser();
  const body = await readBody<{ content: string }>(event);
  if (typeof body.content !== "string") {
    throw createError({ statusCode: 400, statusMessage: "content 必须为字符串" });
  }
  const row = await upsertQuickNoteByUserId(user.id, body.content);
  return R.success({ quickNote: { content: row.content, updatedAt: row.updatedAt } });
});
  • Step 4: 启动服务并手工验证

Run: bun run dev
Expected:

  • GET /api/me/quick-note 已登录返回 { content }

  • PUT /api/me/quick-note 手动保存成功并可再次读回

  • Step 5: 提交

git add server/api/me/quick-note.get.ts server/api/me/quick-note.put.ts server/service/quick-note/index.ts
git commit -m "feat(api): add me quick-note get and put endpoints"

Task 4: 前端轻量编辑器与状态机(dirty/save)

Files:

  • Create: app/components/quick-note-editor-vditor-config.ts

  • Create: app/components/QuickNoteEditor.vue

  • Create: app/components/quick-note-modal-state.ts

  • Create: app/components/quick-note-modal-state.test.ts

  • Step 1: 写失败测试(dirty 判定、保存后清洁、失败保留草稿)

import { describe, expect, test } from "bun:test";
import { computeIsDirty } from "./quick-note-modal-state";

describe("quick note modal state", () => {
  test("is dirty when draft differs from saved", () => {
    expect(computeIsDirty({ savedContent: "a", draftContent: "b" })).toBe(true);
  });
});
  • Step 2: 运行测试确认失败

Run: bun test app/components/quick-note-modal-state.test.ts
Expected: FAIL(状态模块未实现)

  • Step 3: 写最小实现(状态 + 轻量编辑器)
// app/components/quick-note-modal-state.ts
export function computeIsDirty(input: { savedContent: string; draftContent: string }) {
  return input.savedContent !== input.draftContent;
}
// app/components/quick-note-editor-vditor-config.ts
export const QUICK_NOTE_TOOLBAR = ["bold", "italic", "headings", "|", "list", "ordered-list", "|", "link", "upload", "code"];

QuickNoteEditor.vue 复用 post-body-markdown-editor-vditor-bridge.ts 模式,上传格式复用现有解析逻辑,保留 onUploadError 提示。

  • Step 4: 运行测试确认通过

Run: bun test app/components/quick-note-modal-state.test.ts app/components/post-body-markdown-editor-vditor-config.test.ts
Expected: PASS

  • Step 5: 提交
git add app/components/quick-note-editor-vditor-config.ts app/components/QuickNoteEditor.vue app/components/quick-note-modal-state.ts app/components/quick-note-modal-state.test.ts
git commit -m "feat(app): add lightweight quick-note editor and dirty-state helpers"

Task 5: 弹框容器与入口集成(拖拽/全屏/未保存拦截)

Files:

  • Create: app/components/QuickNoteModal.vue

  • Modify: app/components/AppShell.vue

  • Step 1: 写失败检查(确认 AppShell 尚无“速记”入口)

rg "速记|QuickNoteModal|quick-note" app/components/AppShell.vue

Expected: 无命中。

  • Step 2: 运行检查确认失败

Run: rg "速记|QuickNoteModal|quick-note" app/components/AppShell.vue
Expected: Exit code 1(无命中)

  • Step 3: 写最小 UI 与交互实现
<!-- app/components/AppShell.vue (header actions) -->
<UButton
  v-if="showQuickCreate"
  color="neutral"
  variant="soft"
  icon="i-lucide-notebook-pen"
  size="sm"
  class="hidden md:inline-flex"
  @click="quickNoteOpen = true"
>
  速记
</UButton>
<QuickNoteModal v-model:open="quickNoteOpen" />
// QuickNoteModal.vue (核心片段)
const isFullscreen = ref(false);
const isDirty = computed(() => draftContent.value !== savedContent.value);
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onBeforeUnmount(() => window.removeEventListener("beforeunload", onBeforeUnload));

并实现:

  • 默认近全屏(保留边距)

  • 右上角按钮切换全屏/退出全屏

  • 非全屏标题栏拖拽

  • 关闭弹框时 dirty 二次确认

  • Step 4: 启动前端手测关键交互

Run: bun run dev
Expected:

  • “速记”按钮在“写文章”左侧可见

  • 打开即近全屏

  • 全屏切换可用

  • 未保存关闭时弹确认框

  • 未保存刷新/离页触发浏览器原生提醒

  • Step 5: 提交

git add app/components/AppShell.vue app/components/QuickNoteModal.vue
git commit -m "feat(ui): add quick-note modal entry with drag/fullscreen and unsaved guards"

Task 6: 端到端回归与质量闸门

Files:

  • Modify: docs/superpowers/specs/2026-04-27-quick-note-modal-design.md(仅在验收结论需要补充时)

  • Step 1: 运行自动化测试

Run: bun test app/components/quick-note-modal-state.test.ts server/service/quick-note/index.test.ts app/components/post-body-markdown-editor-vditor-config.test.ts
Expected: PASS

  • Step 2: 运行 lint(仅本次变更范围)

Run: bun run lint
Expected: 无新增错误(允许存在历史告警,但不能新增)

  • Step 3: 手工验收清单

Run: bun run dev
Expected:

  • 每个登录用户只有一条速记内容(切账号互不影响)

  • 仅手动保存写入服务端

  • 未保存保护在“关闭弹框”和“离开页面”两处都生效

  • 上传可用,插入行为与文章编辑链路一致

  • Step 4: 最终提交

git add app/components server/api/me server/service/quick-note packages/drizzle-pkg
git commit -m "feat(quick-note): implement per-user quick note modal with manual save"
  • Step 5: 输出验证证据

Run: git show --stat --oneline -1
Expected: 展示 quick-note 相关文件变更清单,便于 PR 描述直接引用。


Self-Review

  1. Spec coverage: 已覆盖入口位置、近全屏+全屏切换、拖拽、轻量编辑器+上传、用户唯一存储、手动保存、双重未保存提醒、测试与验收。
  2. Placeholder scan: 无 TBD/TODO/“后续补充”;每个任务均给出具体路径、命令、预期输出和最小代码片段。
  3. Type consistency: 前后统一使用 quick-note 路由命名、savedContent/draftContent/isDirty 状态命名、GET/PUT /api/me/quick-note 接口约定。