Show recent canvas edits on the workspace landing page
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Successful in 8s
Unified application CI and deployment / verify-and-release (push) Has been skipped

This commit is contained in:
2026-11-04 17:02:14 +08:00
parent 2bc44c6a9e
commit 48c7ae0e98
3 changed files with 57 additions and 8 deletions
@@ -17,7 +17,7 @@ export default function InfiniteCanvas(){
const pickerSequence=useRef(0),agent=useRef<CanvasAgentHandle>(null);
const active=doc.cards.find(c=>c.id===selected);
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=parseCanvasDocument(raw);setDoc(d);setStatus('已恢复本机草稿')}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
useEffect(()=>{if(!ready)return;try{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
useEffect(()=>{if(!ready)return;try{const serialized=JSON.stringify(doc);const previous=localStorage.getItem(key);if(previous!==serialized){localStorage.setItem(key,serialized);if(previous||doc.cards.length)localStorage.setItem(key+'.editedAt',new Date().toISOString());}setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]);
useEffect(()=>{const el=viewport.current;if(!el)return;const block=(e:WheelEvent)=>{if(!(e.target as HTMLElement).closest('textarea,input,select'))e.preventDefault()};el.addEventListener('wheel',block,{passive:false});return()=>el.removeEventListener('wheel',block)},[]);
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if(e.key==='Escape'){setMenu(null);setRefMenu('');setEditing('');return}if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)});
+37 -7
View File
@@ -18,7 +18,7 @@ const purposes = [
{label: '学习与研究', detail: '梳理资料,记录研究过程。', icon: BookOpen},
];
type Space = {name: string; purpose: string};
type Subcanvas = {id: string; name: string; nodes: string[]};
type Subcanvas = {id: string; name: string; nodes: string[]; editedAt?: string};
const personal = {name: '个人空间', purpose: ''};
export default function Workspace() {
@@ -35,7 +35,8 @@ export default function Workspace() {
const [notice, setNotice] = useState('');
const [nodes, setNodes] = useState<CanvasNode[]>([]);
const [subcanvases, setSubcanvases] = useState<Subcanvas[]>([]);
const [selectedCanvas, setSelectedCanvas] = useState('');
const [selectedCanvas, setSelectedCanvas] = useState('history');
const [draftEditedAt, setDraftEditedAt] = useState<string>();
const [expanded, setExpanded] = useState(true);
const [subsetName, setSubsetName] = useState('');
const [subsetNodes, setSubsetNodes] = useState<string[]>([]);
@@ -85,10 +86,11 @@ export default function Workspace() {
const subsetKey = 'studio.subcanvases:' + email + ':' + active.name;
useEffect(() => {
if (!email) return;
setSelectedCanvas('');
setSelectedCanvas('history');
setQuery('');
try {
const draft = localStorage.getItem('studio.infinite-canvas.v1');
setDraftEditedAt(localStorage.getItem('studio.infinite-canvas.v1.editedAt') || undefined);
setNodes(draft ? parseCanvasDocument(draft).cards : []);
} catch {
setNodes([]);
@@ -113,7 +115,7 @@ export default function Workspace() {
const name = subsetName.trim();
if (!name || !subsetNodes.length) return setError('请输入名称并选择至少一个节点。');
const id = selectedCanvas || window.crypto?.randomUUID?.() || String(Date.now());
const subset = {id, name, nodes: subsetNodes};
const subset = {id, name, nodes: subsetNodes, editedAt: new Date().toISOString()};
const next = [...subcanvases.filter(item => item.id !== id), subset];
try {
localStorage.setItem(subsetKey, JSON.stringify(next));
@@ -163,6 +165,21 @@ export default function Workspace() {
const visibleNodes = subset ? nodes.filter(node => subset.nodes.includes(node.id)) : nodes;
const visibleSubsets = subcanvases.filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
const canvasName = '无限画布';
const showHistory = selectedCanvas === 'history';
const dates = new Intl.DateTimeFormat('zh-CN', {year: 'numeric', month: 'long', day: 'numeric'});
const times = new Intl.DateTimeFormat('zh-CN', {hour: '2-digit', minute: '2-digit', hour12: false});
const entries = [
...(nodes.length || draftEditedAt ? [{id: '', name: canvasName, editedAt: draftEditedAt, summary: nodes[0]?.text || nodes[0]?.title || '本机草稿'}] : []),
...subcanvases.map(item => ({...item, summary: nodes.find(node => item.nodes.includes(node.id))?.text || '子画布'})),
].filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
entries.sort((a, b) => (b.editedAt ?? '').localeCompare(a.editedAt ?? ''));
const history = new Map<string, typeof entries>();
for (const entry of entries) {
const day = entry.editedAt && Number.isFinite(Date.parse(entry.editedAt)) ? dates.format(new Date(entry.editedAt)) : '编辑时间未记录';
const group = history.get(day) ?? [];
group.push(entry);
history.set(day, group);
}
return <div className={s.app}>
<Head><title>工作台 · 啡跃</title></Head>
<nav className={s.rail} aria-label="全局导航">
@@ -194,10 +211,11 @@ export default function Workspace() {
<div className={s.panelTitle}><strong>画布</strong><button type="button" disabled={!nodes.length} onClick={() => {setSelectedCanvas(''); openSubsetDialog();}} aria-label="新建子画布" title="新建子画布"><Plus size={18}/></button></div>
<label className={s.search}><Search size={16}/><input ref={search} type="search" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索画布…" aria-label="搜索画布名称"/></label>
{state === 'ready' && <nav className={s.links} aria-label="画布树">
<button className={s.historyLink} type="button" aria-current={showHistory ? 'page' : undefined} onClick={() => setSelectedCanvas('history')}>编辑历史</button>
<p>本机画布</p>
<div className={s.treeRoot}>
<button type="button" aria-label="展开或折叠子画布" aria-expanded={expanded || !!query} disabled={!!query} onClick={() => setExpanded(!expanded)}>{expanded || query ? <ChevronDown size={15}/> : <ChevronRight size={15}/>}</button>
<button type="button" aria-current={!subset ? 'page' : undefined} onClick={() => setSelectedCanvas('')}><Layers size={16}/>{canvasName}</button>
<button type="button" aria-current={!subset && !showHistory ? 'page' : undefined} onClick={() => setSelectedCanvas('')}><Layers size={16}/>{canvasName}</button>
</div>
{(expanded || query) && <ul className={s.children}>{visibleSubsets.map(item => <li key={item.id}><button type="button" aria-current={item.id === selectedCanvas ? 'page' : undefined} onClick={() => setSelectedCanvas(item.id)}><LayoutGrid size={15}/>{item.name}</button></li>)}</ul>}
{query && !visibleSubsets.length && !canvasName.includes(query.trim()) && <p role="status">没有匹配的画布。</p>}
@@ -206,11 +224,23 @@ export default function Workspace() {
</aside>}
<main className={s.main}>
<header className={s.top}>
<div><button type="button" title="切换导航栏" aria-label="切换导航栏" aria-expanded={sidebar} aria-controls={sidebar ? 'workspace-navigation' : undefined} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button><span>{active.name} / {subset?.name ?? canvasName}</span></div>
<div><button type="button" title="切换导航栏" aria-label="切换导航栏" aria-expanded={sidebar} aria-controls={sidebar ? 'workspace-navigation' : undefined} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button><span>{active.name} / {showHistory ? '编辑历史' : subset?.name ?? canvasName}</span></div>
<ThemeToggle/>
</header>
{state === 'loading' ? <p className={s.status} role="status">正在打开工作台…</p> : state === 'error' ? <p className={s.status} role="alert">暂时无法读取账户。 <a href="/account?next=/workspace">重新登录</a></p> :
<div className={s.content}>
showHistory ? <section className={s.history} aria-labelledby="history-heading">
<header><div><h1 id="history-heading">编辑历史</h1><p>按最近编辑日期排列</p></div><a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a></header>
{Array.from(history, ([day, items]) => <section className={s.historyGroup} key={day}>
<h2>{day} · {items.length}</h2>
<ul>{items.map(item => <li key={item.id}><button type="button" onClick={() => setSelectedCanvas(item.id)}>
{item.id ? <LayoutGrid size={18}/> : <Layers size={18}/>}
<span className={s.historyItem}><strong>{item.name}</strong><small>{item.summary}</small></span>
{item.editedAt && Number.isFinite(Date.parse(item.editedAt)) && <time dateTime={item.editedAt}>{times.format(new Date(item.editedAt))}</time>}
<ChevronRight size={16}/>
</button></li>)}</ul>
</section>)}
{!entries.length && <p className={s.historyEmpty}>{query ? '没有匹配的画布。' : '还没有编辑记录。打开画布开始创作,保存后就会出现在这里。'}</p>}
</section> : <div className={s.content}>
<section className={s.intro}>
<Layers size={20}/>
<h1>{subset?.name ?? canvasName}</h1>
@@ -83,3 +83,22 @@
.members { display: grid; gap: 8px; max-height: 30vh; overflow: auto; margin: 0 0 16px; border: 1px solid var(--border); }
.members label { display: flex; align-items: center; gap: 8px; overflow-wrap: anywhere; }
.members input[type=checkbox] { width: auto; min-height: 0; margin: 0; accent-color: var(--primary); }
.historyLink { width: 100%; padding: 9px; text-align: left; border-radius: 6px; }
.historyLink[aria-current] { background: var(--selection); }
.historyLink:hover { background: var(--row-hover); }
.history { margin: 12px 16px 24px; padding: clamp(16px, 3vw, 38px); min-height: calc(100dvh - 92px); background: var(--surface); border: 1px solid var(--border); border-radius: 10px; }
.history > header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 26px; }
.history h1 { margin: 0; font-size: 23px; }
.history header p, .historyGroup h2, .historyItem small, .history time, .historyEmpty { color: var(--muted-foreground); }
.history header p { margin: 6px 0 0; }
.historyGroup { margin-top: 22px; }
.historyGroup h2 { font-size: 14px; font-weight: 500; margin: 0 0 6px; }
.historyGroup ul { list-style: none; padding: 0; margin: 0; }
.historyGroup button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px; min-height: 52px; border-radius: 7px; text-align: left; }
.historyGroup button:hover { background: var(--row-hover); }
.historyGroup button > svg { flex-shrink: 0; color: var(--muted-foreground); }
.historyItem { flex: 1; min-width: 0; }
.historyItem strong, .historyItem small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.historyItem small { margin-top: 4px; }
.history time { font-variant-numeric: tabular-nums; }
.historyEmpty { padding: 18px 0; line-height: 1.65; }