NOTE

blog计划书

2026/05/04 34 min read NOTE BLOG计划书

看完这份完整 README 后,我的判断变了:你不是“缺图表”,而是已有 GitHub 图表资产被新 Feed 首页压缩掉了;现在要做的是把它们从旧 HomeWorkbench 体系里抽出来,变成独立的 Visualization / Showcase 层。

README 里已经明确写了:当前首页已经改成 Profile Rail + Masonry-like Feed + Right Article Drawer,FeedItem 类型也已经包含 post / note / project / book / github / bilibili / update,而且要求新增首页内容必须先抽象成这些 FeedItem,不要回到多模块堆叠首页。

一、图表组件和现有工程怎么协调

你现在的工程应该分成三层:

数据层

派生层

展示层

对应到项目:

apps/web/src/data/
  github-overview.emptyinkpot.json
  books.ts
  music.ts
  profile.ts
 
apps/web/src/lib/
  github.ts
  home.ts
  showcase.ts
  analytics.ts
 
apps/web/src/components/
  visualizations/
  showcase/
  home/

不要把图表逻辑继续塞在 index.astro 里。README 已经说新首页 Feed 是在 apps/web/src/pages/index.astro 组装,GitHub 快照来自 apps/web/src/data/github-overview.emptyinkpot.json,旧的 GitHub 派生入口在 apps/web/src/lib/home.ts

所以协调方式是:

