前言

这篇继续写鸿蒙 PC 桌面工具实践,社区入口放在前面:鸿蒙PC开发者社区 :https://harmonypc.csdn.net/

青瞬速记的定位很轻,它不是完整知识库,也不是长文写作软件,而是一个把突然出现的想法先接住的小工具。
很多灵感并不是因为没有价值而消失,而是因为记录入口太重、字段太多、打开工具以后还要先判断该填哪里。这个工具的第一版目标,就是让用户打开后马上落笔,再慢慢补来源、阶段和下一步动作。

所以这篇文章不会把重点放在炫技上。我会从真实使用动作出发,依次拆数据模型、默认数据、状态层、页面组件、编辑器、工具栏、桥接层、Markdown 导出、样式和构建检查。每一段代码后面都会跟着成段的逻辑解释,说明它为什么放在这里、解决了什么问题、后续扩展时要注意什么。

速记工具的第一目标不是整理完整,而是让想法先安全落地。

一、先确定这个工具到底解决什么问题

青瞬速记解决的是“想法刚出现时,先让它有地方落下来”。这个场景比正式写作更靠前,也比知识整理更轻。用户可能只想到一句话,可能只想到一个入口形式,也可能只是突然意识到某个流程可以改得更顺一点;如果此时页面要求他先填完整标题、分类、标签、正文和状态,灵感很容易被这些动作打断。

因此第一版的核心不是“功能齐全”,而是“路径够短”。打开工具、输入核心句、保存,后面再补来源、情绪、阶段和下一步。这个顺序决定了字段不能太重,按钮不能太多,页面结构也不能像管理后台一样复杂。

青瞬速记结构图

从结构上看,我更愿意把青瞬速记拆成三块:左侧是收纳轨,用来找回已经记录过的想法;中间是灵感流,用来浏览当前积累;右侧是速记编辑器,用来补充当前条目的核心字段。这个结构不是为了凑三栏,而是为了让“找内容”和“继续整理”这两个动作可以在同一个桌面窗口里完成。

第一版我只保留三条产品边界。第一,用户可以只写一句 hook 就保存,工具不能逼他马上写完整。第二,来源、阶段、下一步动作都可以后补,整理动作不能挡在记录动作之前。第三,桌面能力只围绕复制、导出、通知出现,不把页面做成系统能力展示台。

这三条边界确定以后,后面的字段、状态层和组件划分都会更清楚。轻量工具最怕一开始就“顺手再加一点”,加着加着入口变重,最后用户打开它反而需要思考。青瞬速记要避免的正是这个问题。

二、文件职责按使用动作来拆

这一篇只讲文件职责,不再写任何本地工程路径。读者真正需要理解的是页面、状态和桥接能力分别做什么,而不是记住某个具体目录。青瞬速记的核心文件可以按下面的方式理解:

文件 角色 为什么这样放
Home.vue 页面编排层 负责把工具栏、列表和编辑器组合起来
NoteSidebar.vue 左侧列表层 负责展示速记条目、搜索结果和状态入口
NoteEditor.vue 当前条目编辑层 负责编辑 hook、source、stage、nextAction 等字段
NoteToolbar.vue 顶部动作层 负责新建、复制、导出、归档这类动作入口
useNotes.ts 状态层 负责数据加载、保存、选择、筛选、排序和更新
useNativeBridge.ts 桥接层 负责剪贴板、通知等桌面能力的环境适配

这里我没有强行把所有组件名都改成非常业务化的名字。原因很简单:这类桌面小工具往往会复用类似的页面骨架,真正需要变化的是字段语义、默认文案、排序规则和导出内容。组件边界稳定,业务语义变化,维护起来反而更轻。

也就是说,Home.vue 不应该直接碰 localStorage,NoteEditor 不应该知道列表怎么排序,NoteToolbar 不应该拼 Markdown,useNativeBridge 也不应该关心具体业务字段。每一层只做自己的事情,后续改字段或者换桥接实现时才不会牵一发而动全身。

三、先把核心字段定下来

字段是轻量工具的地基。字段设计如果像正式文档,用户就会被迫进入写作状态;字段设计如果像待办清单,灵感又会被压成任务。青瞬速记需要介于两者之间:既允许内容不完整,又要给后续整理留下足够线索。

字段 作用 页面里的位置
id 唯一标识,用来选择、更新和归档条目 状态层
hook 最先冒出来的核心句,是列表主标题 列表 / 编辑器
source 灵感来源,比如阅读、会议、散步、聊天 编辑器 / 导出
mood 当时的状态或强度,可以是文字也可以是数字 侧栏 / 导出
stage 整理阶段,例如待整理、已扩写、已归档 列表 / 筛选
nextAction 下一次打开时要做什么 编辑器 / 导出

