|
| 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 | +} |
0 commit comments