index.astro 只负责排版和喂数据
lib/analytics.ts 负责把原始数据转成图表数据
components/visualizations/* 负责画图
components/showcase/* 负责书架、歌曲、账号展示

二、已有图表资产应该怎么复活

README 已经把旧资产列得很清楚:

图表 现有数据 现有入口 当前状态
GitHub 热力图 overview.months / weeks / totalContributions buildCheckInData() 新 Feed 未展开
GitHub 月度折线图 overview.monthly buildGitHubData() 新 Feed 未展开
GitHub 语言 donut / 饼图 overview.languages buildGitHubData() 新 Feed 未展开
团队图 / 团队信号 overview.profile / repos + 本地团队配置 buildTeamSignals() 新 Feed 未展开
仓库矩阵 overview.repos githubItems 当前首页已展示前 6 个仓库卡片

所以不要重写数据抓取,先做组件抽离:

components/visualizations/
├─ GitHubHeatmap.astro
├─ GitHubLineChart.astro
├─ GitHubLanguageDonut.astro
├─ TeamSignalGraph.astro
├─ RepoMatrix.astro
└─ ChartCard.astro

如果你继续保持 Astro 静态渲染,我建议:

热力图:自己用 div grid 画,不引库
donut:自己用 conic-gradient 画,不引库
团队图:先用静态节点图,不引库
折线图:可以先 SVG 手写;复杂后再用 Recharts

如果你愿意引 React Island,折线图、饼图可以用 Recharts。Recharts 是 React 图表库,官方也提供 ResponsiveContainer 用于响应式图表。(Recharts)

GitHub 数据长期最好通过 GitHub GraphQL API 生成快照,因为 GitHub 官方说明 GraphQL API 适合更精确、灵活地检索 GitHub 数据。(GitHub Docs)

三、首页里图表应该放哪里

不要把所有图表直接摊在首页顶部。推荐三种层级:

首页左栏:只放摘要
Feed:放小型图表卡片
/github 页面:放完整仪表盘

具体:

首页左栏
- 年度贡献数
- 当前主语言
- 最近活跃仓库
- GitHub / Bilibili / 书架入口
 
Feed 图表卡片
- GitHub 热力图 mini
- 月度更新折线图 mini
- 语言占比 donut mini
- 团队信号 mini
 
/github 详情页
- 完整热力图
- 完整折线图
- 语言饼图
- 仓库矩阵
- 团队图
- 自动化信号

这样才不会和你现在的“连续瀑布流”冲突。

四、还需要补多少组件

我建议新增 18 个左右,但不是一次全做。分组如下。

1. 图表基础组件,5 个

ChartCard.astro
MetricBadge.astro
MiniLegend.astro
Sparkline.astro
EmptyChartState.astro

用途:

ChartCard:所有图表统一壳
MetricBadge:显示 total / last updated / active days
MiniLegend:颜色说明
Sparkline:小折线
EmptyChartState:没数据时不要空白

2. GitHub 图表组件,5 个

GitHubHeatmap.astro
GitHubMonthlyLine.astro
GitHubLanguageDonut.astro
GitHubRepoMatrix.astro
GitHubDashboardCard.astro

首页只用 mini 版:

GitHubDashboardCard
  ├─ mini heatmap
  ├─ mini line
  └─ mini donut

完整页用完整版。

3. 团队 / 关系图组件,3 个

TeamSignalGraph.astro
TeamMemberNode.astro
RepoTeamRelationCard.astro

注意:这个“团队图”不要做成真的复杂社交网络图。一开始做成:


├─ repo A
├─ repo B
├─ automation
└─ collaborator / signal

先表达“关系”,不要追求炫技。

4. 书架组件,5 个

BookshelfCard.astro
BookCover.astro
BookGrid.astro
BookDrawer.astro
ReadingStatusBadge.astro

数据建议:

export const books = [
  {
    id: "ddia",
    title: "Designing Data-Intensive Applications",
    author: "Martin Kleppmann",
    cover: "/images/books/ddia.jpg",
    status: "reading",
    rating: 5,
    tags: ["distributed-system", "database"],
    note: "分布式系统基础书。",
  },
];

图片放:

apps/web/public/images/books/

5. 歌曲 / 音乐组件,5 个

MusicCard.astro
AlbumCover.astro
PlaylistGrid.astro
NowPlayingCard.astro
MusicDrawer.astro

数据建议:

export const songs = [
  {
    id: "song-id",
    title: "歌曲名",
    artist: "歌手",
    album: "专辑",
    cover: "/images/music/album.jpg",
    platform: "netease" | "spotify" | "bilibili" | "local",
    href: "https://...",
    mood: ["night", "writing"],
  },
];

图片放:

apps/web/public/images/music/

五、数据模型应该怎么补

Astro 官方推荐用内容集合管理结构化内容,并提供类型检查和查询能力。(Astro 文档)

你现在已有 posts / notes / projects / pages。我建议新增两类:轻数据先用 src/data,长期内容再进 content collections

第一阶段:先用 data 文件

apps/web/src/data/books.ts
apps/web/src/data/music.ts
apps/web/src/data/social.ts
apps/web/src/data/showcase.ts

优点:快、稳、不影响现有 content schema。

第二阶段:升级为 collection

apps/web/src/content/books/
apps/web/src/content/music/
apps/web/src/content/shelf/

当你想给每本书写长笔记时,再升级。

六、FeedItem 应该扩展

README 当前 FeedItem 已有:

post | note | project | book | github | bilibili | update

我建议扩成:

type FeedItem =
  | { type: "post"; drawerId: string }
  | { type: "note"; drawerId: string }
  | { type: "project"; drawerId: string }
  | { type: "book"; id: string }
  | { type: "music"; id: string }
  | { type: "github"; variant: "repo" | "heatmap" | "line" | "language" | "team" }
  | { type: "bilibili"; href: string }
  | { type: "update"; href: string };

这点很重要:图表也要变成 FeedItem
不要让图表另起一套首页布局。

七、页面结构建议

新增这些页面:

/github/
  完整 GitHub 仪表盘
 
/books/
  完整书架
 
/music/
  歌曲 / 歌单 / 专辑墙
 
/social/
  GitHub / Bilibili / 其他账号聚合

首页只展示摘要和入口,不要把所有东西塞满。

八、最终协调方案

你现在应该把首页理解为:

首页 = 展示流
/github = GitHub 数据详情
/books = 书架详情
/music = 音乐详情
/posts/[slug] = 文章完整阅读
右侧 drawer = 快速阅读模式

新增组件总数建议:

P0:8 个
- ChartCard
- GitHubHeatmap
- GitHubLanguageDonut
- GitHubMonthlyLine
- BookshelfCard
- BookCover
- MusicCard
- AlbumCover
 
P1:6 个
- GitHubDashboardCard
- GitHubRepoMatrix
- TeamSignalGraph
- BookGrid
- PlaylistGrid
- ShowcaseDrawer
 
P2:4+ 个
- GitHubFullDashboard
- BookshelfPageSection
- MusicPageSection
- SocialProfilePanel

我的建议是:先做 P0,不要一下子做 18 个。

最稳的第一版效果应该是:

首页:
- 左栏显示 GitHub 总贡献、书架入口、音乐入口、Bilibili 入口
- Feed 混排文章、书籍、歌曲、GitHub 小图表
- 点击文章开右侧 drawer
- 点击书籍/歌曲开小 drawer 或跳详情页
 
/github:
- 展开热力图、折线图、语言 donut、仓库矩阵、团队图

一句话:旧的 GitHub 图表资产不要丢,但也不要全部塞回首页;它们应该升级为 Visualization 组件系统,首页只放 mini 卡片,完整内容放 /github/books/music 这些展示页。现在的问题不是“框架不够高级”,而是“系统设计已经超过了当前实现方式的上限”。

你现在用的是:

Astro(静态 + 少量交互)
+ CSS columns 瀑布流
+ 原生 JS drawer

👉 这套方案 是对的(非常干净),但:

  • 适合:内容站 / 博客 / 轻交互

  • 不适合:你现在要做的「内容平台 + 数据可视化 + 工作台」


🧠 一、你真正缺的不是“更炫库”,而是这三层能力

你现在的目标已经变成:

Content OS UI(内容操作系统界面)

这需要三层能力:

1️⃣ 状态管理层(你现在几乎没有)

当前问题:
- drawer 状态分散
- filter 状态是 DOM 级
- 没有统一 store

👉 结果就是:

  • 信息“碎”

  • 交互“断”

  • UI“像拼的”


2️⃣ 交互系统层(不是组件,而是模式)

你现在是:

组件 = UI块

但应该是:

模式 = 行为 + 状态 + UI

例如:

Drawer Pattern
Feed Pattern
Dashboard Pattern

3️⃣ 可视化系统层(你现在是散的)

你现在的图表:

heatmap / donut / line / team

👉 是:

零散组件 ❌

应该变成:

Analytics System ✔

🧱 二、那要不要换框架?我的判断(很关键)

❌ 不建议:直接推倒 Astro

原因:

  • 你现在 SEO / 内容系统已经很好

  • README 已经明确 Astro 是展示层

  • 你的内容结构是“文件型 content collections”

👉 Astro 是对的


✅ 正确升级路径是:

Astro(外壳)
+ React Island(交互层)
+ Visualization System(图表层)
+ State Store(状态层)

⚙️ 三、真正“高级”的组合(不是噱头)

我给你一套业界比较顶级但不过度复杂的组合👇


🧩 1. 组件层(UI)

推荐:Radix + Tailwind + shadcn/ui

npx shadcn@latest init

👉 优点:

  • 非常“克制”(不会很花)

  • 交互正确(focus / keyboard / accessibility)

  • Drawer / Dialog / Tooltip / Tabs 全都有

👉 你现在的 drawer:

原生 JS ❌ → Radix Dialog ✔

🧠 2. 状态管理(关键升级)

推荐:Zustand(最适合你)

npm i zustand

为什么不是 Redux?

👉 因为你是:

个人项目 + UI状态多 + 不复杂业务逻辑

示例(直接用):

import { create } from "zustand";
 
export const useUIStore = create((set) => ({
  activePost: null,
  filter: "all",
  isDrawerOpen: false,
 
  openPost: (post) =>
    set({ activePost: post, isDrawerOpen: true }),
 
  closeDrawer: () =>
    set({ isDrawerOpen: false }),
 
  setFilter: (filter) =>
    set({ filter }),
}));

👉 解决你现在:

  • drawer 乱

  • filter 分散

  • 状态不可控


📊 3. 图表系统(重点)

推荐分层:

类型 方案
简单图(donut) CSS / SVG
折线图 Recharts
复杂图(团队图) react-force-graph
热力图 自己写(你已有)

推荐库:

npm i recharts
npm i react-force-graph
npm i react-github-calendar

👉 但重点不是库,而是:

ChartCard(统一壳)✔

🧱 4. 布局系统(你现在最大问题)

你现在的问题:

grid + columns 混乱
间距不统一
信息碎

推荐升级:

→ CSS Grid 主布局
→ Masonry 用 @egjs/react-grid(成熟)
npm i @egjs/react-grid

👉 比 columns 更稳定(不会乱高)


🎭 5. 动效(让它“高级”的关键)

不是炫,是“反馈”。

推荐:Framer Motion

npm i framer-motion

用在:

drawer 滑出
卡片 hover
filter 切换
图表加载

🔌 6. 数据层(未来)

你现在是:

JSON snapshot(github-overview)

👉 OK,但未来可以升级:

GitHub GraphQL API

👉 README 里已经明确 analytics 是未来模块:

modules/analytics

🧩 四、最终推荐架构(你可以直接照这个走)

Astro (页面)
└── React Island
    ├── Feed
    ├── Drawer
    ├── Charts
    ├── Bookshelf
    └── Music
 
State (Zustand)
└── UIStore
 
Data
├── content collections
├── github snapshot
├── books.ts
└── music.ts
 
Components
├── ui/ (shadcn)
├── charts/
├── feed/
└── showcase/

🎯 五、什么叫“更高级”?我给你一个判断标准

不是:

❌ 用 Next.js
❌ 用更复杂框架
❌ 用更多库


而是:

✔ 状态统一

所有 UI 行为都能解释

✔ 信息连续

用户不迷路

✔ 组件可复用

ChartCard 可以装任何图

✔ 交互有反馈

点击 → 动画 → 状态变化

✔ 页面像“系统”,不是“拼图”


🧠 最后给你一个直白判断

你现在已经:

70% 工程能力 ✔
30% 系统设计 ❌

而“高级感”其实来自:

系统设计 > 框架选择

如果你要下一步

我可以直接帮你做一件更狠的事:

👉 把你整个首页重构成“真正的 Content OS UI 蓝图”

包括:

  • Feed + Drawer + Chart + Bookshelf + Music 一体设计

  • 每个组件的结构 + 状态 + 交互

  • 直接可以照抄实现

你现在已经不在“写博客”,你在做:

个人内容平台 / 内容操作系统

这一步你已经走对了 👍 下面是 MyBlog 首页 Content OS UI 蓝图
目标不是“更漂亮的博客首页”,而是:

一个连续浏览、可快速阅读、可展示个人知识资产和创作信号的个人内容操作系统首页。

你的 README 当前已经规定首页是:

Profile Rail
+ Masonry-like Feed
+ Right Article Drawer

并且新增内容必须抽象成 post / note / project / book / github / bilibili / update 等 FeedItem,不应该回到多模块堆叠首页。


1. 首页最终形态

┌──────────────────────────────────────────────────────────────┐
│ System Top Bar                                                │
│ 搜索 / 当前状态 / 设置 / 快速入口                              │
├───────────────┬───────────────────────────────┬──────────────┤
│ Profile Rail  │ Main Content Feed             │ Context Dock │
│               │                               │ 可选隐藏      │
│ 个人信息       │ Hero Signal Card              │ 当前筛选      │
│ GitHub摘要     │ Masonry Feed                  │ 当前文章目录  │
│ 书架摘要       │ posts / notes / books / music │ 阅读队列      │
│ 音乐摘要       │ github charts / updates       │              │
│ Bilibili入口   │                               │              │
└───────────────┴───────────────────────────────┴──────────────┘
 
点击文章 / 札记 / 项目:
→ 右侧 Article Drawer 滑出
→ 背景 Feed 保持原位置
→ 点击空白关闭

实际第一版可以先不做 Context Dock,保留:

左栏 280px + 中间 Feed + 右侧 Drawer

2. 页面分层

首页分四层:

L0 Shell Layer
页面骨架、滚动容器、响应式
 
L1 Identity Layer
头像、介绍、GitHub、Bilibili、书架、音乐入口
 
L2 Feed Layer
文章、笔记、项目、书籍、歌曲、GitHub 图表、更新卡片
 
L3 Action / Context Layer
搜索、筛选、阅读抽屉、文章 TOC、复制链接、打开完整页

重点:
所有展示内容都进入 Feed;所有阅读动作都进入 Drawer;所有统计图表都进入 ChartCard。


3. 文件结构

建议新结构:

apps/web/src/pages/
├─ index.astro
├─ github/index.astro
├─ books/index.astro
├─ music/index.astro
└─ social/index.astro
 
apps/web/src/components/home/
├─ HomeShell.astro
├─ HomeTopBar.astro
├─ HomeProfileRail.astro
├─ HomeFeed.astro
├─ HomeFeedToolbar.astro
├─ HomeArticleDrawer.astro
├─ HomeArticleToc.astro
└─ HomeEmptyState.astro
 
apps/web/src/components/feed/
├─ FeedCard.astro
├─ PostFeedCard.astro
├─ NoteFeedCard.astro
├─ ProjectFeedCard.astro
├─ BookFeedCard.astro
├─ MusicFeedCard.astro
├─ GitHubFeedCard.astro
├─ BilibiliFeedCard.astro
└─ UpdateFeedCard.astro
 
apps/web/src/components/charts/
├─ ChartCard.astro
├─ GitHubHeatmap.astro
├─ GitHubMonthlyLine.astro
├─ GitHubLanguageDonut.astro
├─ GitHubRepoMatrix.astro
└─ TeamSignalGraph.astro
 
apps/web/src/components/showcase/
├─ BookCover.astro
├─ BookshelfPreview.astro
├─ MusicPreview.astro
├─ AlbumCover.astro
├─ SocialProfileCard.astro
└─ ExternalAccountCard.astro
 
apps/web/src/lib/
├─ feed.ts
├─ analytics.ts
├─ showcase.ts
├─ github.ts
└─ profile.ts
 
apps/web/src/data/
├─ books.ts
├─ music.ts
├─ socials.ts
└─ github-overview.emptyinkpot.json

4. 首页组件树

index.astro
└─ HomeShell
   ├─ HomeTopBar
   ├─ HomeProfileRail
   │  ├─ ProfileCard
   │  ├─ GitHubMiniStatus
   │  ├─ BookshelfPreview
   │  ├─ MusicPreview
   │  └─ SocialLinks

   ├─ HomeFeed
   │  ├─ HomeFeedToolbar
   │  ├─ HeroSignalCard
   │  ├─ MasonryFeed
   │  │  ├─ PostFeedCard
   │  │  ├─ NoteFeedCard
   │  │  ├─ ProjectFeedCard
   │  │  ├─ BookFeedCard
   │  │  ├─ MusicFeedCard
   │  │  ├─ GitHubFeedCard
   │  │  ├─ BilibiliFeedCard
   │  │  └─ UpdateFeedCard

   └─ HomeArticleDrawer
      ├─ DrawerHeader
      ├─ ArticleMeta
      ├─ HomeArticleToc
      └─ ArticleBody

5. 首页 FeedItem 总模型

export type FeedItem =
  | {
      type: "post";
      id: string;
      title: string;
      summary: string;
      date: string;
      tags: string[];
      href: string;
      drawerId: string;
      cover?: string;
      weight?: number;
    }
  | {
      type: "note";
      id: string;
      title: string;
      summary: string;
      date: string;
      tags: string[];
      href: string;
      drawerId: string;
    }
  | {
      type: "project";
      id: string;
      title: string;
      summary: string;
      status: "active" | "paused" | "archived";
      href: string;
      drawerId?: string;
    }
  | {
      type: "book";
      id: string;
      title: string;
      author: string;
      cover: string;
      status: "reading" | "finished" | "planned";
      note?: string;
      href?: string;
    }
  | {
      type: "music";
      id: string;
      title: string;
      artist: string;
      cover?: string;
      platform?: "netease" | "spotify" | "bilibili" | "local";
      href?: string;
      mood?: string[];
    }
  | {
      type: "github";
      id: string;
      variant: "repo" | "heatmap" | "line" | "language" | "team";
      title: string;
      href?: string;
    }
  | {
      type: "bilibili";
      id: string;
      title: string;
      href: string;
      cover?: string;
      summary?: string;
    }
  | {
      type: "update";
      id: string;
      title: string;
      date: string;
      summary: string;
      href: string;
    };

6. 首页布局 CSS 合同

.home-os {
  min-height: 100vh;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
}
 
.home-os__topbar {
  position: sticky;
  top: 0;
  z-index: 50;
  backdrop-filter: blur(18px) saturate(1.4);
}
 
.home-os__shell {
  width: min(1520px, 100% - 40px);
  margin: 0 auto;
  display: grid;
  grid-template-columns: 280px minmax(0, 1fr);
  gap: 20px;
  min-height: 0;
}
 
.home-os__rail {
  position: sticky;
  top: 76px;
  align-self: start;
  max-height: calc(100vh - 96px);
  overflow: auto;
}
 
.home-os__main {
  min-width: 0;
  min-height: 0;
  overflow-y: auto;
  padding-bottom: 32px;
}
 
.home-feed-grid {
  column-count: 3;
  column-gap: 16px;
}
 
.home-feed-card {
  display: inline-block;
  width: 100%;
  margin: 0 0 16px;
  break-inside: avoid;
  min-width: 0;
}
 
@media (max-width: 1240px) {
  .home-feed-grid {
    column-count: 2;
  }
}
 
@media (max-width: 900px) {
  .home-os__shell {
    display: block;
    width: min(100% - 24px, 720px);
  }
 
  .home-os__rail {
    position: static;
    max-height: none;
    overflow: visible;
    margin-bottom: 16px;
  }
 
  .home-os__main {
    overflow: visible;
  }
 
  .home-feed-grid {
    column-count: 1;
  }
}

7. 首页顶部 TopBar

TopBar 只做四件事:

1. 全站搜索
2. Feed 筛选
3. 当前系统状态
4. 设置入口

结构:

[MyBlog / Content OS] [Search...] [All Posts Notes Books Music GitHub] [Settings]

交互:

输入搜索:实时过滤 Feed
点击分类:只隐藏/显示,不重建 DOM
点击 Settings:进入 /settings/
移动端:搜索折叠为按钮

8. 左侧 Profile Rail

左栏不是装饰,是“身份 + 状态摘要”。

ProfileCard
- avatar
- name
- tagline
- 当前正在做什么
 
GitHubMiniStatus
- total contributions
- active repos
- top language
- 进入 /github
 
BookshelfPreview
- 最近在读 3 本
- 进入 /books
 
MusicPreview
- 最近喜欢 / 正在听
- 进入 /music
 
SocialLinks
- GitHub
- Bilibili
- RSS
- About

左栏原则:

只放摘要,不放完整图表。
完整图表去 /github。
完整书架去 /books。
完整音乐去 /music。

9. Feed 内容节奏

Feed 不是随机混排,要有节奏:

1. Hero Signal Card
2. 最新文章
3. GitHub mini heatmap
4. 书架卡片
5. 项目卡片
6. 音乐卡片
7. 月度折线图
8. 笔记
9. Bilibili 卡片
10. 更新日志

推荐排序算法:

function scoreFeedItem(item: FeedItem) {
  const typeWeight = {
    post: 100,
    note: 82,
    project: 76,
    github: 70,
    book: 64,
    music: 58,
    bilibili: 52,
    update: 48,
  };
 
  return typeWeight[item.type] ?? 0;
}

首页不要一次全是文章,也不要一次全是图表。


10. 文章 Drawer 交互合同

打开:

点击 post / note / project 卡片
→ 阻止默认跳转
→ 打开右侧 drawer
→ 记录触发卡片
→ Feed 滚动位置保持

关闭:

点击空白
按 Esc
点击关闭按钮
移动端关闭按钮

关闭后:

focus 回到触发卡片
不滚动页面
筛选状态保留

Drawer 内:

Header
- 关闭
- 打开完整页
- 复制链接
 
Body
- title
- meta
- tags
- TOC
- article body

CSS:

.home-article-backdrop {
  position: fixed;
  inset: 0;
  z-index: 80;
  background: rgba(15, 15, 20, 0.36);
  backdrop-filter: blur(4px);
}
 
.home-article-drawer {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 90;
  width: min(760px, 100vw);
  height: 100vh;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
}
 
.home-article-drawer__body {
  overflow-y: auto;
  padding: 24px;
}
 
.home-article-reader {
  display: grid;
  grid-template-columns: 180px minmax(0, 1fr);
  gap: 24px;
}
 
.home-article-toc {
  position: sticky;
  top: 20px;
  align-self: start;
  max-height: calc(100vh - 120px);
  overflow: auto;
}
 
@media (max-width: 760px) {
  .home-article-drawer {
    width: 100vw;
  }
 
  .home-article-reader {
    display: block;
  }
 
  .home-article-toc {
    position: static;
    max-height: none;
    margin-bottom: 20px;
  }
}

11. 图表系统设计

所有图表必须用统一外壳:

ChartCard
├─ title
├─ description
├─ primary metric
├─ chart body
├─ legend
└─ action link

GitHub 热力图

用途:

展示长期活跃度

首页版:

最近 12 周 mini heatmap

详情页版:

全年 heatmap + 月份标签 + 总贡献数

组件:

GitHubHeatmap.astro

折线图

用途:

展示月度提交 / 发布 / 更新趋势

首页版:

mini sparkline

详情页版:

完整坐标轴 + 月份 + tooltip

组件:

GitHubMonthlyLine.astro
ContentPublishLine.astro

语言饼图 / Donut

用途:

展示 GitHub language distribution
或者内容类型占比:posts / notes / projects / books / music

组件:

GitHubLanguageDonut.astro
ContentTypeDonut.astro

团队图

用途:

展示你、仓库、自动化、协作信号之间的关系

第一版不要做复杂力导向图,先做静态关系图:

emptyinkpot
├─ MyBlog
├─ OpenClaw
├─ AI Writer
├─ Publish Pipeline
└─ Automation

组件:

TeamSignalGraph.astro

12. 书架系统设计

数据:

export type BookItem = {
  id: string;
  title: string;
  author: string;
  cover: string;
  status: "reading" | "finished" | "planned";
  rating?: number;
  tags?: string[];
  note?: string;
  href?: string;
};

首页展示:

BookFeedCard
- 封面
- 书名
- 作者
- 状态
- 一句话笔记

/books 页面:

Books Page
├─ Reading Now
├─ Finished
├─ Planned
├─ Tags
└─ Book Grid

图片位置:

apps/web/public/images/books/

13. 音乐系统设计

数据:

export type MusicItem = {
  id: string;
  title: string;
  artist: string;
  album?: string;
  cover?: string;
  platform: "netease" | "spotify" | "bilibili" | "local";
  href?: string;
  mood?: string[];
  note?: string;
};

首页展示:

MusicFeedCard
- 专辑封面
- 歌名
- artist
- mood 标签
- 外链

/music 页面:

Music Page
├─ Now Playing / 最近喜欢
├─ Playlists
├─ Albums
├─ Mood Tags
└─ Song Grid

图片位置:

apps/web/public/images/music/

14. Bilibili 展示

不要伪造信息。README 已经说 Bilibili 链接目前集中在 profile.ts,未配置真实主页前只展示占位卡片。

组件:

BilibiliFeedCard.astro
BilibiliProfileCard.astro

状态:

configured:展示主页、视频、简介
empty:显示“待配置 Bilibili 主页”
error:显示“暂时无法加载”

15. 交互状态

每个 Feed 区域都必须有:

loading
empty
error
ready

示例:

type ViewState = "loading" | "empty" | "error" | "ready";

UI 规则:

loading:skeleton
empty:解释为什么空
error:显示可恢复操作
ready:正常展示

16. 首页筛选系统

筛选项:

All
Writing
Projects
Books
Music
GitHub
Bilibili
Updates

映射:

const filterMap = {
  all: ["post", "note", "project", "book", "music", "github", "bilibili", "update"],
  writing: ["post", "note"],
  projects: ["project"],
  books: ["book"],
  music: ["music"],
  github: ["github"],
  bilibili: ["bilibili"],
  updates: ["update"],
};

硬规则:

筛选只改变可见性,不重建 Feed。
筛选不重置 scrollTop。
筛选后如果无结果,显示 EmptyState。

17. 动效规则

只做三类动效:

1. Drawer slide in/out
2. Feed card hover lift
3. Filter fade/scale

禁止:

不要大面积视差
不要让 hover 改变卡片尺寸
不要为了动效改变布局流
不要给图表加过度复杂动画

18. P0 实现清单

第一阶段只做最关键的 12 个组件:

HomeShell.astro
HomeTopBar.astro
HomeProfileRail.astro
HomeFeed.astro
HomeFeedToolbar.astro
HomeArticleDrawer.astro
PostFeedCard.astro
BookFeedCard.astro
MusicFeedCard.astro
GitHubFeedCard.astro
ChartCard.astro
GitHubHeatmap.astro

P0 完成后,首页应该具备:

左栏身份展示
瀑布流 Feed
文章 Drawer
书架卡片
音乐卡片
GitHub mini 图表
筛选不重置滚动
移动端可用

19. P1 实现清单

GitHubMonthlyLine.astro
GitHubLanguageDonut.astro
GitHubRepoMatrix.astro
TeamSignalGraph.astro
BookshelfPreview.astro
MusicPreview.astro
/github/index.astro
/books/index.astro
/music/index.astro

P1 完成后:

GitHub 仪表盘恢复
书架有完整页面
音乐有完整页面
首页不臃肿

20. P2 实现清单

SearchOverlay.astro
ContentTypeDonut.astro
RelatedContentRail.astro
ReadingQueue.astro
ShowcaseDrawer.astro
SocialProfilePage.astro

P2 完成后:

首页接近完整 Content OS
具备搜索、关系、阅读队列、完整社交展示

21. 最终首页体验

用户打开首页后应该是这个感觉:

我先看到这个人是谁、最近在做什么。
然后我可以一直往下滑,看文章、项目、书、音乐、GitHub 活跃度。
看到文章不用跳走,点开右侧抽屉就能读。
读完点空白回到原位置。
想深入看 GitHub / 书架 / 音乐,可以进独立详情页。
整个首页像一个人的内容系统,而不是一堆博客模块。

22. 关键原则

你后续所有首页改动都应该遵守这 7 条:

1. 首页只保留一个主 Feed。
2. 文章阅读走 Drawer,不强制跳页。
3. 图表统一走 ChartCard。
4. 书架、音乐、GitHub 都是 FeedItem。
5. 左栏只放摘要,详情页放完整内容。
6. 筛选不重建 DOM,不重置滚动。
7. 不再恢复旧的多模块堆叠首页。

这就是你的 Content OS UI 首页蓝图

好,这次我们不再只是“UI优化”,而是把“书签”做成一个真正的视觉系统核心。你这个方向其实非常对——只要做好,AI味会直接消失一大半,因为:

你从“组件设计” → 进入“隐喻设计(metaphor design)”

🧠 一、先说清楚:你要的不是“标签”,而是“物件”

你现在的问题是:

标签 = UI元素

但你想要的是:

书签 = 现实世界的物件

👉 这两个完全不同:

UI标签 书签
扁平 有形状
无重量 有“插入感”
信息性 情绪+分类
可复制 有个性

🎯 二、核心设计:真实书签系统(Bookmark Object System)

我们定义一个系统,而不是样式。


1️⃣ 书签的“物理隐喻”

你要模拟的是:

一本书里插着不同颜色的书签

👉 所以书签应该:

✔ 从卡片“顶部插进去”
✔ 有一个“露出部分”
✔ 有颜色区分
✔ 有形状(不是纯矩形)
✔ 有轻微不对称(更真实)

2️⃣ 结构(HTML)

<div class="card">
  <div class="bookmark bookmark--post">
    <span>POST</span>
  </div>
 
  <h2>标题</h2>
  <p>摘要...</p>
</div>

3️⃣ 关键视觉(核心区别)

👉 书签不是“贴在上面”,而是:

“从卡片里面伸出来”

🎨 三、CSS实现(可直接用)


① 基础书签形状

.bookmark {
  position: absolute;
  top: -6px;
  left: 16px;
 
  padding: 6px 10px 8px;
  font-size: 11px;
  letter-spacing: 0.12em;
 
  font-family: var(--font-ui);
  text-transform: uppercase;
 
  background: var(--bookmark-color);
  color: #fff;
 
  border-radius: 2px;
 
  clip-path: polygon(
    0 0,
    100% 0,
    100% 100%,
    50% 85%,
    0 100%
  );
}

👉 这个 clip-path

模拟“书签底部折角”

② 插入感(关键)

.card {
  position: relative;
  overflow: visible;
}
.bookmark {
  box-shadow: 0 2px 4px rgba(0,0,0,0.08);
}

👉 看起来像“插进去 + 压住纸面”


③ 不同分类颜色(你要的文化色)

.bookmark--post {
  --bookmark-color: #6B2D5C; /* 清华紫 */
}
 
