看完这份完整 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 Pattern3️⃣ 可视化系统层(你现在是散的)
你现在的图表:
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 + 右侧 Drawer2. 页面分层
首页分四层:
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.json4. 首页组件树
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
└─ ArticleBody5. 首页 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 bodyCSS:
.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 linkGitHub 热力图
用途:
展示长期活跃度首页版:
最近 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.astro12. 书架系统设计
数据:
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.astroP0 完成后,首页应该具备:
左栏身份展示
瀑布流 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.astroP1 完成后:
GitHub 仪表盘恢复
书架有完整页面
音乐有完整页面
首页不臃肿20. P2 实现清单
SearchOverlay.astro
ContentTypeDonut.astro
RelatedContentRail.astro
ReadingQueue.astro
ShowcaseDrawer.astro
SocialProfilePage.astroP2 完成后:
首页接近完整 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.json1. Highlight 完整 DOM 容错方案
1.1 不要只存 DOM path
单纯 DOM path 很脆弱。文章加一个标题、换一个组件、Markdown 渲染变化,路径就失效。
工业级做法是 三锚点组合:
Primary:TextQuoteSelector
Secondary:TextPositionSelector
Fallback:DOM Path SelectorW3C 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 pathexport 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 highlightexport 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 flexsearch2.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.json2.3 SearchOverlay 交互规格
快捷键:
Cmd/Ctrl + K:打开搜索
Esc:关闭
↑/↓:移动结果
Enter:打开
Tab:切换分组分组:
All
Articles
Notes
Projects
Books
Music
HighlightsUI:
┌────────────────────────────────────┐
│ 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/musicP1: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 CUI:
Breadcrumb + 时间线🌐 七、Graph View 的“巧思升级”
你现在不是缺 graph,是缺:
graph 的语义🧠 抄 Obsidian + InfraNodus 的点
👉 InfraNodus:
✔ cluster(主题分组)
✔ 中心节点(重要性)
✔ gap(缺失连接)👉 Juggl(非常值得抄)
✔ 节点可自定义形状
✔ 可展开/收起
✔ CSS 控制样式(GitHub)
👉 extended-graph
✔ 节点可以加图片
✔ 按 tag 过滤
✔ focus node(GitHub)
🧠 八、真正值得你抄的开源项目
我筛选了“对你最有用”的👇
1️⃣ Juggl(强烈推荐)
👉 GitHub:
obsidian graph 高级版本特点:
✔ 可控 graph
✔ 样式自定义
✔ 节点语义清晰2️⃣ obsidian-graph(语义 graph)
👉 GitHub:
语义连接(embedding)(GitHub)
3️⃣ knowledge-graph(SQLite + search)
👉 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/modifiersdnd-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 负责回访。