Files
iboard/src/pages/checkin/ListView.jsx
T
cheney 004f8c262d 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)与接口清单
2026-10-22 11:37:16 +08:00

292 lines
11 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button, Dialog, DotLoading, Empty, Input, Popup, Segmented, Selector, Toast,
} from 'antd-mobile'
import { Celebrate } from '../../components/RingProgress.jsx'
import { checklistApi } from '../../api/checkin.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
const SCENES = ['出差', '回老家', '其他']
// 清单视图: 出差/回老家等场景的一次性检查清单 (F-LS-*)
export default function ListView() {
const [archived, setArchived] = useState(false)
const [lists, setLists] = useState(null)
const [err, setErr] = useState('')
const [openId, setOpenId] = useState(null)
const [newItem, setNewItem] = useState({})
const [bursts, setBursts] = useState({})
const [templates, setTemplates] = useState([])
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ title: '', scene: '出差', templateId: '' })
const load = useCallback((silent) => {
if (!silent) setErr('')
checklistApi.lists(archived)
.then((d) => setLists(d.items || []))
.catch((e) => { if (!silent) setErr(e.message) })
}, [archived])
useEffect(() => { setLists(null); load(false) }, [load])
useRefreshOnVisible(() => load(true))
useEffect(() => {
checklistApi.templates().then((d) => setTemplates(d.items || [])).catch(() => {})
}, [])
const withProgress = useCallback((list) => {
const items = list.items || []
const done = items.filter((i) => i.done).length
return {
...list,
total: items.length,
done,
percent: items.length > 0 ? done / items.length : 0,
completed: items.length > 0 && done === items.length,
}
}, [])
const patchList = (id, updater) =>
setLists((prev) => (prev || []).map((l) => (l.id === id ? updater(l) : l)))
// 勾选: 乐观更新 + 服务端确认 (F-LS-03, F-SY-06)
async function toggleItem(listId, item) {
const snapshot = lists
const next = !item.done
patchList(listId, (l) => withProgress({
...l,
items: l.items.map((i) => (i.id === item.id ? { ...i, done: next } : i)),
}))
try {
await checklistApi.updateItem(item.id, { done: next })
const l = (lists || []).find((x) => x.id === listId)
const updated = withProgress({
...l,
items: l.items.map((i) => (i.id === item.id ? { ...i, done: next } : i)),
})
if (updated.completed) setBursts((m) => ({ ...m, [listId]: (m[listId] || 0) + 1 }))
} catch (e) {
setLists(snapshot)
Toast.show({ content: e.message || '更新失败' })
}
}
async function addItem(listId, title) {
const t = String(title || '').trim()
if (!t) return
try {
const { item } = await checklistApi.addItem(listId, { title: t })
patchList(listId, (l) => withProgress({ ...l, items: [...l.items, item] }))
setNewItem((m) => ({ ...m, [listId]: '' }))
} catch (e) {
Toast.show({ content: e.message || '添加失败' })
}
}
async function removeItem(listId, itemId) {
const snapshot = lists
patchList(listId, (l) => withProgress({ ...l, items: l.items.filter((i) => i.id !== itemId) }))
try {
await checklistApi.removeItem(itemId)
} catch (e) {
setLists(snapshot)
Toast.show({ content: e.message || '删除失败' })
}
}
async function checkAll(listId, done) {
const snapshot = lists
patchList(listId, (l) => withProgress({ ...l, items: l.items.map((i) => ({ ...i, done })) }))
try {
const { item } = await checklistApi.checkAll(listId, done)
patchList(listId, () => withProgress(item))
if (item.completed) setBursts((m) => ({ ...m, [listId]: (m[listId] || 0) + 1 }))
} catch (e) {
setLists(snapshot)
Toast.show({ content: e.message || '操作失败' })
}
}
async function createList() {
const title = String(form.title || '').trim()
if (!title) return Toast.show({ content: '请填写清单名称' })
try {
await checklistApi.create({ title, scene: form.scene, templateId: form.templateId || undefined })
setShowForm(false)
setForm({ title: '', scene: '出差', templateId: '' })
load(true)
} catch (e) {
Toast.show({ content: e.message || '创建失败' })
}
}
async function duplicate(list) {
try {
await checklistApi.duplicate(list.id)
Toast.show({ content: '已复制为新清单' })
load(true)
} catch (e) {
Toast.show({ content: e.message || '复制失败' })
}
}
async function saveAsTemplate(list) {
try {
await checklistApi.createTemplate({
name: list.title,
scene: list.scene || '其他',
items: (list.items || []).map((i) => ({ title: i.title, note: i.note })),
})
Toast.show({ content: '已保存为模板' })
const d = await checklistApi.templates()
setTemplates(d.items || [])
} catch (e) {
Toast.show({ content: e.message || '保存失败' })
}
}
async function toggleArchive(list) {
try {
await checklistApi.update(list.id, { archived: !list.archived })
Toast.show({ content: list.archived ? '已恢复到进行中' : '已归档' })
load(true)
} catch (e) {
Toast.show({ content: e.message || '操作失败' })
}
}
async function removeList(list) {
const ok = await Dialog.confirm({ content: `删除「${list.title}」?`, confirmText: '删除', cancelText: '取消' })
if (!ok) return
try {
await checklistApi.remove(list.id)
load(true)
} catch (e) {
Toast.show({ content: e.message || '删除失败' })
}
}
const sorted = useMemo(() => lists || [], [lists])
return (
<div className="list-view">
<div className="list-view-head">
<Segmented
options={[{ label: '进行中', value: 'active' }, { label: '已归档', value: 'archived' }]}
value={archived ? 'archived' : 'active'}
onChange={(v) => setArchived(v === 'archived')}
/>
<Button size="mini" color="primary" onClick={() => setShowForm(true)}>+ 新建</Button>
</div>
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => load(false)}>重试</span></div>}
{lists === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{lists !== null && !err && sorted.length === 0 && (
<Empty description={archived ? '暂无归档清单' : '还没有检查清单'} />
)}
<div className="checklist-wrap">
{sorted.map((list) => {
const open = openId === list.id
return (
<div key={list.id} className={'checklist-card' + (list.completed ? ' done' : '')}>
<div className="checklist-head" onClick={() => setOpenId(open ? null : list.id)}>
<div className="checklist-title-row">
<span className="checklist-title">{list.title}</span>
{list.scene && <span className="checklist-scene">{list.scene}</span>}
</div>
<div className="checklist-progress-row">
<div className="checklist-bar">
<div
className="checklist-bar-fill"
style={{ width: Math.round((list.percent || 0) * 100) + '%' }}
/>
</div>
<span className="checklist-count">{list.done}/{list.total}</span>
<span className={'checklist-arrow' + (open ? ' open' : '')}>›</span>
</div>
</div>
{open && (
<div className="checklist-body">
{(list.items || []).map((item) => (
<div key={item.id} className={'checklist-item' + (item.done ? ' done' : '')}>
<span
className="check-box"
onClick={(e) => { e.stopPropagation(); toggleItem(list.id, item) }}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none">
<path d="M4 12.5l5 5L20 6.5" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="checklist-item-title" onClick={() => toggleItem(list.id, item)}>
{item.title}
{item.note && <em>{item.note}</em>}
</span>
<span className="checklist-item-del" onClick={() => removeItem(list.id, item.id)}>×</span>
</div>
))}
<div className="checklist-add">
<Input
placeholder="添加检查项"
value={newItem[list.id] || ''}
onChange={(v) => setNewItem((m) => ({ ...m, [list.id]: v }))}
onEnterPress={() => addItem(list.id, newItem[list.id])}
/>
<Button size="mini" color="primary" onClick={() => addItem(list.id, newItem[list.id])}>添加</Button>
</div>
<div className="checklist-actions">
<span className="habit-link" onClick={() => checkAll(list.id, true)}>全选</span>
<span className="habit-link" onClick={() => checkAll(list.id, false)}>全不选</span>
<span className="habit-link" onClick={() => duplicate(list)}>复制</span>
<span className="habit-link" onClick={() => saveAsTemplate(list)}>存为模板</span>
<span className="habit-link" onClick={() => toggleArchive(list)}>{list.archived ? '恢复' : '归档'}</span>
<span className="habit-link danger" onClick={() => removeList(list)}>删除</span>
</div>
</div>
)}
<Celebrate fire={bursts[list.id] || 0} color="#00b578" />
</div>
)
})}
</div>
<Popup
visible={showForm}
onMaskClick={() => setShowForm(false)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
>
<div className="form-popup">
<div className="form-title">新建检查清单</div>
<div className="form-field">
<label>名称</label>
<Input placeholder="例如:出差 · 杭州" value={form.title} onChange={(v) => setForm((f) => ({ ...f, title: v }))} />
</div>
<div className="form-field">
<label>场景</label>
<Selector
options={SCENES.map((s) => ({ label: s, value: s }))}
value={[form.scene]}
onChange={(v) => setForm((f) => ({ ...f, scene: v[0] }))}
/>
</div>
<div className="form-field">
<label>从模板生成(可选)</label>
<Selector
options={[{ label: '不使用模板', value: '' }, ...templates.map((t) => ({ label: t.name, value: String(t.id) }))]}
value={[form.templateId]}
onChange={(v) => setForm((f) => ({ ...f, templateId: v[0] }))}
/>
</div>
<div className="form-actions">
<Button block color="primary" onClick={createList}>创建</Button>
<Button block fill="outline" onClick={() => setShowForm(false)} style={{ marginTop: 8 }}>取消</Button>
</div>
</div>
</Popup>
</div>
)
}