.bookmark--project {
  --bookmark-color: #2F5D50; /* 高级绿 */
}
 
.bookmark--book {
  --bookmark-color: #9E2A2B; /* 高级红 */
}
 
.bookmark--music {
  --bookmark-color: #C9A227; /* 王室黄 */
}
 
.bookmark--github {
  --bookmark-color: #3A3A3A;
}

🧠 四、进一步“真实感”的 3 个高级技巧


1️⃣ 轻微旋转(超级关键)

.bookmark {
  transform: rotate(-1.5deg);
}

👉 或随机:

element.style.transform = `rotate(${Math.random() * 2 - 1}deg)`

👉 效果:

从 UI → 手工感

2️⃣ 书签长度不一样

.bookmark--post {
  padding-right: 14px;
}
 
.bookmark--book {
  padding-right: 18px;
}

👉 微差异 = 不像模板


3️⃣ 顶部“压痕”(极高级)

.bookmark::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  background: rgba(0,0,0,0.15);
}

👉 模拟:

纸张压住的阴影

🎨 五、标题系统(和书签绑定)

👉 现在最重要的一步:

书签颜色 = 标题颜色

实现:

.card--post h2 {
  color: #6B2D5C;
}
 
.card--project h2 {
  color: #2F5D50;
}

👉 效果:

