feat(V0.2): 打卡 / 日历 / 清单模块

- 数据: Habit/CheckIn/CheckList/CheckListItem/CheckListTemplate + 迁移 + 重新生成 client
- 后端: server/checkin/period.js 周期计算(日/周/月/年, 周一起始, Asia/Shanghai)
        /api/checkin/* 打卡项CRUD/打卡/撤销/补打/某日详情/月历
        /api/checklists/* 清单与检查项CRUD/勾选/全选/复制/模板(系统预置出差·回老家)
- 前端: 底部新增「打卡」Tab -> /checkin, 页内三视图
        环形进度 RingProgress + 完成特效(粒子/光晕/触感, 可关闭)
        打卡视图: 待办列表/乐观更新/撤销/新建编辑
        日历视图: 月历网格 + 今天·昨天·明天 + 日期标记/完成率
        清单视图: 场景清单/勾选进度条/模板生成/另存模板/归档
- 文档: docs/V0.2/需求文档.md 补充实现约定(附录B)与接口清单
This commit is contained in:
cheney
2026-10-22 11:37:16 +08:00
parent 54a3224f00
commit 004f8c262d
32 changed files with 11030 additions and 63 deletions
+2 -1
View File
@@ -8,6 +8,7 @@ const TABS = [
{ key: '/', name: 'home', label: '首页' },
{ key: '/macro', name: 'macro', label: '宏观' },
{ key: '/monitor', name: 'monitor', label: '监控' },
{ key: '/checkin', name: 'checkin', label: '打卡' },
{ key: '/me', name: 'me', label: '我' },
]
@@ -46,4 +47,4 @@ export default function BottomNav() {
))}
</TabBar>
)
}
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useRef, useState } from 'react'
// 环形进度组件 (F-CK-03/04/05, U-01)
// props:
// percent: 0~1 完成度
// size: 外框尺寸(px)
// stroke: 环宽(px)
// color: 进度色
// completed: 是否达标 (达标时显示对勾而非数字)
// done/target: 中心文案
// onClick: 可选, 点击整个环触发 (用于"点环即打卡")
export default function RingProgress({
percent = 0,
size = 58,
stroke = 6,
color = '#1677ff',
completed = false,
done = 0,
target = 1,
over = 0,
children,
onClick,
disabled = false,
}) {
const p = Math.max(0, Math.min(1, Number(percent) || 0))
const r = (size - stroke) / 2
const c = 2 * Math.PI * r
const offset = c * (1 - p)
return (
<div
className={'ring-wrap' + (onClick ? ' clickable' : '') + (disabled ? ' disabled' : '')}
style={{ width: size, height: size }}
onClick={disabled ? undefined : onClick}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="ring-svg">
{/* 背景环 */}
<circle
cx={size / 2} cy={size / 2} r={r}
fill="none" stroke="#ececf0" strokeWidth={stroke}
/>
{/* 进度环: 从 12 点方向顺时针 */}
<circle
className="ring-bar"
cx={size / 2} cy={size / 2} r={r}
fill="none" stroke={color} strokeWidth={stroke} strokeLinecap="round"
strokeDasharray={c} strokeDashoffset={offset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<div className="ring-center">
{children !== undefined ? children : (
completed ? (
<svg className="ring-check" width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M4 12.5l5 5L20 6.5" stroke={color} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<span className="ring-num">
{done}
<em>/{target}</em>
</span>
)
)}
</div>
{over > 0 && <span className="ring-over">+{over}</span>}
</div>
)
}
// 完成特效 (F-CK-07, U-02/U-03/U-07)
// props: fire 计数器(每次 +1 触发一次), color 主色
// 尊重"特效开关": localStorage iboard_celebrate === '0' 时不播放
export function Celebrate({ fire = 0, color = '#1677ff' }) {
const [burst, setBurst] = useState(0)
const [visible, setVisible] = useState(false)
const prev = useRef(fire)
const timer = useRef(null)
useEffect(() => {
if (fire === prev.current) return
prev.current = fire
if (fire <= 0) return
if (!effectsEnabled()) return
setBurst((n) => n + 1)
setVisible(true)
haptic()
clearTimeout(timer.current)
timer.current = setTimeout(() => setVisible(false), 900)
return () => clearTimeout(timer.current)
}, [fire])
if (!visible) return null
const dots = Array.from({ length: 14 }, (_, i) => i)
return (
<div className="celebrate" key={burst}>
<span className="celebrate-ring" style={{ borderColor: color }} />
{dots.map((i) => {
const angle = (360 / dots.length) * i + (i % 2 ? 8 : -8)
const dist = 26 + (i % 4) * 8
return (
<i
key={i}
style={{
background: i % 3 === 0 ? '#ffc53d' : color,
'--a': angle + 'deg',
'--d': dist + 'px',
animationDelay: (i % 3) * 40 + 'ms',
}}
/>
)
})}
</div>
)
}
// 特效全局开关 (默认开)
export function effectsEnabled() {
try {
if (localStorage.getItem('iboard_celebrate') === '0') return false
} catch { /* ignore */ }
// 系统"减弱动效"偏好时降级
if (typeof window !== 'undefined' && window.matchMedia) {
try {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false
} catch { /* ignore */ }
}
return true
}
export function setEffectsEnabled(v) {
try { localStorage.setItem('iboard_celebrate', v ? '1' : '0') } catch { /* ignore */ }
}
// 轻量触感反馈 (Web Vibration API; Tauri Android WebView 支持)
export function haptic(ms = 18) {
try {
if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(ms)
} catch { /* ignore */ }
}
+10 -1
View File
@@ -22,6 +22,15 @@ const ICONS = {
<path d="M3 12h4l2-5 4 10 2-5h6" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.12 : 0} />
</svg>
),
// 打卡: 日历 + 对勾
checkin: (active) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="18" height="16" rx="2" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.12 : 0} />
<path d="M3 10h18" />
<path d="M8 3v4M16 3v4" />
<path d="M9 15.5l2.2 2.2L15.5 13" />
</svg>
),
me: (active) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
@@ -34,4 +43,4 @@ export default function TabIcon({ name, active }) {
const render = ICONS[name]
if (!render) return null
return <span className="tab-icon">{render(!!active)}</span>
}
}