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:
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user