视觉识别 = 秒级完成

🧱 六、再给你 3 个“巧思”(很关键)


💡 1. “多书签叠加”(高级)

用于重要文章:

<div class="bookmark bookmark--post"></div>
<div class="bookmark bookmark--highlight"></div>

👉 效果:

像一本被重点标记的书

💡 2. “书签突出边界”

.bookmark {
  left: 12px;
  top: -10px;
}

👉 更明显“插入感”


💡 3. Hover 微交互(不要浮动卡片)

.card:hover .bookmark {
  transform: rotate(0deg) translateY(-1px);
}

👉 很 subtle,但很高级


🧠 七、整体视觉效果会变成什么?

现在你是:

Notion / AI dashboard

改完之后:

📖 一本带书签的研究笔记
📚 一面知识展墙
📜 有人使用过的内容系统

🧠 八、最关键一句话(你一定要记住)

你不是在“给卡片加标签”
你是在“让内容变成可触摸的对象”

如果你要继续进阶(下一步)

我可以帮你做更狠的:

👉 给你一套 “整页像书页排版”的版本(含边距 / 行宽 / 栏宽 / 对齐系统)
👉 或帮你把 文章 Drawer 变成“真正的阅读模式(类似 Kindle / Obsidian)”

这个再往下,就真的不是“博客”,是作品级 UI 了。很好,这一步就进入真正“作品级 UI”了。我们不再做“卡片网站”,而是:

首页 = 展墙
阅读 = 书页

我分两部分给你完整方案👇


📖 Part 1:整页“书页排版系统”(Editorial Layout System)

目标:

像一本排版精良的书,而不是网页

🧠 一、核心原则(必须遵守)

1. 行宽固定(不是铺满)
2. 边距有“书页比例”
3. 内容有“版心”
4. 左右留白是设计的一部分
5. 所有元素对齐“基线系统”

📐 二、版心系统(最关键)

✅ 标准书页比例

:root[data-theme="heritage"] {
  --page-max-width: 760px;
  --page-padding-x: clamp(20px, 6vw, 80px);
  --page-padding-y: clamp(24px, 5vh, 64px);
}

✅ 页面结构

.page {
  display: grid;
  grid-template-columns:
    1fr
    minmax(0, var(--page-max-width))
    1fr;
}
 
.page__inner {
  grid-column: 2;
  padding:
    var(--page-padding-y)
    var(--page-padding-x);
}

👉 效果:

不是居中,而是“有版心”

🪶 三、正文宽度控制(极重要)

.prose {
  max-width: 66ch;
}

👉 为什么是 66ch?

这是阅读最舒适的行宽(书籍标准)

✍️ 四、Typography(书籍级)


1️⃣ 正文

.prose {
  font-family: var(--font-body);
  font-size: 18px;
  line-height: 1.9;
  letter-spacing: 0.01em;
}

2️⃣ 段落

.prose p {
  margin: 1.2em 0;
  text-align: justify;
}

👉 中文建议:

text-justify: inter-ideograph;

3️⃣ 标题(书籍风)

.prose h1 {
  font-size: clamp(32px, 5vw, 48px);
  line-height: 1.2;
  margin-bottom: 0.8em;
}
 
.prose h2 {
  font-size: 24px;
  margin-top: 2.5em;
  padding-top: 1em;
  border-top: 1px solid var(--heritage-line);
}
 
.prose h3 {
  font-size: 20px;
  margin-top: 2em;
}

4️⃣ 首字下沉(可选但非常高级)

.prose p:first-of-type::first-letter {
  float: left;
  font-size: 3em;
  line-height: 1;
  margin-right: 8px;
  font-family: var(--font-display);
}

📏 五、基线系统(隐藏高级感)

.prose * {
  margin-top: 0;
  margin-bottom: 0;
}
 
.prose > * + * {
  margin-top: 1.2em;
}

👉 统一节奏,而不是随意 margin


📐 六、图片排版(像书)

.prose img {
  display: block;
  margin: 2em auto;
  max-width: 100%;
}
 
.prose figure {
  margin: 2em 0;
  text-align: center;
}
 
.prose figcaption {
  font-size: 13px;
  color: var(--heritage-muted);
}

📚 七、段落层级(重点)

.prose blockquote {
  border-left: 4px solid var(--heritage-purple);
  padding-left: 1em;
  margin: 2em 0;
  color: var(--heritage-muted);
}

🪟 八、页面“纸张感”

.page__inner {
  background: var(--heritage-bg);
}

👉 可加:

box-shadow: 0 0 0 1px var(--heritage-line);

📖 Part 2:阅读 Drawer → Kindle / Obsidian 模式

目标:

不是“侧边栏”,而是“阅读空间”

🧠 一、核心变化

现在你:

右侧 panel ❌

要变:

沉浸式阅读层 ✔

🧱 二、Drawer 改造


① 宽度

.home-article-drawer {
  width: min(860px, 100vw);
}

👉 不要窄


② 居中版心

.drawer-content {
  max-width: 760px;
  margin: 0 auto;
  padding: 48px 32px;
}

③ 背景

.home-article-drawer {
  background: var(--heritage-bg);
}

④ 外部遮罩(弱化)

.home-article-backdrop {
  background: rgba(0,0,0,0.15);
}

🧭 三、阅读模式布局

┌──────────────┐
│ header       │
├──────────────┤
│              │
│   article    │
│   (居中)     │
│              │
└──────────────┘

🧭 四、TOC(像 Obsidian)

👉 不要左侧

改成:

浮动 TOC(右上角)

CSS:

.toc {
  position: fixed;
  right: 40px;
  top: 120px;
  width: 200px;
  font-size: 13px;
}

👉 移动端隐藏


🧠 五、阅读体验增强(关键)


1️⃣ 滚动进度

.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 2px;
  background: var(--heritage-purple);
}

2️⃣ 当前标题高亮

.toc a.active {
  color: var(--heritage-purple);
}

3️⃣ 行高优化(阅读模式)

.drawer-content {
  line-height: 1.9;
}

4️⃣ 阅读宽度切换(高级)

[窄] [标准] [宽]

🎯 六、最关键体验(你必须做到)


✔ 打开文章

→ 页面不跳
→ drawer 展开
→ 内容居中
→ 背景弱化

✔ 关闭

→ 回到原位置
→ 无滚动跳动

✔ 阅读时

→ 不被 UI 打扰
→ 像读书

🧠 七、对比一下变化


❌ 现在

一个右侧 panel

✅ 目标

一个“临时展开的书页”

🧠 最后一句(非常关键)

你现在做的转变是:

UI设计 → 阅读体验设计

如果你要再往上走(下一步)

我可以继续帮你做:

👉 Kindle 模式(夜间 / 护眼 / sepia 切换)
👉 Obsidian 风快捷键(J/K 阅读跳转)
👉 “阅读历史 / 标记 / 书签系统”(真正的知识系统)

这一步再做,你这个项目就已经不是博客了,是:

个人知识操作系统(PKOS)
```很好,这一步已经不是 UI 设计了,而是**真正的“阅读系统 / 知识系统”设计**。我给你一套**可以直接实现的完整方案**,而且是互相联动的三块,而不是零散功能。
 
---
 
# 🧠 总体结构(先理解这个)
 
你要做的是一个“阅读子系统”,而不是零散 feature:
 
```text
Reader System
├─ Theme Engine(Kindle 模式)
├─ Navigation Engine(快捷键 / 跳转)
├─ Memory Engine(阅读历史 / 书签 / 标记)

三者共享一个状态核心:

ReaderState

🌙 Part 1:Kindle 模式(主题系统)

不是简单 dark mode,而是:

阅读环境切换系统

🎨 1️⃣ 三种模式定义

type ReaderTheme =
  | "light"   // 当前
  | "sepia"   // 护眼(Kindle风)
  | "dark";   // 夜间

🎨 2️⃣ CSS变量(直接用)

:root[data-reader-theme="light"] {
  --reader-bg: #F5F1E8;
  --reader-text: #1E1B18;
}
 
:root[data-reader-theme="sepia"] {
  --reader-bg: #EFE6D6;
  --reader-text: #2A241E;
}
 
:root[data-reader-theme="dark"] {
  --reader-bg: #1C1A18;
  --reader-text: #E8E4DD;
}

🎨 3️⃣ 应用到 Drawer

.home-article-drawer {
  background: var(--reader-bg);
  color: var(--reader-text);
}

🎛️ 4️⃣ UI 控件(放在 Drawer header)

<div class="reader-theme-switch">
  <button data-theme="light">☀</button>
  <button data-theme="sepia">📜</button>
  <button data-theme="dark">🌙</button>
</div>

🧠 5️⃣ 状态保存

const key = "reader-theme";
 
function setTheme(theme) {
  document.documentElement.dataset.readerTheme = theme;
  localStorage.setItem(key, theme);
}
 
function initTheme() {
  const saved = localStorage.getItem(key) || "light";
  setTheme(saved);
}

⌨️ Part 2:Obsidian 风快捷键

目标:

不用鼠标也能完整浏览内容

🎯 1️⃣ 快捷键定义

J → 下一个卡片
K → 上一个卡片
Enter → 打开文章
Esc → 关闭 Drawer
H → 回首页顶部
G G → 跳顶部(Obsidian习惯)

🧠 2️⃣ 核心状态

let activeIndex = 0;
let cards = [];

🧠 3️⃣ 初始化

cards = Array.from(document.querySelectorAll("[data-feed-card]"));

🎯 4️⃣ 高亮当前卡片

.feed-card--active {
  outline: 2px solid var(--heritage-purple);
}

⚙️ 5️⃣ 控制逻辑

function setActive(index) {
  cards[activeIndex]?.classList.remove("feed-card--active");
 
  activeIndex = Math.max(0, Math.min(index, cards.length - 1));
 
  const el = cards[activeIndex];
  el.classList.add("feed-card--active");
 
  el.scrollIntoView({
    block: "center",
    behavior: "smooth",
  });
}

