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 (