下面先定义 TypeScript 类型。类型不只是为了让编译器通过,它会影响后面所有组件怎么读写数据。

export interface 青瞬速记Item {
  id: string;
  hook: string;
  source: string;
  mood: number | string;
  stage: string;
  nextAction: string;
}

export type 青瞬速记Filter = 'all' | 'active' | 'archived';

这段代码先把一条速记内容固定成 青瞬速记Itemid 不承担展示任务,但它是状态层最重要的字段;列表选择、更新条目、归档条目都不能依赖数组下标,因为一旦搜索或排序发生变化,下标就会失效。用稳定的 id 操作条目,后面才不会出现选中错乱。

hook 是这个工具最重要的业务字段。它不是正式标题,而是用户第一时间抓住想法的句子。比如“把浏览器临时页做成灵感入口”,这句话不完整,但足够让用户之后想起当时的方向。对速记工具来说,允许 hook 不完整,比要求用户写一个漂亮标题更重要。

source 用来把灵感放回现场。一个想法是读文章时出现的,还是会议里想到的,还是走路时突然冒出来的,后续整理时感受完全不同。source 不一定参与第一秒输入,但它能帮助用户几天后重新理解这条内容。

mood 写成 number | string 是为了保留弹性。第一版可以用“轻量捕捉”“强烈想法”“随手记”这样的文字,后面如果要做强度评分,也可以改成数字。这里不急着把控件定死,是因为轻工具第一版更重要的是跑通动作。

stage 表示整理阶段。速记不是写完就结束,很多条目会经历“待整理、已扩写、已归档”这样的过程。把阶段放到数据里,列表才能优先显示未处理内容,导出时也能保留当前状态。

nextAction 是我认为很值得保留的字段。它解决的是“过几天再打开时不知道从哪继续”的问题。速记工具如果只保存想法,不保存下一步,用户下次仍然需要重新启动思考;有了 nextAction,条目就从静态记录变成了可继续推进的对象。

最后的 青瞬速记Filter 给筛选状态加了类型约束。第一版只需要 all、active、archived 三类,后续如果要加 pinned、recent、source 等筛选,也可以在这里扩展。先把筛选值收束成类型,页面里就不容易写出拼错的字符串。

四、默认数据要像真实使用场景

很多示例项目会把默认数据写成“测试 1”“示例标题”“内容内容内容”。这种数据能让页面渲染出来,但不能验证工具是否真的适合使用。青瞬速记的默认数据应该像真实灵感,而不是像占位符。

export const seed青瞬速记Items: 青瞬速记Item[] = [
  {
    id: 'qingshun_quick_capture-001',
    hook: '把浏览器临时页做成灵感入口',
    source: '早晨读产品文章时想到',
    mood: '轻量捕捉',
    stage: '待整理',
    nextAction: '补一张入口交互草图',
  },
];

这段 seed 数据有两个作用。第一,它是第一次打开应用时的展示内容,让用户不面对完全空白的页面;第二,它是开发阶段的真实样本,用来检查列表、编辑器、搜索和导出是否能承接业务字段。

id 里带了 qingshun_quick_capture,这样调试本地数据时能看出它属于哪个工具。hook 不是“示例内容”,而是一条具体想法;它能测试列表主标题的长度,也能让读者明白 hook 应该如何填写。source 说明想法来自阅读场景,避免这个字段看起来像普通分类。

mood 写成“轻量捕捉”,表示这条内容不是正式任务,只是一个还没展开的想法。stage 写成“待整理”,符合速记条目刚创建时的真实状态。nextAction 写成“补一张入口交互草图”,让这条记录具备继续推进的线索,而不是停留在一句空泛想法上。

默认数据还有一个很实际的价值:它会暴露页面细节问题。如果 hook 太长,列表是否截断得自然?如果 source 有中文长句,编辑区是否换行正常?如果导出 Markdown,字段顺序是否符合阅读习惯?这些问题都需要真实一点的数据才能看出来。

所以 seed 数据不是为了截图好看,而是为了让第一版体验从一开始就贴近真实场景。一个轻量工具是否成立,很多时候不是看组件有多少,而是看默认打开时用户能不能立刻理解它该怎么用。

五、状态层先处理加载和保存

青瞬速记的状态层放在 useNotes.ts 里。这个文件不负责页面长什么样,也不关心按钮怎么摆放,它只负责数据从哪里来、怎么变、什么时候保存。先看最基础的加载和持久化逻辑。

