Files
iboard/src/pages/checkin/ListView.jsx
T
cheney c765755906 feat(UI): 四 Tab 头部统一 + 清单模板分类 + 宏观 tab 顺序与溢出提示
头部统一
- 新增 PageHeader 组件: 高度 23px (原 NavBar 45px 的一半), 标题 12px 主色加粗, 背景取页面底色
- 打卡 / 宏观 / 监控 三页各自引用, 互不影响; 宏观与监控补齐「宏观」「监控」标题
- 「我」页移除 NavBar 空白条, 蓝色头像区直接贴顶 (不引用 PageHeader, 与其余三页解耦)
- 清理失效样式 .me-navbar

清单模板分类
- 分段由 2 项扩为 3 项: 进行中 / 已归档 / 模板; 右上角按钮随分段切换
- 模板独立增删改查: 新增 GET /templates/:id、PUT /templates/:id; 预置模板拒绝改删
- 模板按名称字母顺序排序 (Intl.Collator zh-Hans-CN, 中文按拼音/数字按数值), 前后端同口径
- 新建清单去掉场景选择器, 仅保留名称; scene 改为沿用模板自带场景
- 模板改为从右侧滑出 78vw 面板选择, 首项「不使用模板」

宏观 tab
- 顺序改为 报警 → 新闻 → 油价 → 经济数据 → 策略研究 → 经济文章, 默认选中新闻
- /macro 由路由直接重定向到 /macro/news, activeFromPath 兜底同样返回 news
- SwipeTabs 增加右侧溢出提示: 渐变遮罩 + 主色圆底箭头, 可点击向右翻页, 滚到末尾自动消失
- 切换 tab 时自动把激活项滚到可视区中央
2026-10-23 16:40:32 +08:00