🎹 6️⃣ 键盘监听

document.addEventListener("keydown", (e) => {
  if (document.documentElement.classList.contains("drawer-open")) return;
 
  if (e.key === "j") setActive(activeIndex + 1);
  if (e.key === "k") setActive(activeIndex - 1);
 
  if (e.key === "Enter") {
    const btn = cards[activeIndex].querySelector("[data-open-drawer]");
    btn?.click();
  }
 
  if (e.key === "g") {
    if (window._gPressed) {
      window.scrollTo({ top: 0, behavior: "smooth" });
      window._gPressed = false;
    } else {
      window._gPressed = true;
      setTimeout(() => (window._gPressed = false), 300);
    }
  }
});

🧠 Part 3:阅读历史 / 书签 / 标记系统(核心)

这是最重要的部分。


📚 1️⃣ 阅读历史(History)


数据结构

type HistoryItem = {
  id: string;
  title: string;
  timestamp: number;
};

保存

function saveHistory(item) {
  const key = "reading-history";
  const list = JSON.parse(localStorage.getItem(key) || "[]");
 
  const updated = [item, ...list.filter(i => i.id !== item.id)].slice(0, 50);
 
  localStorage.setItem(key, JSON.stringify(updated));
}

使用

打开 drawer 时调用:

saveHistory({
  id: articleId,
  title: articleTitle,
  timestamp: Date.now(),
});

🔖 2️⃣ 书签系统(Bookmark)


数据结构

type Bookmark = {
  id: string;
  note?: string;
  createdAt: number;
};

UI(文章内)

<button data-bookmark>🔖</button>

保存

function toggleBookmark(id) {
  const key = "bookmarks";
  const list = JSON.parse(localStorage.getItem(key) || "[]");
 
  const exists = list.find(b => b.id === id);
 
  const updated = exists
    ? list.filter(b => b.id !== id)
    : [...list, { id, createdAt: Date.now() }];
 
  localStorage.setItem(key, JSON.stringify(updated));
}

状态显示

function isBookmarked(id) {
  const list = JSON.parse(localStorage.getItem("bookmarks") || "[]");
  return list.some(b => b.id === id);
}

✏️ 3️⃣ 文本标记(Highlight)(高级)


思路(简化版)

选中文本 → 保存 range → 渲染为高亮

数据结构

type Highlight = {
  articleId: string;
  text: string;
  offset: number;
};

简单实现(可先做)

document.addEventListener("mouseup", () => {
  const selection = window.getSelection();
  const text = selection.toString();
 
  if (text.length > 10) {
    console.log("highlight:", text);
    // 存储
  }
});

👉 后续再做 DOM range mapping(复杂一点)


🧠 4️⃣ 阅读侧边栏(关键 UI)

你应该加一个:

Reading Panel

内容:

📖 最近阅读
🔖 收藏
📝 标记

结构:

<aside class="reading-panel">
  <section>History</section>
  <section>Bookmarks</section>
</aside>

🎯 最终效果

你将得到:


🧠 使用体验

J/K 浏览 → Enter 打开 → 阅读 → 标记 → 收藏 → Esc 返回

📖 阅读体验

☀ 白天
📜 护眼
🌙 夜间

🧩 知识系统

文章不是“读完就结束”
而是:
→ 被记录
→ 被收藏
→ 被标记
→ 被回访

🧠 最关键一句(你已经到了这一步)

你不再是在做博客
你在做一个「个人知识操作系统(PKOS)」

🚀 如果你下一步继续

我可以帮你做:

👉 Highlight 精准定位(像 Kindle)
👉 全局搜索(含笔记 + 高亮)
👉 Graph View(像 Obsidian)

这一步做完,你这个项目就直接进入:

作品级 + 产品级
```很好,这一段开始就进入**真正困难但最有价值的部分**了。我先直接说一句现实判断:
 
👉 这三件事里  
**Highlight 精准定位 = 最难(但最核心)**  
Graph View = 最炫(但可以后做)  
全局搜索 = 最实用(应该先做)
 
我会按**可落地顺序 + 技术难度**帮你设计👇
 
---
 
# 🧠 总架构(你必须先有这个认知)
 
这三块其实共享一个数据核心:
 
```ts
type KnowledgeNode = {
  id: string;          // post/note/book
  title: string;
  type: "post" | "note" | "book";
  content?: string;
  tags?: string[];
};
 
type Highlight = {
  id: string;
  articleId: string;
  text: string;
  anchor: Anchor;      // 关键!
};
 
type Bookmark = {
  id: string;
  articleId: string;
};
 
type GraphEdge = {
  from: string;
  to: string;
  type: "tag" | "link" | "reference";
};

👉 所有功能都围绕这个来:

搜索 → 查 node + highlight
Graph → node + edge
Highlight → node 内定位

🔥 Part 1:Highlight 精准定位(像 Kindle)

⚠️ 先打破一个幻想

你不能用:

❌ 保存文本内容
❌ 保存 index

👉 因为:

  • 文章会变

  • 渲染会变

  • DOM会变


✅ 正确做法:Anchor 定位系统(核心)


🧠 Anchor 结构

type Anchor = {
  path: string[];     // DOM路径
  offset: number;     // 文本偏移
  length: number;     // 高亮长度
  context: string;    // 前后文本(容错)
};

🧱 1️⃣ 获取 Anchor(选中时)

function getAnchor(selection) {
  const range = selection.getRangeAt(0);
  const node = range.startContainer;
 
  return {
    path: getNodePath(node),
    offset: range.startOffset,
    length: range.toString().length,
    context: range.toString().slice(0, 30),
  };
}

🧱 2️⃣ DOM Path(关键)

function getNodePath(node) {
  const path = [];
  while (node && node.parentNode) {
    const index = Array.from(node.parentNode.childNodes).indexOf(node);
    path.unshift(index);
    node = node.parentNode;
  }
  return path;
}

🧱 3️⃣ 重新定位(核心)

function resolveAnchor(anchor, root) {
  let node = root;
 
  for (const i of anchor.path) {
    node = node.childNodes[i];
    if (!node) break;
  }
 
  if (!node) return null;
 
  return {
    node,
    offset: anchor.offset,
  };
}

🧱 4️⃣ 渲染高亮

function highlight(anchor) {
  const { node, offset } = resolveAnchor(anchor, document);
 
  const range = document.createRange();
  range.setStart(node, offset);
  range.setEnd(node, offset + anchor.length);
 
  const span = document.createElement("mark");
  span.className = "highlight";
 
  range.surroundContents(span);
}

🎨 CSS(Kindle 风)

.highlight {
  background: rgba(201,162,39,0.35);
  padding: 2px 0;
}

🧠 容错机制(非常关键)

当 DOM 变了:

👉 fallback:

function fuzzyFind(anchor, root) {
  const text = root.innerText;
  const index = text.indexOf(anchor.context);
 
  if (index === -1) return null;
 
  // approximate mapping
}

🔍 Part 2:全局搜索(含 highlight)

这是最应该先做的。


🧠 搜索目标

✔ 文章标题
✔ 正文
✔ 标签
✔ 高亮内容
✔ 笔记

🧱 方案:Mini Search Engine(前端)

推荐:

npm i flexsearch

🧱 1️⃣ 建索引

import FlexSearch from "flexsearch";
 
const index = new FlexSearch.Document({
  document: {
    id: "id",
    index: ["title", "content", "tags"],
  },
});

🧱 2️⃣ 加入数据

nodes.forEach(node => {
  index.add(node);
});

🧱 3️⃣ 搜索

const results = index.search("MCP");

🧠 加入 Highlight 搜索

highlights.forEach(h => {
  index.add({
    id: h.id,
    content: h.text,
    articleId: h.articleId,
  });
});

🧠 UI(重点)

Search Overlay
├─ 输入框
├─ 分类
│  ├─ Articles
│  ├─ Highlights
│  ├─ Books
├─ 结果列表

🎨 交互(像 Obsidian)

Cmd + K → 打开搜索
↑ ↓ → 选择
Enter → 打开

🌐 Part 3:Graph View(Obsidian 风)


⚠️ 现实判断

Graph View 很炫,但如果数据弱 → 没意义

🧠 Graph 数据来源

节点:
- 文章
- 标签
- 高亮
 
边:
- 文章 ↔ 标签
- 文章 ↔ 文章(链接)
- 高亮 → 文章

🧱 推荐库

npm i react-force-graph

🧱 基础实现

const data = {
  nodes: [
    { id: "post1" },
    { id: "tag:AI" },
  ],
  links: [
    { source: "post1", target: "tag:AI" },
  ],
};

🎨 样式建议(别用默认)

nodeCanvasObject={(node, ctx) => {
  ctx.fillStyle = "#6B2D5C";
  ctx.beginPath();
  ctx.arc(node.x, node.y, 4, 0, 2 * Math.PI);
  ctx.fill();
}}

🧠 高级交互

hover → 高亮邻居
click → 打开文章 drawer
filter → 按 tag / 类型

🧠 最重要的路线(不要乱做)


✅ 正确顺序

1️⃣ 全局搜索(立刻有价值)
2️⃣ Highlight(难但核心)
3️⃣ Graph(最后做)

❌ 错误顺序

先做 Graph → 看起来很炫 → 实际没用

🧠 最后给你一个判断标准

当你完成这些后:


❌ 普通博客

读 → 走

✅ 你的系统

读 → 标记 → 搜索 → 再读 → 连接 → 形成网络

🔥 最关键一句

Highlight 是“记忆”
Search 是“入口”
Graph 是“结构”

如果你下一步要继续

我可以帮你做:

👉 Highlight 完整 DOM 容错方案(工业级)
👉 搜索 UI(直接可用组件)
👉 Graph 的“有意义布局”(不是随机点)

这一步再往下,你这个项目就真的:

不是博客,是 Obsidian + Kindle + GitHub 的融合体
```下面这版是“工程级方案”,不是概念稿。三件事要统一成一个 **Knowledge Layer**,否则 Highlight、Search、Graph 会各做各的,最后变成三套孤岛。
 
你的当前工程已经有 `apps/web`、静态内容集合、首页 Drawer、FeedItem 和本地设置系统,所以第一版建议仍然走 **Astro 静态构建 + 客户端本地知识层 localStorage/IndexedDB**,不要一上来就做后端。README 里也明确了当前内容系统是文件型 content collections,正式内容真源是 `apps/web/src/content/`。
 
---
 
# 0. 总体目标
 
```text
Highlight = 记忆点
Search = 入口
Graph = 结构

最终数据流:

posts / notes / projects / books / music
        ↓ build
knowledge-index.json
        ↓ client
Search Overlay + Reader Drawer + Graph View
        ↓ user actions
localStorage / IndexedDB

highlights / bookmarks / reading history

建议新增目录:

apps/web/src/lib/knowledge/
├─ anchors.ts
├─ highlights.ts
├─ search.ts
├─ graph.ts
├─ storage.ts
└─ types.ts
 
apps/web/src/components/knowledge/
├─ SearchOverlay.tsx
├─ GraphView.tsx
├─ HighlightToolbar.tsx
├─ ReadingMemoryPanel.astro
└─ ReaderCommandHint.astro
 