const STORAGE_KEY = 'qingshun-quick-capture';

const items = ref<青瞬速记Item[]>(loadItems());
const activeId = ref(items.value[0]?.id ?? '');

function persist() {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(items.value));
}

function loadItems() {
  const raw = localStorage.getItem(STORAGE_KEY);
  return raw ? JSON.parse(raw) : seed青瞬速记Items;
}

这段代码里,STORAGE_KEY 必须独立命名,不能写成普通的 notes。因为系列里不同工具都可能有本地数据,如果 key 太泛,很容易互相读到对方的数据。qingshun-quick-capture 这个名字能同时表达工具主题和数据用途,后面排查 localStorage 时也更直观。

items 使用 ref 保存速记数组,初始化时调用 loadItems()。这说明页面打开后先尝试读取用户上次保存的数据,如果本地没有内容,再回退到 seed 数据。这样第一次打开不会空白,第二次打开也不会丢失上次记录。

activeId 保存当前选中条目的 id。它默认取第一条数据的 id,如果数组为空就回退成空字符串。这里用 items.value[0]?.id ?? '' 是为了避免数组为空时报错,让当前选择始终有一个明确状态。

persist() 把当前 items.value 序列化成 JSON 字符串,然后写入 localStorage。localStorage 只能存字符串,所以需要 JSON.stringify。这是一种很适合第一版轻量工具的保存方式:不需要服务端,不需要数据库,也不需要复杂权限。

loadItems() 则做相反的事情。它先从 localStorage 读取字符串,如果读到了,就用 JSON.parse 还原成数组;如果没读到,就返回 seed 数据。实际项目里可以在这里加 try/catch,防止用户手动改坏本地存储导致解析失败。文章里先展示核心路径,方便看清楚加载与保存的关系。

这一段代码的关键是把“本地优先”和“默认兜底”同时做好。桌面小工具不能要求用户手动保存,也不能每次刷新都回到默认示例。只要用户做了记录,下一次打开还在,这个工具才会给人可靠感。

六、新建、更新和归档要集中在状态层

有了加载和保存,还需要处理用户动作。青瞬速记至少要能新建条目、更新条目和归档条目。它们都应该放在状态层,而不是分散到不同组件里。

function createItem() {
  const item: 青瞬速记Item = {
    id: `qingshun-${Date.now()}`,
    hook: '新的灵感',
    source: '临时记录',
    mood: '未标记',
    stage: '待整理',
    nextAction: '补充这条灵感的下一步',
  };

  items.value = [item, ...items.value];
  activeId.value = item.id;
  persist();
}

function updateItem(nextItem: 青瞬速记Item) {
  items.value = items.value.map(item =>
    item.id === nextItem.id ? nextItem : item
  );
  persist();
}

function archiveItem(id: string) {
  items.value = items.value.map(item =>
    item.id === id ? { ...item, stage: '已归档' } : item
  );
  persist();
}

createItem() 的目标是让新建动作尽可能轻。它直接构造一条默认 item,并把 stage 设为“待整理”。这符合速记工具的真实状态:刚创建的内容往往还没有被整理,不应该默认就是完成状态。

新条目的 id 用 Date.now() 拼出来,第一版足够使用。如果项目对唯一性要求更高,可以换成 crypto.randomUUID()。这里的重点不在 id 生成算法,而在于新建后必须拥有稳定 id,否则后续选择、更新、归档都会缺少依据。

items.value = [item, ...items.value] 把新条目放到列表最前面。速记工具的最新内容应该立刻可见,如果把新内容放到数组最后,用户还要滚动才能找到,体验就变重了。紧接着把 activeId 设置为新条目的 id,表示新建后自动选中,用户可以马上继续编辑。

updateItem() 使用 map 来更新数组。它遍历每一项,如果 id 和 nextItem 一致,就替换成新对象;否则保留旧对象。这种写法不会依赖数组下标,也不会直接在原数组里乱改,响应式更新更清楚。

archiveItem() 没有删除数据,而是把 stage 改成“已归档”。这是一个产品取舍:灵感条目可能以后还要找回,直接删除太激进;归档则表示从活跃视图里收起来,但内容仍然存在。后续筛选时,只要根据 stage 区分 active 和 archived 即可。

三个函数最后都调用 persist()。这点非常重要。青瞬速记是轻工具,用户不应该额外点击保存;新建、更新、归档这些动作完成后,本地数据就应该同步落下。自动保存是这类工具的基本体验。

七、当前条目和可见列表都用 computed 派生