529 lines
22 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, 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 (
<div className="list-view">
<div className="list-view-head">
<Segmented
options={[
{ label: '进行中', value: 'active' },
{ label: '已归档', value: 'archived' },
{ label: '模板', value: 'template' },
]}
value={tab}
onChange={(v) => setTab(v)}
/>
{tab === 'template'
? <Button size="mini" color="primary" onClick={newTemplate}>+ 新建模板</Button>
: <Button size="mini" color="primary" onClick={() => openCreateForm()}>+ 新建</Button>}
</div>
{/* ---------- 模板分类 ---------- */}
{tab === 'template' && (
<>
{templates === null && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{templates !== null && templates.length === 0 && (
<Empty description="还没有模板">
<Button color="primary" size="small" onClick={newTemplate}>创建第一个模板</Button>
</Empty>
)}
<div className="template-wrap">
{(templates || []).map((tpl) => (
<div key={tpl.id} className="template-card">
<div className="template-head">
<span className="template-name">{tpl.name}</span>
{tpl.builtin && <span className="template-badge">预置</span>}
<span className="template-count">{templateCount(tpl)} 项</span>
</div>
<div className="template-items">
{(Array.isArray(tpl.items) ? tpl.items : []).slice(0, 4).map((it, i) => (
<span key={i} className="template-item-chip">{it.title}</span>
))}
{templateCount(tpl) > 4 && (
<span className="template-item-chip more">+{templateCount(tpl) - 4}</span>
)}
</div>
<div className="template-actions">
<span className="habit-link" onClick={() => openCreateForm(String(tpl.id))}>由此新建</span>
{!tpl.builtin && <span className="habit-link" onClick={() => editTemplate(tpl)}>编辑</span>}
{!tpl.builtin && <span className="habit-link danger" onClick={() => removeTemplate(tpl)}>删除</span>}
</div>
</div>
))}
</div>
</>
)}
{/* ---------- 清单列表 ---------- */}
{tab !== 'template' && (
<>
{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 ? '暂无归档清单' : '还没有检查清单'}>
{!archived && <Button color="primary" size="small" onClick={() => openCreateForm()}>新建清单</Button>}
</Empty>
)}
<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}
maxLength={20}
onChange={(v) => setForm((f) => ({ ...f, title: v }))}
/>
</div>
<div className="form-field">
<label>模板</label>
<div className="template-pick-row" onClick={() => setPickerOpen(true)}>
<span className={selectedTpl ? 'template-pick-name' : 'template-pick-name placeholder'}>
{selectedTpl ? selectedTpl.name : '不使用模板(空白清单)'}
</span>
<span className="template-pick-meta">
{selectedTpl ? `${templateCount(selectedTpl)} 项` : ''}
<span className="template-pick-arrow">›</span>
</span>
</div>
</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>
{/* ---------- 模板选择: 右侧滑出面板 ---------- */}
<Popup
visible={pickerOpen}
position="right"
onMaskClick={() => setPickerOpen(false)}
bodyStyle={{ width: '78vw', maxWidth: 340, height: '100%', display: 'flex', flexDirection: 'column' }}
>
<div className="tpl-picker">
<div className="tpl-picker-head">
<span className="tpl-picker-title">选择模板</span>
<span className="tpl-picker-close" onClick={() => setPickerOpen(false)}>×</span>
</div>
<div className="tpl-picker-tip">按名称字母顺序排列</div>
<div className="tpl-picker-list">
<div
className={'tpl-picker-item' + (!form.templateId ? ' active' : '')}
onClick={() => { setForm((f) => ({ ...f, templateId: '' })); setPickerOpen(false) }}
>
<span className="tpl-picker-name">不使用模板</span>
<span className="tpl-picker-sub">空白清单</span>
</div>
{(templates || []).map((tpl) => (
<div
key={tpl.id}
className={'tpl-picker-item' + (String(tpl.id) === String(form.templateId) ? ' active' : '')}
onClick={() => { setForm((f) => ({ ...f, templateId: String(tpl.id) })); setPickerOpen(false) }}
>
<span className="tpl-picker-name">
{tpl.name}
{tpl.builtin && <span className="template-badge">预置</span>}
</span>
<span className="tpl-picker-sub">{templateCount(tpl)} 项</span>
</div>
))}
{templates !== null && templates.length === 0 && (
<div className="tpl-picker-empty">还没有可用模板</div>
)}
</div>
</div>
</Popup>
{/* ---------- 模板编辑 ---------- */}
<Popup
visible={!!tplEdit}
onMaskClick={() => setTplEdit(null)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16, maxHeight: '82vh', overflowY: 'auto' }}
>
{tplEdit && (
<div className="form-popup">
<div className="form-title">{tplEdit.id ? '编辑模板' : '新建模板'}</div>
<div className="form-field">
<label>模板名称</label>
<Input
placeholder="例如:出差 / 露营"
value={tplEdit.name}
maxLength={20}
onChange={(v) => setTplEdit((t) => ({ ...t, name: v }))}
/>
</div>
<div className="form-field">
<label>检查项({tplEdit.items.length})</label>
<div className="tpl-edit-items">
{tplEdit.items.map((it, idx) => (
<div key={idx} className="tpl-edit-item">
<span className="tpl-edit-index">{idx + 1}</span>
<Input
placeholder="检查项内容"
value={it.title}
maxLength={40}
onChange={(v) => setTplItem(idx, v)}
/>
<span className="checklist-item-del" onClick={() => removeTplItem(idx)}>×</span>
</div>
))}
<Button size="mini" fill="outline" onClick={addTplItem} style={{ marginTop: 8 }}>+ 添加检查项</Button>
</div>
</div>
<div className="form-actions">
<Button block color="primary" loading={tplSaving} onClick={saveTemplate}>保存</Button>
<Button block fill="outline" onClick={() => setTplEdit(null)} style={{ marginTop: 8 }}>取消</Button>
</div>
</div>
)}
</Popup>
</div>
)
}