apps/web/public/data/
└─ knowledge-index.json

1. Highlight 完整 DOM 容错方案

1.1 不要只存 DOM path

单纯 DOM path 很脆弱。文章加一个标题、换一个组件、Markdown 渲染变化,路径就失效。

工业级做法是 三锚点组合

Primary:TextQuoteSelector
Secondary:TextPositionSelector
Fallback:DOM Path Selector

W3C Web Annotation Data Model 本身就是为跨平台、跨资源复用 annotation 设计的,其中 TextQuoteSelector 的思路就是用 exact/prefix/suffix 定位文本片段。(W3C)

1.2 Highlight 数据结构

export type HighlightColor =
  | "gold"
  | "purple"
  | "red"
  | "green"
  | "blue";
 
export type TextQuoteSelector = {
  type: "TextQuoteSelector";
  exact: string;
  prefix: string;
  suffix: string;
};
 
export type TextPositionSelector = {
  type: "TextPositionSelector";
  start: number;
  end: number;
};
 
export type DomPathSelector = {
  type: "DomPathSelector";
  startPath: number[];
  startOffset: number;
  endPath: number[];
  endOffset: number;
};
 
export type HighlightAnchor = {
  quote: TextQuoteSelector;
  position: TextPositionSelector;
  dom: DomPathSelector;
  contentHash: string;
};
 
export type HighlightRecord = {
  id: string;
  articleId: string;
  title: string;
  color: HighlightColor;
  note?: string;
  anchor: HighlightAnchor;
  createdAt: number;
  updatedAt: number;
};

1.3 保存选区:从 Selection 生成三锚点

核心思路:

1. 获取用户选中的 Range
2. 在 article root 中生成全文 textContent
3. 算出选区在全文里的 start/end
4. 保存 exact + prefix + suffix
5. 同时保存 DOM path
export function createHighlightAnchor(root: HTMLElement, range: Range): HighlightAnchor {
  const exact = range.toString();
  const fullText = normalizeText(root.textContent || "");
 
  const start = findRangeTextOffset(root, range.startContainer, range.startOffset);
  const end = start + normalizeText(exact).length;
 
  const prefixStart = Math.max(0, start - 48);
  const suffixEnd = Math.min(fullText.length, end + 48);
 
  return {
    quote: {
      type: "TextQuoteSelector",
      exact: normalizeText(exact),
      prefix: fullText.slice(prefixStart, start),
      suffix: fullText.slice(end, suffixEnd),
    },
    position: {
      type: "TextPositionSelector",
      start,
      end,
    },
    dom: {
      type: "DomPathSelector",
      startPath: getNodePath(root, range.startContainer),
      startOffset: range.startOffset,
      endPath: getNodePath(root, range.endContainer),
      endOffset: range.endOffset,
    },
    contentHash: hashText(fullText),
  };
}
 
function normalizeText(input: string) {
  return input.replace(/\s+/g, " ").trim();
}

1.4 恢复定位:三级 fallback

恢复时按这个顺序:

1. contentHash 相同 → 优先 TextPositionSelector
2. exact + prefix + suffix 能匹配 → 使用 TextQuoteSelector
3. exact 多处出现 → prefix/suffix 消歧
4. 仍失败 → DOM path
5. 仍失败 → fuzzy exact
6. 全失败 → 标记为 orphan highlight
export function resolveHighlight(root: HTMLElement, anchor: HighlightAnchor): Range | null {
  const fullText = normalizeText(root.textContent || "");
  const currentHash = hashText(fullText);
 
  if (currentHash === anchor.contentHash) {
    const byPosition = resolveByTextPosition(root, anchor.position);
    if (byPosition) return byPosition;
  }
 
  const byQuote = resolveByQuote(root, anchor.quote);
  if (byQuote) return byQuote;
 
  const byDom = resolveByDomPath(root, anchor.dom);
  if (byDom) return byDom;
 
  return resolveByFuzzyQuote(root, anchor.quote);
}

1.5 TextQuoteSelector 匹配策略

function resolveByQuote(root: HTMLElement, quote: TextQuoteSelector): Range | null {
  const textNodes = collectTextNodes(root);
  const fullText = textNodes.map((n) => n.nodeValue || "").join("");
 
  const candidates = findAllIndexes(fullText, quote.exact);
 
  if (candidates.length === 0) return null;
 
  const best = candidates
    .map((start) => {
      const end = start + quote.exact.length;
      const prefix = fullText.slice(Math.max(0, start - quote.prefix.length), start);
      const suffix = fullText.slice(end, end + quote.suffix.length);
 
      return {
        start,
        end,
        score:
          similarity(prefix, quote.prefix) * 0.45 +
          similarity(suffix, quote.suffix) * 0.45 +
          0.1,
      };
    })
    .sort((a, b) => b.score - a.score)[0];
 
  if (!best || best.score < 0.65) return null;
 
  return createRangeFromTextOffsets(textNodes, best.start, best.end);
}

1.6 渲染高亮:不要直接 surroundContents

range.surroundContents() 遇到跨节点、嵌套 inline、code、link 很容易抛错。更稳的是:

拆文本节点 → 插入 mark → 保留原结构
export function applyHighlight(root: HTMLElement, record: HighlightRecord) {
  const range = resolveHighlight(root, record.anchor);
  if (!range) return { status: "orphan" as const };
 
  const wrapperClass = `reader-highlight reader-highlight--${record.color}`;
 
  try {
    wrapRangeWithMarks(range, wrapperClass, record.id);
    return { status: "applied" as const };
  } catch {
    return { status: "failed" as const };
  }
}

CSS:

.reader-highlight {
  border-radius: 2px;
  padding: 0.04em 0;
  cursor: pointer;
}
 
.reader-highlight--gold {
  background: rgba(201, 162, 39, 0.34);
}
 
.reader-highlight--purple {
  background: rgba(107, 45, 92, 0.22);
}
 
.reader-highlight--red {
  background: rgba(158, 42, 43, 0.22);
}
 
.reader-highlight--green {
  background: rgba(47, 93, 80, 0.22);
}

1.7 Orphan Highlight 处理

如果文章改动导致无法恢复,不要静默丢失。

UI 显示:

这个标记暂时无法定位
原文摘录:xxxx
操作:重新定位 / 删除 / 保留

数据状态:

type HighlightRenderState = "applied" | "orphan" | "failed";

2. 搜索 UI:直接可用组件

2.1 推荐引擎

第一版用 FlexSearch。它支持浏览器和 Node.js,且支持 document/multi-field search,适合你这种静态站内索引。(GitHub)

安装:

npm i flexsearch

2.2 搜索索引数据

构建期生成:

export type KnowledgeSearchDoc = {
  id: string;
  type: "post" | "note" | "project" | "book" | "music" | "highlight";
  title: string;
  content: string;
  tags: string[];
  href?: string;
  drawerId?: string;
  sourceId?: string;
  updatedAt?: string;
};

输出:

apps/web/public/data/knowledge-index.json

2.3 SearchOverlay 交互规格

快捷键:

Cmd/Ctrl + K:打开搜索
Esc:关闭
↑/↓:移动结果
Enter:打开
Tab:切换分组

分组:

All
Articles
Notes
Projects
Books
Music
Highlights

UI:

┌────────────────────────────────────┐
│ Search everything...        ⌘K     │
├────────────────────────────────────┤
│ All Articles Notes Books Highlights│
├────────────────────────────────────┤
│ result title                       │
│ excerpt with matched text          │
│ tag tag tag                        │
└────────────────────────────────────┘

2.4 React 组件

import { useEffect, useMemo, useState } from "react";
import FlexSearch from "flexsearch";
 
type SearchDoc = {
  id: string;
  type: string;
  title: string;
  content: string;
  tags: string[];
  href?: string;
  drawerId?: string;
};
 
type Props = {
  docs: SearchDoc[];
  onOpenDrawer?: (drawerId: string) => void;
};
 
export function SearchOverlay({ docs, onOpenDrawer }: Props) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [group, setGroup] = useState("all");
  const [activeIndex, setActiveIndex] = useState(0);
 
  const index = useMemo(() => {
    const idx = new FlexSearch.Document({
      document: {
        id: "id",
        index: ["title", "content", "tags"],
        store: ["id", "type", "title", "content", "tags", "href", "drawerId"],
      },
      tokenize: "forward",
    });
 
    docs.forEach((doc) => idx.add(doc));
    return idx;
  }, [docs]);
 
  const results = useMemo(() => {
    if (!query.trim()) return [];
 
    const raw = index.search(query, { enrich: true, limit: 20 });
    const map = new Map<string, SearchDoc>();
 
    raw.forEach((fieldResult: any) => {
      fieldResult.result.forEach((item: any) => {
        const doc = item.doc as SearchDoc;
        if (group === "all" || doc.type === group) {
          map.set(doc.id, doc);
        }
      });
    });
 
    return Array.from(map.values());
  }, [index, query, group]);
 
  useEffect(() => {
    const onKey = (event: KeyboardEvent) => {
      const isCommandK = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k";
 
      if (isCommandK) {
        event.preventDefault();
        setOpen(true);
      }
 
      if (!open) return;
 
      if (event.key === "Escape") setOpen(false);
      if (event.key === "ArrowDown") {
        event.preventDefault();
        setActiveIndex((value) => Math.min(value + 1, results.length - 1));
      }
      if (event.key === "ArrowUp") {
        event.preventDefault();
        setActiveIndex((value) => Math.max(value - 1, 0));
      }
      if (event.key === "Enter") {
        event.preventDefault();
        const doc = results[activeIndex];
        if (!doc) return;
 
        if (doc.drawerId && onOpenDrawer) {
          onOpenDrawer(doc.drawerId);
          setOpen(false);
        } else if (doc.href) {
          window.location.href = doc.href;
        }
      }
    };
 
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, results, activeIndex, onOpenDrawer]);
 
  if (!open) return null;
 
  return (
    <div className="search-shell" role="dialog" aria-modal="true">
      <button className="search-backdrop" onClick={() => setOpen(false)} />
 
      <section className="search-panel">
        <div className="search-input-row">
          <input
            autoFocus
            value={query}
            onChange={(event) => {
              setQuery(event.target.value);
              setActiveIndex(0);
            }}
            placeholder="Search posts, notes, books, highlights..."
          />
          <kbd>Esc</kbd>
        </div>
 
        <nav className="search-tabs">
          {["all", "post", "note", "project", "book", "music", "highlight"].map((item) => (
            <button
              key={item}
              className={group === item ? "is-active" : ""}
              onClick={() => {
                setGroup(item);
                setActiveIndex(0);
              }}
            >
              {item}
            </button>
          ))}
        </nav>
 
        <div className="search-results">
          {results.length === 0 ? (
            <p className="search-empty">输入关键词,搜索文章、笔记、书架、音乐和标记。</p>
          ) : (
            results.map((doc, index) => (
              <button
                key={doc.id}
                className={`search-result ${index === activeIndex ? "is-active" : ""}`}
                onMouseEnter={() => setActiveIndex(index)}
                onClick={() => {
                  if (doc.drawerId && onOpenDrawer) {
                    onOpenDrawer(doc.drawerId);
                    setOpen(false);
                  } else if (doc.href) {
                    window.location.href = doc.href;
                  }
                }}
              >
                <span className={`search-result__type search-result__type--${doc.type}`}>
                  {doc.type}
                </span>
                <strong>{doc.title}</strong>
                <small>{makeExcerpt(doc.content, query)}</small>
              </button>
            ))
          )}
        </div>
      </section>
    </div>
  );
}
 
