feat: 足迹 Footprints 个人留痕系统
桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA 同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署 多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { ToastProvider } from '@/components/ui';
|
||||
|
||||
export function AppShell() {
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="flex h-full">
|
||||
{navOpen && (
|
||||
<div className="fixed inset-0 z-30 bg-black/45 lg:hidden" onClick={() => setNavOpen(false)} aria-hidden />
|
||||
)}
|
||||
<Sidebar open={navOpen} onClose={() => setNavOpen(false)} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Header onMenu={() => setNavOpen(true)} />
|
||||
<main className="min-h-0 flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Search, Sun, Moon, Plus, Menu } from 'lucide-react';
|
||||
import { Button, Input } from '@/components/ui';
|
||||
|
||||
const TITLES: Record<string, { title: string; sub?: string }> = {
|
||||
'/timeline': { title: '时间线', sub: '按学年与月份回顾全部足迹' },
|
||||
'/records': { title: '记录', sub: '全部足迹的检索与管理' },
|
||||
'/grades': { title: '成绩', sub: '学年学科成绩 · 梯度与总览' },
|
||||
'/dashboard': { title: '统计', sub: '量化个人成长收获' },
|
||||
'/types': { title: '记录类型', sub: '类型与字段模板管理' },
|
||||
'/taxonomy': { title: '字典与维度', sub: '级别、角色与成长维度' }
|
||||
};
|
||||
|
||||
export function Header({ onMenu }: { onMenu?: () => void }) {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [q, setQ] = useState('');
|
||||
const [dark, setDark] = useState(document.documentElement.classList.contains('dark'));
|
||||
|
||||
const meta = TITLES[pathname] ?? { title: '' };
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next = !dark;
|
||||
setDark(next);
|
||||
document.documentElement.classList.toggle('dark', next);
|
||||
localStorage.setItem('fp-theme', next ? 'dark' : 'light');
|
||||
window.dispatchEvent(new CustomEvent('fp-theme-change'));
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-4 border-b border-line bg-surface px-5">
|
||||
<Button variant="ghost" size="icon" className="lg:hidden" onClick={onMenu} aria-label="打开导航">
|
||||
<Menu size={17} />
|
||||
</Button>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-[15px] font-semibold leading-tight text-ink-1">{meta.title}</h1>
|
||||
{meta.sub && <p className="truncate text-[11px] leading-tight text-ink-3">{meta.sub}</p>}
|
||||
</div>
|
||||
|
||||
<div className="relative ml-auto w-[280px]">
|
||||
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder="搜索标题、内容、结果、标签"
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && q.trim()) {
|
||||
navigate('/records?q=' + encodeURIComponent(q.trim()));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label="切换主题">
|
||||
{dark ? <Sun size={15} /> : <Moon size={15} />}
|
||||
</Button>
|
||||
|
||||
<Button variant="primary" onClick={() => navigate('/records?new=1')}>
|
||||
<Plus size={14} />
|
||||
新建记录
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, ChevronsLeft } from 'lucide-react';
|
||||
import { appInfo, syncStatus } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NAV = [
|
||||
{
|
||||
section: '工作区',
|
||||
items: [
|
||||
{ to: '/timeline', label: '时间线', icon: CalendarDays },
|
||||
{ to: '/records', label: '记录', icon: Rows3 },
|
||||
{ to: '/grades', label: '成绩', icon: GraduationCap },
|
||||
{ to: '/dashboard', label: '统计', icon: ChartColumn }
|
||||
]
|
||||
},
|
||||
{
|
||||
section: '配置',
|
||||
items: [
|
||||
{ to: '/types', label: '记录类型', icon: Shapes },
|
||||
{ to: '/taxonomy', label: '字典与维度', icon: SlidersHorizontal }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function LogoMark() {
|
||||
return (
|
||||
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" aria-hidden>
|
||||
<rect x="1" y="1" width="24" height="24" rx="6" fill="var(--brand)" />
|
||||
<ellipse cx="11" cy="10" rx="4.2" ry="5.2" fill="#fff" opacity="0.95" transform="rotate(-18 11 10)" />
|
||||
<circle cx="17.5" cy="17.5" r="3.1" fill="#fff" opacity="0.7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const info = useQuery({ queryKey: ['app-info'], queryFn: appInfo, staleTime: Infinity });
|
||||
const sync = useQuery({ queryKey: ['sync-status'], queryFn: syncStatus, refetchInterval: 15000 });
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={
|
||||
'flex w-[224px] shrink-0 flex-col border-r border-line bg-surface' +
|
||||
' max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:z-40 max-lg:transition-transform' +
|
||||
(open ? ' max-lg:translate-x-0' : ' max-lg:-translate-x-full')
|
||||
}
|
||||
>
|
||||
<div className="flex h-14 items-center gap-2.5 border-b border-line px-4">
|
||||
<LogoMark />
|
||||
<div className="leading-tight">
|
||||
<div className="text-[14px] font-semibold text-ink-1">足迹</div>
|
||||
<div className="text-[10px] font-medium tracking-[0.18em] text-ink-3">FOOTPRINTS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-3">
|
||||
{NAV.map(group => (
|
||||
<div key={group.section} className="mb-4">
|
||||
<div className="px-2 pb-1.5 text-[11px] font-medium text-ink-3">{group.section}</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={onClose}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex h-8 items-center gap-2.5 rounded-md px-2 text-[13px] transition-colors',
|
||||
isActive
|
||||
? 'bg-brand-subtle font-medium text-brand-text'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink-1'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon size={15} strokeWidth={2} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-line px-4 py-3">
|
||||
<div className="truncate text-[11px] text-ink-3" title={info.data?.db_path ?? ''}>
|
||||
{info.data ? '数据库:' + info.data.db_path.split(/[\\/]/).slice(-1)[0] : '加载中…'}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-ink-3">
|
||||
<ChevronsLeft size={11} />
|
||||
{sync.data
|
||||
? (sync.data.pending_ops > 0
|
||||
? '本地模式 · 待同步 ' + sync.data.pending_ops
|
||||
: '本地模式 · 数据已就绪')
|
||||
: '…'}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user