import { useCallback, useEffect, useMemo, useState } from 'react' import { Button, Dialog, DotLoading, Empty, Input, Popup, Segmented, Toast, } from 'antd-mobile' import { Celebrate } from '../../components/RingProgress.jsx' import { checklistApi } from '../../api/checkin.js' import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' // 清单视图: 进行中 / 已归档 / 模板 三类并列 (F-LS-*) // - 进行中 / 已归档: 清单本体, 勾选即完成 // - 模板: 独立 CRUD (新建 / 编辑 / 删除), 按名称字母顺序排序 (中文按拼音) // - 新建清单: 只填名称, 模板从右侧滑出面板中选择 (不再使用场景选择器) const NAME_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' }) const byName = (a, b) => NAME_COLLATOR.compare(String(a.name || a.title || ''), String(b.name || b.title || '')) export default function ListView() { const [tab, setTab] = useState('active') // active | archived | template 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(null) const [showForm, setShowForm] = useState(false) const [form, setForm] = useState({ title: '', templateId: '' }) const [pickerOpen, setPickerOpen] = useState(false) // 右侧模板选择面板 const [tplEdit, setTplEdit] = useState(null) // { id, name, items[] } | null const [tplSaving, setTplSaving] = useState(false) const archived = tab === 'archived' const load = useCallback((silent) => { if (!silent) setErr('') checklistApi.lists(archived) .then((d) => setLists(d.items || [])) .catch((e) => { if (!silent) setErr(e.message) }) }, [archived]) const loadTemplates = useCallback(() => { checklistApi.templates() .then((d) => setTemplates((d.items || []).slice().sort(byName))) .catch(() => setTemplates([])) }, []) useEffect(() => { setLists(null); load(false) }, [load]) useRefreshOnVisible(() => { load(true); if (tab === 'template') loadTemplates() }) useEffect(() => { loadTemplates() }, [loadTemplates]) 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 || '操作失败' }) } } // ---------- 清单 ---------- function openCreateForm(templateId = '') { setForm({ title: '', templateId }) setShowForm(true) } async function createList() { const title = String(form.title || '').trim() if (!title) return Toast.show({ content: '请填写清单名称' }) const tpl = templateById(form.templateId) try { await checklistApi.create({ title, // 场景不再由用户选择: 沿用模板自带的场景 (无模板则为空), 兼容历史数据展示 scene: tpl?.scene || '', templateId: form.templateId || undefined, }) setShowForm(false) setForm({ title: '', templateId: '' }) setTab('active') 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) { const items = (list.items || []).map((i) => ({ title: i.title, note: i.note })) if (!items.length) return Toast.show({ content: '空清单无法存为模板' }) try { await checklistApi.createTemplate({ name: list.title, scene: list.scene || '', items }) Toast.show({ content: '已保存为模板' }) loadTemplates() } 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 || '删除失败' }) } } // ---------- 模板 CRUD ---------- function templateById(id) { if (!id) return null return (templates || []).find((t) => String(t.id) === String(id)) || null } function newTemplate() { setTplEdit({ id: null, name: '', items: [{ title: '' }], builtin: false }) } function editTemplate(tpl) { if (tpl.builtin) return Toast.show({ content: '系统预置模板不可编辑' }) setTplEdit({ id: tpl.id, name: tpl.name, builtin: false, items: (Array.isArray(tpl.items) ? tpl.items : []).map((i) => ({ title: String(i?.title || ''), note: String(i?.note || ''), })), }) } function setTplItem(idx, title) { setTplEdit((t) => ({ ...t, items: t.items.map((it, i) => (i === idx ? { ...it, title } : it)) })) } function addTplItem() { setTplEdit((t) => ({ ...t, items: [...t.items, { title: '' }] })) } function removeTplItem(idx) { setTplEdit((t) => ({ ...t, items: t.items.filter((_, i) => i !== idx) })) } async function saveTemplate() { if (!tplEdit) return const name = String(tplEdit.name || '').trim() const items = tplEdit.items .map((i) => ({ title: String(i.title || '').trim(), note: String(i.note || '') })) .filter((i) => i.title) if (!name) return Toast.show({ content: '请填写模板名称' }) if (name.length > 20) return Toast.show({ content: '模板名称不能超过 20 个字' }) if (!items.length) return Toast.show({ content: '模板至少需要一个检查项' }) setTplSaving(true) try { if (tplEdit.id) await checklistApi.updateTemplate(tplEdit.id, { name, items }) else await checklistApi.createTemplate({ name, items }) Toast.show({ content: tplEdit.id ? '已保存' : '模板已创建' }) setTplEdit(null) loadTemplates() } catch (e) { Toast.show({ content: e.message || '保存失败' }) } finally { setTplSaving(false) } } async function removeTemplate(tpl) { if (tpl.builtin) return Toast.show({ content: '系统预置模板不可删除' }) const ok = await Dialog.confirm({ content: `删除模板「${tpl.name}」?已创建的清单不受影响。`, confirmText: '删除', cancelText: '取消', }) if (!ok) return try { await checklistApi.removeTemplate(tpl.id) Toast.show({ content: '模板已删除' }) loadTemplates() } catch (e) { Toast.show({ content: e.message || '删除失败' }) } } const sorted = useMemo(() => lists || [], [lists]) const templateCount = (t) => (Array.isArray(t.items) ? t.items.length : 0) const selectedTpl = templateById(form.templateId) return (
setTab(v)} /> {tab === 'template' ? : }
{/* ---------- 模板分类 ---------- */} {tab === 'template' && ( <> {templates === null &&
加载中...
} {templates !== null && templates.length === 0 && ( )}
{(templates || []).map((tpl) => (
{tpl.name} {tpl.builtin && 预置} {templateCount(tpl)} 项
{(Array.isArray(tpl.items) ? tpl.items : []).slice(0, 4).map((it, i) => ( {it.title} ))} {templateCount(tpl) > 4 && ( +{templateCount(tpl) - 4} )}
openCreateForm(String(tpl.id))}>由此新建 {!tpl.builtin && editTemplate(tpl)}>编辑} {!tpl.builtin && removeTemplate(tpl)}>删除}
))}
)} {/* ---------- 清单列表 ---------- */} {tab !== 'template' && ( <> {err &&
{err} load(false)}>重试
} {lists === null && !err &&
加载中...
} {lists !== null && !err && sorted.length === 0 && ( {!archived && } )}
{sorted.map((list) => { const open = openId === list.id return (
setOpenId(open ? null : list.id)}>
{list.title} {list.scene && {list.scene}}
{list.done}/{list.total} ›
{open && (
{(list.items || []).map((item) => (
{ e.stopPropagation(); toggleItem(list.id, item) }} > toggleItem(list.id, item)}> {item.title} {item.note && {item.note}} removeItem(list.id, item.id)}>×
))}
setNewItem((m) => ({ ...m, [list.id]: v }))} onEnterPress={() => addItem(list.id, newItem[list.id])} />
checkAll(list.id, true)}>全选 checkAll(list.id, false)}>全不选 duplicate(list)}>复制 saveAsTemplate(list)}>存为模板 toggleArchive(list)}>{list.archived ? '恢复' : '归档'} removeList(list)}>删除
)}
) })}
)} {/* ---------- 新建清单 (名称 + 模板, 无场景) ---------- */} setShowForm(false)} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} >
新建检查清单
setForm((f) => ({ ...f, title: v }))} />
setPickerOpen(true)}> {selectedTpl ? selectedTpl.name : '不使用模板(空白清单)'} {selectedTpl ? `${templateCount(selectedTpl)} 项` : ''} ›
{/* ---------- 模板选择: 右侧滑出面板 ---------- */} setPickerOpen(false)} bodyStyle={{ width: '78vw', maxWidth: 340, height: '100%', display: 'flex', flexDirection: 'column' }} >
选择模板 setPickerOpen(false)}>×
按名称字母顺序排列
{ setForm((f) => ({ ...f, templateId: '' })); setPickerOpen(false) }} > 不使用模板 空白清单
{(templates || []).map((tpl) => (
{ setForm((f) => ({ ...f, templateId: String(tpl.id) })); setPickerOpen(false) }} > {tpl.name} {tpl.builtin && 预置} {templateCount(tpl)} 项
))} {templates !== null && templates.length === 0 && (
还没有可用模板
)}
{/* ---------- 模板编辑 ---------- */} setTplEdit(null)} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16, maxHeight: '82vh', overflowY: 'auto' }} > {tplEdit && (
{tplEdit.id ? '编辑模板' : '新建模板'}
setTplEdit((t) => ({ ...t, name: v }))} />
{tplEdit.items.map((it, idx) => (
{idx + 1} setTplItem(idx, v)} /> removeTplItem(idx)}>×
))}
)}
) }