function makeExcerpt(content: string, query: string) {
  const index = content.toLowerCase().indexOf(query.toLowerCase());
  if (index < 0) return content.slice(0, 120);
  return content.slice(Math.max(0, index - 40), index + 100);
}

2.5 CSS

.search-shell {
  position: fixed;
  inset: 0;
  z-index: 120;
}
 
.search-backdrop {
  position: absolute;
  inset: 0;
  border: 0;
  background: rgba(30, 27, 24, 0.32);
}
 
.search-panel {
  position: relative;
  width: min(720px, calc(100vw - 28px));
  margin: 8vh auto 0;
  background: var(--heritage-bg);
  border: 2px solid var(--heritage-line);
  border-radius: 6px;
  overflow: hidden;
}
 
.search-input-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 12px;
  padding: 14px;
  border-bottom: 1px solid var(--heritage-line);
}
 
.search-input-row input {
  border: 0;
  outline: 0;
  background: transparent;
  font-size: 18px;
  font-family: var(--font-body);
}
 
.search-tabs {
  display: flex;
  gap: 6px;
  padding: 10px 14px;
  border-bottom: 1px solid var(--heritage-line);
  overflow-x: auto;
}
 
.search-tabs button {
  border: 1px solid var(--heritage-line);
  background: transparent;
  padding: 5px 10px;
  border-radius: 3px;
}
 
.search-tabs button.is-active {
  background: var(--heritage-purple);
  color: white;
}
 
.search-results {
  max-height: min(520px, 62vh);
  overflow: auto;
  padding: 8px;
}
 
.search-result {
  width: 100%;
  display: grid;
  gap: 5px;
  padding: 12px;
  border: 0;
  border-radius: 4px;
  background: transparent;
  text-align: left;
}
 
.search-result.is-active {
  background: var(--heritage-paper);
}
 
.search-result__type {
  width: fit-content;
  font-family: var(--font-ui);
  font-size: 11px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--heritage-muted);
}
 
.search-result strong {
  font-family: var(--font-display);
  font-size: 18px;
  line-height: 1.24;
}
 
.search-result small {
  color: var(--heritage-muted);
  line-height: 1.6;
}

3. Graph View:有意义布局,不是随机点

3.1 先定图谱语义

不要直接把所有东西丢进 force graph。要先定义节点等级。

export type GraphNodeType =
  | "self"
  | "collection"
  | "post"
  | "note"
  | "project"
  | "book"
  | "tag"
  | "highlight";
 
export type GraphNode = {
  id: string;
  label: string;
  type: GraphNodeType;
  level: number;
  weight: number;
};
 
export type GraphLink = {
  source: string;
  target: string;
  type: "contains" | "tagged" | "references" | "highlighted" | "related";
  weight: number;
};

3.2 有意义的布局:中心辐射 + 分层

建议布局:

Level 0:self / MyBlog
Level 1:collections
  posts / notes / projects / books / music / github
Level 2:content nodes
  具体文章、书、项目
Level 3:tags / highlights

视觉:

中心:MyBlog
第一圈:内容类型
第二圈:具体内容
第三圈:标签和标记

这比随机力导向图有意义得多。

3.3 构图算法:预计算 radial position

export function buildRadialGraph(nodes: GraphNode[], links: GraphLink[]) {
  const rings = groupBy(nodes, (node) => node.level);
 
  const positioned = nodes.map((node) => {
    const ring = rings[node.level] || [];
    const index = ring.findIndex((item) => item.id === node.id);
    const count = ring.length || 1;
 
    const radius = [0, 140, 320, 520][node.level] ?? 680;
    const angle = (Math.PI * 2 * index) / count - Math.PI / 2;
 
    return {
      ...node,
      x: Math.cos(angle) * radius,
      y: Math.sin(angle) * radius,
      fx: node.level <= 1 ? Math.cos(angle) * radius : undefined,
      fy: node.level <= 1 ? Math.sin(angle) * radius : undefined,
    };
  });
 
  return { nodes: positioned, links };
}

3.4 社区分区:让每类内容在自己的扇区

更高级的布局:按 collection 分扇区。

posts:-120° ~ -30°
notes:-30° ~ 30°
projects:30° ~ 100°
books:100° ~ 170°
music:170° ~ 230°
github:230° ~ 300°

这样图谱不是一团毛线,而是像“知识星盘”。

const sectors = {
  post: [-130, -40],
  note: [-35, 25],
  project: [35, 100],
  book: [110, 170],
  music: [180, 230],
  github: [240, 310],
};
 
function placeInSector(node: GraphNode, index: number, count: number) {
  const [start, end] = sectors[node.type] ?? [0, 360];
  const angleDeg = start + ((end - start) * (index + 0.5)) / count;
  const angle = (angleDeg * Math.PI) / 180;
  const radius = [0, 140, 340, 540][node.level] ?? 640;
 
  return {
    ...node,
    x: Math.cos(angle) * radius,
    y: Math.sin(angle) * radius,
  };
}

3.5 React Force Graph 配置

react-force-graph 支持 2D/3D/VR/AR 图组件,底层使用 d3-force-3d,并支持缩放、拖拽、hover/click 等交互;它也支持 DAG 等结构约束,适合后续扩展。(GitHub)

安装:

npm i react-force-graph-2d

组件:

import ForceGraph2D from "react-force-graph-2d";
 
export function KnowledgeGraph({ data, onOpenNode }) {
  return (
    <ForceGraph2D
      graphData={data}
      backgroundColor="rgba(0,0,0,0)"
      nodeRelSize={4}
      linkDirectionalParticles={0}
      cooldownTicks={80}
      d3VelocityDecay={0.42}
      onNodeClick={(node) => onOpenNode(node)}
      nodeCanvasObject={(node, ctx, globalScale) => {
        const size = getNodeSize(node);
        const color = getNodeColor(node.type);
 
        ctx.beginPath();
        ctx.fillStyle = color;
        ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
        ctx.fill();
 
        const label = node.label;
        const fontSize = Math.max(10, 13 / globalScale);
 
        if (globalScale > 0.65 || node.level <= 1) {
          ctx.font = `${fontSize}px serif`;
          ctx.fillStyle = "#1e1b18";
          ctx.fillText(label, node.x + size + 4, node.y + 4);
        }
      }}
      linkCanvasObjectMode={() => "after"}
      linkColor={(link) => getLinkColor(link.type)}
      linkWidth={(link) => Math.max(0.4, link.weight || 1)}
    />
  );
}
 
function getNodeColor(type) {
  return {
    self: "#6B2D5C",
    collection: "#C9A227",
    post: "#6B2D5C",
    note: "#9E2A2B",
    project: "#2F5D50",
    book: "#9E2A2B",
    tag: "#6B645C",
    highlight: "#C9A227",
  }[type] || "#6B645C";
}
 
function getNodeSize(node) {
  return node.level === 0 ? 9 : node.level === 1 ? 7 : Math.max(3, Math.min(6, node.weight || 3));
}
 
function getLinkColor(type) {
  return {
    contains: "rgba(107,45,92,0.28)",
    tagged: "rgba(47,93,80,0.22)",
    references: "rgba(158,42,43,0.26)",
    highlighted: "rgba(201,162,39,0.34)",
    related: "rgba(107,100,92,0.18)",
  }[type] || "rgba(0,0,0,0.16)";
}

3.6 Graph 交互规则

点击 post/note/project → 打开 Drawer
点击 book/music → 跳详情页或打开展示抽屉
点击 tag → 搜索 Overlay 自动筛 tag
点击 highlight → 打开对应文章并滚到高亮
hover → 只高亮一跳邻居

3.7 不要一开始画太多节点

默认只显示:

self
collections
最近 30 篇内容
top 40 tags
最近 50 highlights

否则图谱会乱。

加三个开关:

[内容] [标签] [标记]

4. 推荐落地顺序

P0:搜索先行

1. 生成 knowledge-index.json
2. 接 SearchOverlay
3. Cmd/Ctrl + K
4. 搜 posts/notes/projects/books/music

P1:Highlight 稳定

1. 保存三锚点
2. Reader Drawer 内渲染 mark
3. orphan 状态
4. 高亮进入搜索

P2:Graph 有意义布局

1. 构建 GraphNode/GraphLink
2. radial sector layout
3. click node 打开 drawer/search
4. 默认限量节点

5. 最终组件关系

Reader Drawer
├─ HighlightToolbar
├─ ReaderThemeSwitch
├─ ReadingProgress
└─ ArticleBody
   └─ applyHighlight()
 
SearchOverlay
├─ knowledge-index.json
├─ local highlights
└─ openDrawer / jumpToHighlight
 
GraphView
├─ graph-data.json
├─ local highlights
└─ openDrawer / searchByTag

最终一句话:Highlight 要用 quote/position/path 三锚点,Search 要统一索引内容和用户标记,Graph 要先做分层和扇区布局;这样它们才会像一个知识系统,而不是三个炫技组件。这一步你已经不只是做功能,而是在设计“可触摸的知识界面(Tangible Knowledge UI)”
我给你一套真正能拉开差距的拟物系统 + 可抄的开源方向 + 设计巧思体系

先说一句关键判断:

你现在缺的不是功能,而是“可记忆的视觉符号系统”

🧠 一、从“UI组件”升级为“知识器物系统”

你已经有:

✔ 书签
✔ 高亮
✔ graph
✔ 搜索

但缺的是:

❌ “物件层”

👉 我给你一个整体体系(很重要)

Knowledge Artifacts(知识器物)
├─ 📌 Bookmark(分类)
├─ 🔖 Highlight(记忆)
├─ 🪵 Tab / Edge(导航)
├─ 🧷 Pin(收藏)
├─ 🪶 Annotation(批注)
├─ 🧿 Seal(印章)
├─ 🧭 Trail(阅读轨迹)

👉 这些全部都可以拟物 + 可视化 + 可交互


🔥 二、你提的“印章系统”——这是 killer feature

这个东西做好,直接:

博客 → 作品

🟥 1️⃣ 印章系统(Seal System)

不是 icon,而是:

“判断 / 评价 / 归类 / 记忆强化”

🎨 视觉设计(重点)

✔ 圆形 / 方印
✔ 轻微不规则边缘
✔ 半透明叠加
✔ 旋转角度(随机)
✔ 压印感(不是贴图)

🧱 HTML

<div class="card">
  <div class="seal seal--excellent">精选</div>
</div>

🎨 CSS(核心)