状态层里除了原始数据,还会有派生数据。比如当前选中条目不是单独存一份,而是根据 activeId 从 items 里找出来;可见列表也不是新数组状态,而是根据搜索词和筛选条件计算出来。

const currentItem = computed(() => {
  return items.value.find(item => item.id === activeId.value) ?? null;
});

function selectItem(id: string) {
  activeId.value = id;
}

currentItem 通过 computed 派生出来,它依赖 itemsactiveId。这样当前条目永远来自同一份数组数据,不会出现“列表里是一份、编辑器里又是一份”的情况。找不到条目时返回 null,编辑器就可以显示空状态或暂时不渲染表单。

selectItem() 只做一件事:更新 activeId。它不直接传整个 item,也不复制对象。这是为了让选择逻辑保持轻。只要 activeId 变化,currentItem 会自动重新计算,依赖 currentItem 的编辑器也会随之更新。

下面再看搜索和排序。

const keyword = ref('');
const filter = ref<青瞬速记Filter>('all');

const visibleItems = computed(() => {
  const text = keyword.value.trim().toLowerCase();

  return items.value
    .filter(item => {
      if (filter.value === 'active') return item.stage !== '已归档';
      if (filter.value === 'archived') return item.stage === '已归档';
      return true;
    })
    .filter(item => {
      const haystack = [
        item.hook,
        item.source,
        item.mood,
        item.stage,
        item.nextAction,
      ].join(' ').toLowerCase();

      return haystack.includes(text);
    })
    .sort((a, b) => String(b.id).localeCompare(String(a.id)));
});

这段代码把搜索、筛选和排序都放在 visibleItems 里。模板只需要遍历 visibleItems,不需要知道筛选细节。这样逻辑集中在状态层,页面会更干净。

第一层 filter 处理归档状态。filter 为 active 时,只显示 stage 不是“已归档”的条目;filter 为 archived 时,只显示 stage 等于“已归档”的条目;filter 为 all 时不过滤。这比在组件里写多个 if 更容易维护。

第二层 filter 处理关键字搜索。这里没有用 JSON.stringify(item),而是手动把 hook、source、mood、stage、nextAction 拼成搜索文本。这样搜索范围更可控,也避免字段名本身参与匹配。比如用户搜“待整理”,能搜到 stage;搜“产品文章”,能搜到 source。

最后的 sort 按 id 倒序排列,让新条目靠前。实际项目里更推荐增加 createdAtupdatedAt 字段再排序,但第一版如果 id 里带有时间戳,这样已经可以表达“新内容优先”的方向。

这段 computed 的价值在于:原始数据保持简单,展示数据按需要派生。后续如果要增加 pinned、source 分组、mood 筛选,都可以继续扩展 visibleItems,而不用让 NoteSidebar 承担越来越多业务判断。

八、Home.vue 只做页面编排

Home.vue 是页面入口,但它不应该变成所有逻辑的大杂烩。它适合负责布局和组件连接,把状态层返回的数据和方法传给子组件。

<template>
  <main class="qingshun_quick_capture-page">
    <NoteToolbar
      :current-item="currentItem"
      @create="createItem"
      @copy="copyCurrent"
      @export="exportCurrent"
      @archive="archiveCurrent"
    />

    <section class="workspace">
      <NoteSidebar
        :items="visibleItems"
        :active-id="activeId"
        @select="selectItem"
      />

      <NoteEditor
        :item="currentItem"
        @update="updateItem"
      />
    </section>
  </main>
</template>

这段模板把页面分成工具栏和工作区。工具栏通过事件触发 create、copy、export、archive 等动作;工作区里左侧是 NoteSidebar,右侧是 NoteEditor。Home.vue 负责把它们接起来,但不直接处理 localStorage,也不直接调用剪贴板。

NoteToolbar 接收 currentItem,是为了根据当前是否选中条目决定按钮状态。例如没有当前条目时,复制和导出按钮应该禁用。它通过事件通知父组件,而不是自己直接操作数据,这样工具栏就不会和状态层绑死。

NoteSidebar 接收 visibleItems 和 activeId。visibleItems 已经在状态层算好,所以侧栏只需要展示;activeId 用来高亮当前选中的条目。用户点击列表项时,侧栏发出 select 事件,Home 再调用 selectItem 更新 activeId。

NoteEditor 接收 currentItem,并通过 update 事件把修改后的条目交回父组件。它不需要知道整个列表,也不需要知道搜索条件。这个边界让编辑器更纯粹:它只负责编辑当前条目。

