Skip to content

Commit 3c725ed

Browse files
committed
feat(inbox): 统一收件箱——剪贴板/灵感/导入三路合并 + 24h 去重
1 parent 0ab8f2a commit 3c725ed

3 files changed

Lines changed: 342 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* 统一收件箱数据访问层 — inbox_items 表 IPC 封装
3+
* Unified inbox repository — thin db IPC bridge for inbox_items
4+
*
5+
* @ai-context: 三路来源(clipboard / inspiration / import)统一收件。
6+
* 剪贴板收藏带内容去重(同文本 24h 内不重复入箱,返回既有 id)。
7+
* 非 Electron 环境或 IPC 失败时静默降级,收件箱 UI 不阻塞。
8+
* @ai-context: Three ingestion sources converge here; clipboard capture
9+
* dedupes identical text within 24h. Methods degrade silently outside
10+
* Electron so the inbox UI never breaks.
11+
*/
12+
import type { InboxItem } from '../types';
13+
14+
const TABLE = 'inboxItems';
15+
16+
/** 内容去重窗口(毫秒):同文本 24h 内不重复入箱 */
17+
const DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000;
18+
19+
function getApi(): typeof window.electronAPI | undefined {
20+
return window.electronAPI;
21+
}
22+
23+
/** 从正文派生标题:首行截断 */
24+
function deriveTitle(content: string): string {
25+
const firstLine = content.split('\n')[0]?.trim() ?? '';
26+
return firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
27+
}
28+
29+
/** 读取全部收件箱条目(新→旧) */
30+
export async function listInboxItems(): Promise<InboxItem[]> {
31+
const api = getApi();
32+
if (!api) return [];
33+
try {
34+
const rows = await api.db.query<InboxItem[]>(TABLE, 'getAll');
35+
return (rows ?? []).sort((a, b) => b.created_at.localeCompare(a.created_at));
36+
} catch {
37+
return [];
38+
}
39+
}
40+
41+
/**
42+
* 收藏剪贴板文本到收件箱(去重后写入)。
43+
* @returns { id, duplicated }——duplicated=true 表示 24h 内已存在同文本
44+
*/
45+
export async function captureClipboardText(text: string): Promise<{ id: string; duplicated: boolean } | undefined> {
46+
const trimmed = text.trim();
47+
const api = getApi();
48+
if (!api) return undefined;
49+
if (!trimmed) return undefined;
50+
51+
try {
52+
// 去重:遍历现有条目,命中同文本且在窗口期内则直接返回既有 id
53+
const existing = await listInboxItems();
54+
const now = Date.now();
55+
const dup = existing.find((i) => {
56+
if (i.content !== trimmed) return false;
57+
const age = now - new Date(i.created_at).getTime();
58+
return age >= 0 && age < DEDUP_WINDOW_MS;
59+
});
60+
if (dup) return { id: dup.id, duplicated: true };
61+
62+
const id = crypto.randomUUID();
63+
await api.db.insert(TABLE, {
64+
id,
65+
source: 'clipboard',
66+
title: deriveTitle(trimmed),
67+
content: trimmed,
68+
status: 'new',
69+
created_at: new Date().toISOString(),
70+
});
71+
return { id, duplicated: false };
72+
} catch {
73+
return undefined;
74+
}
75+
}
76+
77+
/** 更新条目状态(settled=已沉淀 / archived=已归档,可逆原则:不物理删除) */
78+
export async function updateInboxStatus(id: string, status: InboxItem['status']): Promise<boolean> {
79+
const api = getApi();
80+
if (!api) return false;
81+
try {
82+
await api.db.update(TABLE, id, { status });
83+
return true;
84+
} catch {
85+
return false;
86+
}
87+
}
88+
89+
/** 删除条目(垃圾清理用,与归档二选一) */
90+
export async function deleteInboxItem(id: string): Promise<boolean> {
91+
const api = getApi();
92+
if (!api) return false;
93+
try {
94+
await api.db.delete(TABLE, id);
95+
return true;
96+
} catch {
97+
return false;
98+
}
99+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/**
2+
* 统一收件箱页 — 三路来源待沉淀项汇聚与操作
3+
* Unified inbox page — convergent tray for pending captures
4+
*
5+
* @ai-context: 展示 inbox_items 全部条目(来源/状态筛选)。剪贴板来源由
6+
* 全局快捷键 Ctrl+Shift+B 实时灌入(capture-clipboard → ShortcutRoot →
7+
* inboxRepository);inspiration/import 来源已具备 schema 与筛选位,
8+
* 待后续管线接线。操作:沉淀(settled)/ 归档(archived,可逆)/ 删除。
9+
* @ai-context: Lists inbox_items with source/status filters. Clipboard items
10+
* flow in live via the global shortcut; inspiration/import sources are
11+
* schema-ready for future pipelines. Actions: settle / archive / delete.
12+
*/
13+
import { useCallback, useEffect, useMemo, useState } from 'react';
14+
import { Inbox, CheckCircle2, Archive, Trash2, Clipboard } from 'lucide-react';
15+
import { useToast } from '@/components/ui';
16+
import RitualHeader from '@/features/inspiration/components/RitualHeader';
17+
import { SOURCE_META, STATUS_META } from '../types';
18+
import type { InboxItem } from '../types';
19+
import { listInboxItems, updateInboxStatus, deleteInboxItem } from '../lib/inboxRepository';
20+
import { cn } from '@/lib/utils';
21+
22+
type SourceFilter = 'all' | InboxItem['source'];
23+
type StatusFilter = 'all' | InboxItem['status'];
24+
25+
export default function InboxPage() {
26+
const { toast } = useToast();
27+
const [items, setItems] = useState<InboxItem[] | null>(null);
28+
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all');
29+
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
30+
31+
const load = useCallback(async () => {
32+
const rows = await listInboxItems();
33+
setItems(rows);
34+
}, []);
35+
36+
useEffect(() => {
37+
void load();
38+
}, [load]);
39+
40+
const filtered = useMemo(() => {
41+
if (!items) return [];
42+
return items.filter(
43+
(i) =>
44+
(sourceFilter === 'all' || i.source === sourceFilter) &&
45+
(statusFilter === 'all' || i.status === statusFilter),
46+
);
47+
}, [items, sourceFilter, statusFilter]);
48+
49+
const handleSettle = async (item: InboxItem) => {
50+
const ok = await updateInboxStatus(item.id, 'settled');
51+
if (ok) {
52+
toast({ type: 'success', message: '已标记为沉淀' });
53+
void load();
54+
}
55+
};
56+
57+
const handleArchive = async (item: InboxItem) => {
58+
const ok = await updateInboxStatus(item.id, 'archived');
59+
if (ok) {
60+
toast({ type: 'info', message: '已归档(可再标记回待沉淀)' });
61+
void load();
62+
}
63+
};
64+
65+
const handleDelete = async (item: InboxItem) => {
66+
const ok = await deleteInboxItem(item.id);
67+
if (ok) {
68+
toast({ type: 'info', message: '已删除' });
69+
void load();
70+
}
71+
};
72+
73+
const counts = useMemo(() => {
74+
const c: Record<string, number> = { all: items?.length ?? 0 };
75+
for (const s of ['clipboard', 'inspiration', 'import'] as const) {
76+
c[s] = items?.filter((i) => i.source === s).length ?? 0;
77+
}
78+
return c;
79+
}, [items]);
80+
81+
return (
82+
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-kb-lg py-kb-xl">
83+
<RitualHeader title="收件箱" note="万物入井 待时而序">
84+
<div className="flex items-center gap-1.5 rounded-full border border-border/40 bg-bg-secondary/60 px-3 py-1.5 text-c1 text-text-tertiary backdrop-blur-xl">
85+
<Clipboard className="w-3.5 h-3.5" />
86+
Ctrl+Shift+B 随时收藏
87+
</div>
88+
</RitualHeader>
89+
90+
{/* ── 筛选栏 ── */}
91+
<div className="flex flex-col gap-2 rounded-2xl border border-border/30 bg-bg-secondary/40 p-3 backdrop-blur-xl">
92+
<div className="flex flex-wrap items-center gap-1.5">
93+
<button
94+
onClick={() => setSourceFilter('all')}
95+
className={cn(
96+
'rounded-full px-3 py-1 text-c1 font-medium transition-colors',
97+
sourceFilter === 'all' ? 'bg-brand-600/20 text-brand-400' : 'text-text-tertiary hover:text-text-secondary',
98+
)}
99+
>
100+
全部 {counts.all}
101+
</button>
102+
{(Object.keys(SOURCE_META) as InboxItem['source'][]).map((s) => (
103+
<button
104+
key={s}
105+
onClick={() => setSourceFilter(sourceFilter === s ? 'all' : s)}
106+
className={cn(
107+
'rounded-full px-3 py-1 text-c1 font-medium transition-colors',
108+
sourceFilter === s ? 'bg-brand-600/20 text-brand-400' : 'text-text-tertiary hover:text-text-secondary',
109+
)}
110+
>
111+
{SOURCE_META[s].label} {counts[s]}
112+
</button>
113+
))}
114+
</div>
115+
<div className="flex items-center gap-1.5">
116+
{(Object.keys(STATUS_META) as InboxItem['status'][]).map((s) => (
117+
<button
118+
key={s}
119+
onClick={() => setStatusFilter(statusFilter === s ? 'all' : s)}
120+
className={cn(
121+
'rounded-full px-2.5 py-0.5 text-c1 transition-colors',
122+
statusFilter === s ? 'text-text-primary underline underline-offset-4' : 'text-text-tertiary hover:text-text-secondary',
123+
)}
124+
>
125+
{STATUS_META[s].label}
126+
</button>
127+
))}
128+
{statusFilter !== 'all' && (
129+
<button onClick={() => setStatusFilter('all')} className="text-c1 text-text-tertiary hover:text-text-secondary">
130+
清除
131+
</button>
132+
)}
133+
</div>
134+
</div>
135+
136+
{/* ── 条目列表 ── */}
137+
{items === null ? (
138+
<div className="space-y-2">
139+
{[0, 1, 2].map((i) => (
140+
<div key={i} className="h-24 animate-pulse rounded-2xl bg-bg-secondary/60" />
141+
))}
142+
</div>
143+
) : filtered.length === 0 ? (
144+
<div className="kb-ritual-empty py-kb-xl">
145+
<Inbox className="mx-auto mb-2 w-8 h-8 text-text-tertiary/40" />
146+
<p className="kb-ritual-empty-title">{items.length === 0 ? '收件箱尚空' : '没有匹配的条目'}</p>
147+
<p className="kb-ritual-empty-note">
148+
{items.length === 0 ? '选中内容后按 Ctrl+Shift+B 收藏到这里' : '换个筛选条件试试'}
149+
</p>
150+
</div>
151+
) : (
152+
<div className="flex flex-col gap-2.5">
153+
{filtered.map((item) => (
154+
<div
155+
key={item.id}
156+
className="flex flex-col gap-2 rounded-2xl border border-border/30 bg-bg-secondary/50 p-4 backdrop-blur-xl transition-colors hover:border-accent-400/40"
157+
>
158+
<div className="flex items-center gap-2">
159+
<span className={`rounded-full px-2 py-0.5 text-c1 font-medium ${SOURCE_META[item.source].badge}`}>
160+
{SOURCE_META[item.source].label}
161+
</span>
162+
<span className={`rounded-full px-2 py-0.5 text-c1 font-medium ${STATUS_META[item.status].badge}`}>
163+
{STATUS_META[item.status].label}
164+
</span>
165+
<span className="ml-auto text-c1 text-text-tertiary">
166+
{new Date(item.created_at).toLocaleString()}
167+
</span>
168+
</div>
169+
{item.title && <p className="text-b2 font-medium text-text-primary">{item.title}</p>}
170+
<p className="line-clamp-3 whitespace-pre-wrap text-c1 text-text-secondary">{item.content}</p>
171+
<div className="flex items-center justify-end gap-1.5">
172+
{item.status === 'new' && (
173+
<button
174+
onClick={() => void handleSettle(item)}
175+
className="flex items-center gap-1 rounded-full bg-emerald-500/10 px-3 py-1 text-c1 font-medium text-emerald-400 transition-colors hover:bg-emerald-500/20"
176+
>
177+
<CheckCircle2 className="w-3.5 h-3.5" /> 标记沉淀
178+
</button>
179+
)}
180+
{item.status !== 'archived' && (
181+
<button
182+
onClick={() => void handleArchive(item)}
183+
className="flex items-center gap-1 rounded-full bg-bg-tertiary px-3 py-1 text-c1 font-medium text-text-tertiary transition-colors hover:text-text-secondary"
184+
>
185+
<Archive className="w-3.5 h-3.5" /> 归档
186+
</button>
187+
)}
188+
{item.status === 'archived' && (
189+
<button
190+
onClick={() => void handleSettle(item)}
191+
className="rounded-full bg-bg-tertiary px-3 py-1 text-c1 font-medium text-text-tertiary transition-colors hover:text-text-secondary"
192+
>
193+
恢复待沉淀
194+
</button>
195+
)}
196+
<button
197+
onClick={() => void handleDelete(item)}
198+
className="flex items-center gap-1 rounded-full bg-bg-tertiary px-3 py-1 text-c1 font-medium text-text-tertiary transition-colors hover:text-semantic-error"
199+
>
200+
<Trash2 className="w-3.5 h-3.5" /> 删除
201+
</button>
202+
</div>
203+
</div>
204+
))}
205+
</div>
206+
)}
207+
</div>
208+
);
209+
}

‎client/src/features/inbox/types.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* 统一收件箱类型 — 与 schema v9 inbox_items 表一一对应
3+
* Unified inbox types — mirror schema v9 inbox_items 1:1
4+
*
5+
* @ai-context: 三路来源(clipboard 剪贴板收藏 / inspiration 灵感 /
6+
* import 知识入籍)统一汇聚;status 三态(new 待沉淀 / settled 已沉淀 /
7+
* archived 已归档)受 schema CHECK 约束,状态迁移可逆。
8+
* @ai-context: Sources are CHECK-constrained in schema; status transitions
9+
* are reversible (archive never deletes).
10+
*/
11+
12+
/** 收件箱条目行结构(snake_case 直通 db IPC) */
13+
export interface InboxItem {
14+
id: string;
15+
source: 'clipboard' | 'inspiration' | 'import';
16+
title: string;
17+
content: string;
18+
status: 'new' | 'settled' | 'archived';
19+
created_at: string;
20+
}
21+
22+
/** 来源元信息(UI 徽章与筛选共用) */
23+
export const SOURCE_META: Record<InboxItem['source'], { label: string; badge: string }> = {
24+
clipboard: { label: '剪贴板', badge: 'bg-cyan-500/15 text-cyan-400' },
25+
inspiration: { label: '灵感', badge: 'bg-amber-500/15 text-amber-400' },
26+
import: { label: '导入', badge: 'bg-violet-500/15 text-violet-400' },
27+
};
28+
29+
/** 状态元信息 */
30+
export const STATUS_META: Record<InboxItem['status'], { label: string; badge: string }> = {
31+
new: { label: '待沉淀', badge: 'bg-emerald-500/15 text-emerald-400' },
32+
settled: { label: '已沉淀', badge: 'bg-blue-500/15 text-blue-400' },
33+
archived: { label: '已归档', badge: 'bg-bg-tertiary text-text-tertiary' },
34+
};

0 commit comments

Comments
 (0)