.seal {
  position: absolute;
  top: 12px;
  right: 12px;
 
  width: 48px;
  height: 48px;
 
  border: 2px solid currentColor;
  border-radius: 50%;
 
  display: flex;
  align-items: center;
  justify-content: center;
 
  font-size: 12px;
  font-family: var(--font-display);
 
  opacity: 0.85;
 
  transform: rotate(-8deg);
 
  background: rgba(255,255,255,0.2);
}

🎨 类型

.seal--excellent {
  color: #9E2A2B;
}
 
.seal--important {
  color: #6B2D5C;
}
 
.seal--insight {
  color: #2F5D50;
}

🧠 使用语义(关键)

精选 → 内容价值高
思考 → 深度内容
未完成 → 草稿感
重读 → 重要内容

👉 这比“like / star”高级一百倍


📌 三、比书签更高级的:边缘标签(Edge Tabs)

现实隐喻:

书页边缘的分类标签

🧱 实现

.edge-tab {
  position: absolute;
  right: -8px;
  top: 40px;
 
  width: 6px;
  height: 28px;
 
  background: var(--color);
  border-radius: 2px;
}

👉 更高级版本

多个 tab 叠加
这篇文章属于:
- 技术
- 设计
- 思考

👉 效果:

像真正的书页分类

🧷 四、Pin(钉子系统)

比 bookmark 更“物理”


🧱 设计

.pin {
  position: absolute;
  top: -6px;
  left: 50%;
 
  width: 10px;
  height: 10px;
 
  background: #333;
  border-radius: 50%;
 
  box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}

👉 用于:

固定内容(置顶)

🪶 五、批注(Annotation)

你现在只有 highlight,还不够。


👉 应该是:

高亮 + 批注 = 思考

UI:

选中 → 浮动菜单
[高亮] [写批注]

渲染:

.annotation {
  border-bottom: 1px dashed var(--color);
}

hover:

显示批注气泡

🧭 六、阅读轨迹(Trail)——极高级

这个非常少人做,但很好用。


👉 概念:

你浏览过的路径

实现:

post A → post B → post C

UI:

Breadcrumb + 时间线

🌐 七、Graph View 的“巧思升级”

你现在不是缺 graph,是缺:

graph 的语义

🧠 抄 Obsidian + InfraNodus 的点

👉 InfraNodus:

✔ cluster(主题分组)
✔ 中心节点(重要性)
✔ gap(缺失连接)

(InfraNodus)


👉 Juggl(非常值得抄)

✔ 节点可自定义形状
✔ 可展开/收起
✔ CSS 控制样式

(GitHub)


👉 extended-graph

✔ 节点可以加图片
✔ 按 tag 过滤
✔ focus node

(GitHub)


🧠 八、真正值得你抄的开源项目

我筛选了“对你最有用”的👇


1️⃣ Juggl(强烈推荐)

👉 GitHub:

obsidian graph 高级版本

特点:

✔ 可控 graph
✔ 样式自定义
✔ 节点语义清晰

2️⃣ obsidian-graph(语义 graph)

👉 GitHub:

语义连接(embedding)

(GitHub)


👉 GitHub:

graph + search 一体

(GitHub)


4️⃣ InfraNodus(思路级)

👉 不是开源重点,但思想非常关键:

✔ 找“知识缺口”
✔ 聚类
✔ 连接生成

🧠 九、你现在真正可以做的 3 个“质变点”


🚀 1️⃣ 书签 → 书签 + 印章

分类 + 评价

🚀 2️⃣ Highlight → Highlight + Annotation

阅读 → 思考

🚀 3️⃣ Graph → Graph + 语义层

连接 → 结构

🎯 十、最关键的一句话

你不是在设计 UI
你在设计“知识如何被看见”

如果你下一步继续(我建议)

我可以帮你做:

👉 印章系统完整交互(拖拽 / 批量 / 管理)
👉 Highlight + Annotation 的完整 UI
👉 Graph 的“主题聚类 + 视觉分区”版本

做到这一步,你这个项目会变成:

不是博客,而是“个人知识界面设计范式”
```可以,这三块应该合成一个 **Knowledge Artifact System**,不要分散实现。
 
## 1. 印章系统:拖拽 / 批量 / 管理
 
印章不是普通标签,而是“人工判断”。建议分三类:
 
```ts
type SealType =
  | "selected"   // 精选
  | "important"  // 重要
  | "insight"    // 洞见
  | "unfinished" // 未完
  | "reread"     // 重读
  | "archive";   // 归档

数据结构:

type Seal = {
  id: string;
  type: SealType;
  label: string;
  color: string;
  shape: "circle" | "square" | "vertical";
};
 
type SealPlacement = {
  sealId: string;
  targetId: string;       // post/note/book/card id
  targetType: "card" | "article" | "highlight";
  x: number;              // 0-1 相对坐标
  y: number;              // 0-1 相对坐标
  rotation: number;
  createdAt: number;
};

交互:

单个卡片:
- hover 卡片 → 显示 “盖章” 按钮
- 点击 → 打开 Seal Palette
- 选择印章 → 盖到卡片右上角
- 拖拽印章 → 调整位置
- 双击印章 → 编辑备注 / 删除
 
批量模式:
- 按住 Shift 或点击“批量管理”
- 多选卡片
- 右侧出现 Batch Bar
- 一次性添加「精选 / 重读 / 未完」
- 支持批量移除某类印章
 
管理页:
/seals
- 查看所有印章
- 新建印章
- 改颜色 / 形状 / 名称
- 查看这个印章标记了哪些内容

视觉 CSS:

.knowledge-seal {
  position: absolute;
  width: 54px;
  height: 54px;
  display: grid;
  place-items: center;
  border: 2px solid currentColor;
  color: var(--seal-color);
  background: rgba(245, 241, 232, 0.34);
  font-family: var(--font-display);
  font-size: 12px;
  line-height: 1.1;
  opacity: 0.82;
  mix-blend-mode: multiply;
  transform: rotate(var(--seal-rotation));
  cursor: grab;
}
 
.knowledge-seal--circle {
  border-radius: 50%;
}
 
.knowledge-seal--square {
  border-radius: 4px;
}
 
.knowledge-seal--vertical {
  width: 34px;
  height: 72px;
  writing-mode: vertical-rl;
}

建议用库:

npm i @dnd-kit/core @dnd-kit/modifiers

dnd-kit 做拖拽,比手写稳定。


2. Highlight + Annotation 完整 UI

Highlight 是“划线”,Annotation 是“想法”。两者必须绑定。

数据结构:

type Annotation = {
  id: string;
  articleId: string;
  highlightId: string;
  body: string;
  color: "gold" | "purple" | "red" | "green";
  tags: string[];
  createdAt: number;
  updatedAt: number;
};

阅读时交互:

选中文本
→ 浮动工具条出现
→ 选颜色 / 写批注 / 加印章 / 复制引用

工具条:

[金] [紫] [红] [绿] | 批注 | 盖章 | 复制

文章侧边批注栏:

右侧 Annotation Rail
- 当前文章所有高亮
- 点击批注 → 滚动到正文位置
- hover 批注 → 正文高亮闪一下
- 可筛选颜色 / 标签 / 印章

DOM:

<div class="reader-selection-toolbar">
  <button data-highlight-color="gold">金</button>
  <button data-highlight-color="purple">紫</button>
  <button data-open-annotation>批注</button>
  <button data-open-seal-palette>盖章</button>
  <button data-copy-quote>复制</button>
</div>

CSS:

.reader-selection-toolbar {
  position: fixed;
  z-index: 140;
  display: flex;
  gap: 6px;
  padding: 6px;
  background: var(--heritage-paper);
  border: 1px solid var(--heritage-line);
  border-radius: 4px;
  box-shadow: 0 8px 24px rgba(30, 27, 24, 0.14);
}
 
.annotation-rail {
  position: fixed;
  right: 24px;
  top: 96px;
  width: 260px;
  max-height: calc(100vh - 140px);
  overflow: auto;
}
 
.annotation-card {
  padding: 12px;
  border: 1px solid var(--heritage-line);
  background: var(--heritage-paper);
  border-radius: 4px;
}

编辑弹层:

Annotation Composer
- 引用原文
- 批注正文 textarea
- 标签输入
- 颜色
- 保存 / 删除

快捷键:

H:高亮
A:添加批注
B:添加书签
S:盖章
Esc:关闭工具条

3. Graph:主题聚类 + 视觉分区

不要做随机星云。你的 Graph 应该像“知识地图”。

节点类型:

type GraphNodeType =
  | "self"
  | "cluster"
  | "post"
  | "note"
  | "book"
  | "music"
  | "project"
  | "tag"
  | "highlight"
  | "seal";

边类型:

type GraphEdgeType =
  | "belongs_to"
  | "tagged"
  | "references"
  | "highlighted"
  | "sealed"
  | "related";

布局规则:

中心:MyBlog / emptyinkpot
第一圈:主题簇 cluster
第二圈:内容节点 post/note/book/project
第三圈:tag/highlight/seal

主题簇建议:

Writing / 思考写作
Engineering / 工程项目
Reading / 书架阅读
Media / 音乐视频
Archive / 历史归档
GitHub / 代码活动

视觉分区:

左上:Writing 紫
右上:Engineering 绿
右下:Reading 红
左下:Music 金
中心:Profile / MyBlog

构建算法:

const clusterSectors = {
  writing: [-150, -60],
  engineering: [-40, 40],
  reading: [60, 145],
  media: [155, 230],
  github: [240, 310],
};
 
function placeNodeInCluster(node, index, total) {
  const [start, end] = clusterSectors[node.cluster];
  const angle = ((start + ((end - start) * (index + 0.5)) / total) * Math.PI) / 180;
  const radius = node.level === 1 ? 160 : node.level === 2 ? 360 : 560;
 
  return {
    ...node,
    x: Math.cos(angle) * radius,
    y: Math.sin(angle) * radius,
  };
}

Graph UI:

顶部工具:
[全部] [文章] [书架] [标记] [印章] [仅看精选]
 
左侧:
Cluster list
 
中间:
Graph canvas
 
右侧:
Node Inspector
- 标题
- 类型
- 标签
- 印章
- 相关高亮
- 打开文章 / 打开搜索

交互:

点击 cluster → 只看这个主题区
点击 seal → 显示所有盖过这个印章的内容
点击 highlight → 打开文章 drawer 并滚到高亮
点击 tag → 打开搜索,并自动填入 tag
hover node → 只显示一跳关系

4. 三者联动后的体验

真正高级的是联动:

你读文章
→ 选中一句话
→ 高亮为金色
→ 写批注
→ 盖“洞见”印章
→ 这条高亮进入搜索
→ Graph 里出现 highlight 节点
→ “洞见”印章成为一组可回访的知识集合

这就是系统感。


5. 最小落地顺序

P0
- Highlight Toolbar
- Annotation Composer
- 本地保存 highlights / annotations
 
P1
- Seal Palette
- 卡片盖章
- /seals 管理页
 
P2
- Graph 聚类布局
- seal / highlight 节点进入 Graph
 
P3
- 拖拽印章位置
- 批量盖章
- Graph Inspector

最关键的原则:印章负责判断,高亮负责记忆,批注负责思考,Graph 负责回访。