整个 Home.vue 的逻辑可以总结成一句话:状态层给数据和方法,Home 负责把它们分配给组件,组件通过事件把用户动作传回来。这个结构比把所有东西都写在一个页面文件里更稳。

九、侧栏负责找内容,不负责改内容

NoteSidebar 的职责是让用户快速找到想法。它应该显示 hook、source、stage 这些能帮助识别条目的信息,但不应该在侧栏里处理复杂编辑。

<template>
  <aside class="note-sidebar">
    <button
      v-for="item in items"
      :key="item.id"
      class="note-card"
      :class="{ active: item.id === activeId }"
      @click="$emit('select', item.id)"
    >
      <strong>{{ item.hook }}</strong>
      <span>{{ item.source }}</span>
      <em>{{ item.stage }}</em>
    </button>
  </aside>
</template>

这段代码里,侧栏用 v-for 遍历 items,每条速记渲染成一个按钮。用 button 而不是 div,是因为它本质上是可点击的选择项,键盘和可访问性也更自然。

:key="item.id" 是列表渲染必须注意的点。Vue 需要稳定 key 来判断哪些节点可以复用。这里不能用数组下标,因为列表会筛选和排序,下标变化后容易导致错误复用。id 才是每条数据真正稳定的标识。

:class="{ active: item.id === activeId }" 用来标记当前选中项。activeId 从 Home 传进来,侧栏自己不保存当前选中状态。这样当前选择始终由状态层控制,侧栏只是展示结果。

点击卡片时发出 select 事件,把 item.id 传给父组件。侧栏不直接修改 activeId,因为 activeId 属于状态层。这个边界能避免状态分散:谁管理数据,谁负责修改数据;展示组件只负责发出用户意图。

卡片里显示 hook、source、stage 三个信息。hook 是主标题,source 帮助回忆来源,stage 告诉用户这条是否还需要整理。侧栏不显示 nextAction,是为了保持卡片轻;下一步动作更适合放在编辑器或详情区。

十、编辑器要像速记补全,而不是正式表单

NoteEditor 不是复杂资料表单,它的第一目标是让用户快速补全当前条目。hook 必须显眼,source 和 nextAction 要容易填写,stage 可以用简单输入或选项控件实现。

<script setup lang="ts">
const props = defineProps<{ item: 青瞬速记Item | null }>();
const emit = defineEmits<{ update: [item: 青瞬速记Item] }>();

function patchItem(partial: Partial<青瞬速记Item>) {
  if (!props.item) return;
  emit('update', { ...props.item, ...partial });
}
</script>

<template>
  <form v-if="item" class="editor-form">
    <label>
      核心句
      <input
        :value="item.hook"
        @input="patchItem({ hook: ($event.target as HTMLInputElement).value })"
      />
    </label>

    <label>
      下一步
      <textarea
        :value="item.nextAction"
        @input="patchItem({ nextAction: ($event.target as HTMLTextAreaElement).value })"
      />
    </label>
  </form>
</template>

这段代码没有直接用 v-model="item.hook" 修改 props,而是通过 patchItem 发出 update 事件。这样写比直接改 props 更规范:子组件不直接改变父组件传进来的对象,而是把“我想更新什么”告诉父组件,由状态层统一更新。

patchItem 接收 Partial<青瞬速记Item>,表示只传需要修改的字段。例如输入 hook 时只传 { hook: nextValue },输入 nextAction 时只传 { nextAction: nextValue }。函数内部用 { ...props.item, ...partial } 合成完整对象,再通过 emit 发出去。

if (!props.item) return 是空状态保护。当前没有选中条目时,编辑器不应该报错。模板里也用了 v-if="item",保证没有 item 时不渲染表单。双重保护能让组件在列表为空或数据还没加载时更稳定。

输入框绑定 hook,因为 hook 是最核心的速记内容。textarea 绑定 nextAction,因为下一步可能比一句话长一点。source、mood、stage 也可以继续补进表单,但第一版演示时先抓住最关键的两个字段,避免编辑器显得太重。

这个组件的设计重点是“局部更新、父层保存”。编辑器不管 localStorage,不管复制,不管导出,也不管列表排序。它只把用户输入转换成 update 事件,这样边界非常清楚。

十一、工具栏只放主流程动作

青瞬速记的工具栏不能做成按钮仓库。第一版只保留新建、复制、导出、归档这几类动作。它们都围绕速记条目的生命周期:创建、分享、输出、收起。

<template>
  <header class="note-toolbar">
    <button type="button" @click="$emit('create')">快速记录</button>
    <button type="button" :disabled="!currentItem" @click="$emit('copy')">复制摘要</button>
    <button type="button" :disabled="!currentItem" @click="$emit('export')">导出 Markdown</button>
    <button type="button" :disabled="!currentItem" @click="$emit('archive')">归档</button>
  </header>
</template>

这段工具栏代码很简单,但它表达了几个重要边界。第一,工具栏不直接调用 createItem、copyCurrent 这些函数,而是通过 emit 把动作交给父组件。第二,复制、导出、归档都依赖 currentItem,所以没有当前条目时按钮应该禁用。第三,按钮文案直接服务使用动作,不写抽象词。

“快速记录”放在第一个,是因为新建是速记工具最重要的入口。“复制摘要”和“导出 Markdown”放在中间,是因为它们负责让内容流出工具。“归档”放在最后,是因为它表示当前条目阶段变化,不应该比记录动作更抢眼。

工具栏第一版不加设置、同步、统计和模板按钮。不是这些功能没用,而是它们不属于第一层主流程。轻工具的顶部动作越克制,用户越容易把注意力放回内容本身。

十二、复制摘要要做成纯函数

复制摘要不应该直接散落在工具栏里。更好的做法是先写一个纯函数,把当前条目转换成摘要文本,再由桥接层负责复制。

function build青瞬速记Summary(item: 青瞬速记Item) {
  return [
    '# 青瞬速记摘要',
    '',
    `- 核心句:${item.hook}`,
    `- 来源:${item.source}`,
    `- 状态:${item.stage}`,
    `- 下一步:${item.nextAction}`,
  ].join('\n');
}

这段函数只做一件事:把 item 转成摘要字符串。它不访问 DOM,不调用剪贴板,也不依赖 Vue 响应式对象。这样它更容易复用,也更容易测试。传入一个 item,就能得到固定格式的 Markdown 文本。

摘要里保留 hook、source、stage、nextAction,而没有放 mood。这个选择是基于使用场景:摘要通常用于发给别人或贴到文档里,核心句、来源、状态和下一步更有行动价值。mood 可以留在完整导出里,不一定出现在短摘要。

用数组加 join('\n') 拼 Markdown,比手写一整段字符串更容易维护。以后如果要增加字段,只需要在数组里加一行;如果要调整顺序,也只需要移动数组项。这个写法很适合轻量文本导出。

十三、桥接层统一处理桌面能力

页面不应该到处判断自己运行在浏览器、Electron 还是 OpenHarmony 宿主环境里。剪贴板和通知都属于桌面能力,应该统一放到桥接层。

export function useNativeBridge() {
  const api = window.ohosBridge ?? window.electronAPI;

  async function copyText(text: string) {
    if (api?.copyText) return api.copyText(text);
    return navigator.clipboard.writeText(text);
  }

  async function notify(message: string) {
    if (api?.notify) return api.notify(message);
  }

  return { copyText, notify };
}

这段代码先从 window.ohosBridgewindow.electronAPI 中选择可用 API。这样做是为了兼容不同宿主环境:在鸿蒙侧可以走 ohosBridge,在 Electron 壳里可以走 electronAPI,在普通浏览器调试时则使用 navigator.clipboard 作为复制兜底。

copyText 是核心能力,所以必须有浏览器兜底。开发阶段直接跑 Vite 时,如果没有这个兜底,前端页面就很难单独调试。navigator.clipboard.writeText(text) 能让复制动作在浏览器里也可用,虽然实际桌面环境里更推荐走宿主注入的能力。

notify 没有强行做浏览器通知兜底,因为浏览器通知涉及权限申请,第一版没有必要把这个问题引进来。通知是增强反馈,复制才是核心动作。这里保持 notify 可用则调用,不可用就静默跳过,能让调试过程更顺。

桥接层返回 { copyText, notify },页面只调用这两个方法,不需要关心底层细节。如果后续新增保存文件、打开目录、窗口置顶等能力,也应该继续在这一层扩展,而不是让组件直接碰原生 API。

十四、导出 Markdown 要保留完整上下文

复制摘要适合快速分享,导出 Markdown 适合完整保存。青瞬速记的导出内容不能只包含 hook,因为想法离开应用后还需要来源、状态和下一步。

function export青瞬速记Markdown(item: 青瞬速记Item) {
  return [
    '# 青瞬速记',
    '',
    '> 由青瞬速记导出,用于保留灵感出现时的上下文。',
    '',
    '## 核心句',
    String(item.hook ?? ''),
    '',
    '## 来源',
    String(item.source ?? ''),
    '',
    '## 状态',
    String(item.stage ?? ''),
    '',
    '## 情绪或强度',
    String(item.mood ?? ''),
    '',
    '## 下一步',
    String(item.nextAction ?? ''),
  ].join('\n');
}

async function exportCurrent() {
  if (!currentItem.value) return;
  const markdown = export青瞬速记Markdown(currentItem.value);
  await bridge.copyText(markdown);
  await bridge.notify('青瞬速记内容已复制为 Markdown');
}

export青瞬速记Markdown 负责把一条速记转换成完整 Markdown。它使用标题、引用说明和多个二级标题组织内容,让导出的文本离开应用后仍然可读。每个字段单独成块,比简单列表更适合后续扩写。

String(item.hook ?? '') 这类写法是防御性处理。虽然 TypeScript 类型里 hook 是 string,但真实本地数据可能来自旧版本,也可能被用户手动改坏。导出时把值统一转成字符串,可以避免 undefined、null 或数字导致拼接异常。

exportCurrent 是动作函数,它先判断 currentItem 是否存在。如果没有当前条目,直接 return,避免空数据导出。存在当前条目时,先生成 Markdown,再调用桥接层复制,最后发通知。这里依然保持了内容生成和桌面能力分离:导出函数只管文本,bridge 只管复制和通知。

如果后续要支持“保存为文件”,这段结构也很好扩展。只需要把 markdown 传给新的 saveFile 方法,export青瞬速记Markdown 本身不用改。把格式化和动作分开,是这段代码最重要的设计点。

十五、复制当前条目和归档当前条目

工具栏触发的是 copy、export、archive 这些动作,真正操作当前条目的函数可以继续放在 Home 或 composable 返回的方法里。下面给出简化写法。

async function copyCurrent() {
  if (!currentItem.value) return;
  const summary = build青瞬速记Summary(currentItem.value);
  await bridge.copyText(summary);
  await bridge.notify('青瞬速记摘要已复制');
}

function archiveCurrent() {
  if (!currentItem.value) return;
  archiveItem(currentItem.value.id);
}

copyCurrent 先判断 currentItem 是否存在,然后调用摘要函数生成文本,再通过 bridge 复制。这里没有在函数里手写摘要格式,因为摘要格式已经由 build青瞬速记Summary 管理。动作函数只负责把几个能力串起来:取当前条目、生成摘要、复制、通知。

archiveCurrent 同样先判断当前条目是否存在,然后把当前 id 交给 archiveItem。它不直接修改 stage,因为归档逻辑已经属于状态层。这样 Home 或工具栏动作层只是调用状态能力,不会重复实现业务规则。

这两个函数体现了一个小原则:动作函数可以串联流程,但不要吞掉各层职责。摘要格式归摘要函数,复制归桥接层,归档归状态层。每层都薄一点,后面改起来才轻。

十六、样式要让记录动作没有压迫感

青瞬速记的视觉方向应该轻、安静、可持续。它不需要强烈装饰,也不需要复杂动效。用户打开它时,应该感觉可以马上写一句话,而不是进入一个需要学习的系统。

.qingshun_quick_capture-page {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  background: #f7f8fb;
  color: #1f2937;
}

.workspace {
  display: grid;
  grid-template-columns: 280px minmax(0, 1fr);
  gap: 16px;
  min-height: 0;
  padding: 16px;
}

.note-card {
  width: 100%;
  text-align: left;
  border: 1px solid #e5e7eb;
  background: #ffffff;
  border-radius: 8px;
  padding: 12px;
}

.editor-form {
  display: grid;
  gap: 14px;
}

页面根类名使用 qingshun_quick_capture-page,这样样式范围足够明确,不会影响其他工具。min-height: 100vh 让页面至少铺满窗口,display: flexflex-direction: column 适合顶部工具栏加下方工作区的结构。

workspace 使用 grid 布局,第一列固定 280px,第二列用 minmax(0, 1fr) 占满剩余空间。这里的 minmax(0, 1fr) 很重要,它能避免长文本把网格撑爆。桌面工具经常会遇到长标题、长链接或长说明,布局要提前处理。

.note-card 使用白底、浅边框和 8px 圆角,视觉上像一个可点击条目,但不会过度装饰。宽度设置为 100%,是为了让整个卡片区域都可点击。text-align: left 确保 button 内文字仍然像普通列表内容,不会默认居中。

.editor-form 使用 grid 和 gap 组织输入控件。表单不需要太花,关键是控件之间有稳定间距,用户能快速看到当前正在编辑什么。速记工具的样式目标不是惊艳,而是降低输入前的阻力。

十七、构建检查确认主题没有串

写完页面和文章后,还需要做构建检查和主题关键词检查。系列工具最容易出现的问题是旧主题残留,比如 key 没换、标题没换、默认数据还是上一篇的内容。

# 在前端应用中执行
npm install
npm run build

rg "qingshun-quick-capture|/quick-capture|青瞬速记" src package.json
rg "TODO|旧标题|测试数据" src

npm install 用来确保依赖完整,已经安装过依赖时可以跳过。npm run build 是基础构建检查,它能暴露类型错误、组件引用错误和打包配置问题。博客文章里写了代码,至少要保证对应项目能构建通过,否则读者照着做会踩坑。

第一条 rg 搜索青瞬速记相关关键词。qingshun-quick-capture 对应本地存储 key 或业务 slug,/quick-capture 对应路由或页面标识,青瞬速记 对应中文主题。它们能帮助确认当前页面确实围绕第 9 篇主题展开。

第二条 rg 搜索风险词。TODO 可能表示还有没收尾的代码,旧标题 可能表示文案没换干净,测试数据 可能表示 seed 仍然像占位符。这些词不一定都代表错误,但发布前应该扫一遍。

这里不写本地项目路径,是为了避免文章把读者注意力拉到机器目录上。真正重要的是命令目的:构建能不能过,主题有没有串,默认内容是不是还像真实速记工具。

十八、一个完整使用流程应该怎么走

把上面的代码串起来,用户流程应该是这样的:打开工具时,状态层先从 localStorage 读取数据;如果没有数据,就展示 seed 条目。页面根据 visibleItems 渲染侧栏,根据 activeId 计算 currentItem,再把 currentItem 交给编辑器。

用户点击“快速记录”时,createItem 创建一条默认处于“待整理”的条目,并把它放到列表最前面。用户输入 hook 或 nextAction 时,编辑器通过 update 事件把修改交给状态层,状态层更新 items 并保存到 localStorage。

用户需要把当前想法发给别人时,可以点“复制摘要”。copyCurrent 会调用 build青瞬速记Summary 生成短文本,再通过 bridge.copyText 复制。用户需要完整保存时,可以点“导出 Markdown”,exportCurrent 会生成包含 hook、source、stage、mood、nextAction 的完整 Markdown。

当一条灵感已经整理完,用户可以点归档。archiveCurrent 调用 archiveItem,把 stage 改成“已归档”。这条内容没有被删除,只是从 active 视图里收起来。后面切换 archived 筛选时仍然能找回。

这条流程很短,但闭环完整。它从记录开始,到保存、回找、复制、导出、归档结束。对于一个轻量桌面工具来说,这比一开始堆很多高级功能更重要。

十九、后续增强要继续保持轻

青瞬速记后面当然可以继续扩展。比如增加全局快捷键,让用户不用切换窗口就能新建;增加置顶小窗,让记录入口更轻;增加 source 分组,让阅读、会议、聊天来源分开查看;增加批量导出,把一组待整理灵感发到写作工具里。

还可以增加 pinned 字段,把近期最重要的想法置顶;增加 updatedAt 字段,让最近编辑内容靠前;增加 stage 快速切换,让用户不用进入编辑器就能把条目标成已扩写或已归档。甚至可以把 nextAction 转成待办,让速记和任务管理产生轻连接。

但这些增强都要先问一个问题:会不会拖慢第一秒记录?如果会,就不要放在第一层入口。如果只是整理阶段才需要,就放到二级操作里。如果只是少数用户需要,就不要占据主界面。

青瞬速记的核心优势是轻。功能可以增加,但轻入口不能丢。否则它就会从“灵感接住器”变成另一个需要管理的系统。

二十、总结

青瞬速记这一版的核心价值,是把创意捕捉这件事落成一个能保存、能回看、能复制、能导出的桌面小工具。它不追求一开始就成为知识库,也不追求一开始就成为写作平台,它先把“突然想到”的瞬间接住。

整篇实现里,类型定义负责统一字段语义,seed 数据负责模拟真实打开状态,useNotes 负责加载、保存、选择和筛选,Home.vue 负责页面编排,Sidebar 和 Editor 分别负责找内容与改内容,Toolbar 负责动作入口,useNativeBridge 负责桌面能力适配,导出函数负责把条目变成可继续使用的 Markdown。

每一块代码后面的解释都不是为了翻译语法,而是为了说明它在这个工具里的位置。轻量工具最重要的不是代码多复杂,而是每个字段、每个按钮、每个状态变化都能回到真实使用动作上。青瞬速记只要守住“先记下来,再慢慢整理”这条主线,第一版就站得住。


相关资源

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