-
打卡
+
diff --git a/src/pages/checkin/ListView.jsx b/src/pages/checkin/ListView.jsx
index 5c465af..2e8aa48 100644
--- a/src/pages/checkin/ListView.jsx
+++ b/src/pages/checkin/ListView.jsx
@@ -1,24 +1,33 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
- Button, Dialog, DotLoading, Empty, Input, Popup, Segmented, Selector, Toast,
+ 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'
-const SCENES = ['出差', '回老家', '其他']
+// 清单视图: 进行中 / 已归档 / 模板 三类并列 (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 || ''))
-// 清单视图: 出差/回老家等场景的一次性检查清单 (F-LS-*)
export default function ListView() {
- const [archived, setArchived] = useState(false)
+ 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([])
+ const [templates, setTemplates] = useState(null)
const [showForm, setShowForm] = useState(false)
- const [form, setForm] = useState({ title: '', scene: '出差', templateId: '' })
+ 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('')
@@ -27,13 +36,16 @@ export default function ListView() {
.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 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
@@ -107,13 +119,27 @@ export default function ListView() {
}
}
+ // ---------- 清单 ----------
+
+ 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: form.scene, templateId: form.templateId || undefined })
+ await checklistApi.create({
+ title,
+ // 场景不再由用户选择: 沿用模板自带的场景 (无模板则为空), 兼容历史数据展示
+ scene: tpl?.scene || '',
+ templateId: form.templateId || undefined,
+ })
setShowForm(false)
- setForm({ title: '', scene: '出差', templateId: '' })
+ setForm({ title: '', templateId: '' })
+ setTab('active')
load(true)
} catch (e) {
Toast.show({ content: e.message || '创建失败' })
@@ -131,15 +157,12 @@ export default function ListView() {
}
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: (list.items || []).map((i) => ({ title: i.title, note: i.note })),
- })
+ await checklistApi.createTemplate({ name: list.title, scene: list.scene || '', items })
Toast.show({ content: '已保存为模板' })
- const d = await checklistApi.templates()
- setTemplates(d.items || [])
+ loadTemplates()
} catch (e) {
Toast.show({ content: e.message || '保存失败' })
}
@@ -166,93 +189,221 @@ export default function ListView() {
}
}
+ // ---------- 模板 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 (
setArchived(v === 'archived')}
+ options={[
+ { label: '进行中', value: 'active' },
+ { label: '已归档', value: 'archived' },
+ { label: '模板', value: 'template' },
+ ]}
+ value={tab}
+ onChange={(v) => setTab(v)}
/>
-
+ {tab === 'template'
+ ?
+ : }
- {err &&
{err} load(false)}>重试
}
- {lists === null && !err &&
加载中...
}
- {lists !== null && !err && sorted.length === 0 && (
-
- )}
-
-
- {sorted.map((list) => {
- const open = openId === list.id
- return (
-
-
setOpenId(open ? null : list.id)}>
-
-
{list.title}
- {list.scene &&
{list.scene}}
+ {/* ---------- 模板分类 ---------- */}
+ {tab === 'template' && (
+ <>
+ {templates === null &&
加载中...
}
+ {templates !== null && templates.length === 0 && (
+
+
+
+ )}
+
+ {(templates || []).map((tpl) => (
+
+
+ {tpl.name}
+ {tpl.builtin && 预置}
+ {templateCount(tpl)} 项
-
-
-
{list.done}/{list.total}
-
›
+
+ {(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)}>删除}
+ ))}
+
+ >
+ )}
- {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)}>×
+ {/* ---------- 清单列表 ---------- */}
+ {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}
+
›
- ))}
-
-
- 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)}>删除
-
+ {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)}
@@ -262,23 +413,24 @@ export default function ListView() {
新建检查清单
- setForm((f) => ({ ...f, title: v }))} />
-
-
-
- ({ label: s, value: s }))}
- value={[form.scene]}
- onChange={(v) => setForm((f) => ({ ...f, scene: v[0] }))}
+ setForm((f) => ({ ...f, title: v }))}
/>
-
-
({ label: t.name, value: String(t.id) }))]}
- value={[form.templateId]}
- onChange={(v) => setForm((f) => ({ ...f, templateId: v[0] }))}
- />
+
+ setPickerOpen(true)}>
+
+ {selectedTpl ? selectedTpl.name : '不使用模板(空白清单)'}
+
+
+ {selectedTpl ? `${templateCount(selectedTpl)} 项` : ''}
+ ›
+
+
@@ -286,6 +438,91 @@ export default function ListView() {
+
+ {/* ---------- 模板选择: 右侧滑出面板 ---------- */}
+
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)}>×
+
+ ))}
+
+
+
+
+
+
+
+
+ )}
+
)
}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 24f269c..2478a80 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -67,12 +67,31 @@ input, textarea, select { font: inherit; }
to { opacity: 1; transform: translateY(0); }
}
-/* 带有 antd-mobile NavBar 的页面 (Home/Monitor/Macro/Article) */
+/* 带有页面头部的页面 (Checkin/Monitor/Macro) */
/* height:100% 让页面占满 app-main 高度, 内部 tab-content 独立滚动,
- 头部 NavBar 与分类 tab 固定在顶部不上滚 */
+ 头部与分类 tab 固定在顶部不上滚 */
.page-with-navbar { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.page-with-navbar .adm-error-block { flex: 1; }
+/* 统一主 Tab 头部 (打卡 / 宏观 / 监控): 原 NavBar 45px → 23px, 标题主色小字
+ 注意: 「我」页不使用此头部, 见 MePage (顶部直接是头像区) */
+.page-header {
+ height: 23px;
+ flex: 0 0 23px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--color-bg);
+ overflow: hidden;
+}
+.page-header-title {
+ font-size: 12px;
+ line-height: 1;
+ font-weight: 600;
+ letter-spacing: 1px;
+ color: var(--color-primary);
+}
+
/* 底部 antd-mobile TabBar 定位 */
.app-tabbar.adm-tab-bar {
position: fixed;
@@ -92,11 +111,53 @@ input, textarea, select { font: inherit; }
/* 宏观页 SwipeTabs */
.swipe-tabs-wrap {
+ position: relative;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
+/* tab 条右侧溢出提示: 渐变遮罩 + 箭头, 表示右边还有未展示的 tab
+ 遮罩宽度留足, 让被截断的 tab 文字淡出而不是被箭头压住 */
+.swipe-tabs-wrap .adm-tabs-header {
+ position: relative;
+ background: var(--color-card);
+}
+.swipe-tabs-wrap .adm-tabs-tab-wrapper { padding: 0 10px; }
+.tab-more-hint {
+ position: absolute;
+ top: 0;
+ right: 0;
+ height: 45px;
+ width: 58px;
+ z-index: 3;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding-right: 6px;
+ cursor: pointer;
+ background: linear-gradient(to right, rgba(255,255,255,0) 0%, #fff 62%, #fff 100%);
+}
+.tab-more-arrow {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 19px;
+ height: 19px;
+ border-radius: 50%;
+ background: #fff;
+ border: 1px solid #e3e6ea;
+ color: var(--color-primary);
+ font-size: 14px;
+ line-height: 1;
+ font-weight: 700;
+ box-shadow: 0 1px 4px rgba(0,0,0,.14);
+ animation: tab-more-nudge 1.5s ease-in-out infinite;
+}
+@keyframes tab-more-nudge {
+ 0%, 100% { transform: translateX(0); }
+ 50% { transform: translateX(3px); }
+}
.tab-content {
flex: 1 1 auto;
min-height: 0;
@@ -200,8 +261,8 @@ input, textarea, select { font: inherit; }
.login-error { background: #fef2f2; color: var(--color-danger); padding: 10px; border-radius: 6px; font-size: 13px; margin-bottom: 12px; }
/* ================== 我的页 ================== */
+/* 「我」页不设顶部头部条: 头像区直接贴顶 (2026-10-23 界面调整) */
.me-page { display: flex; flex-direction: column; min-height: 100%; background: var(--color-bg); }
-.me-navbar.adm-nav-bar { background: #fff; border-bottom: 1px solid var(--color-border); }
.me-profile { background: linear-gradient(135deg, #1677ff, #4096ff); color: #fff; padding: 24px 16px 28px; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.me-avatar { background: rgba(255,255,255,.2); color: #fff; font-size: 28px; }
.me-name { font-size: 18px; font-weight: 600; }
@@ -652,6 +713,71 @@ input, textarea, select { font: inherit; }
.checklist-add .adm-input { flex: 1; border: 1px solid var(--color-border); border-radius: 6px; padding: 6px 8px; }
.checklist-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }
+/* ----- 模板分类 (清单的第三个分段: 进行中 / 已归档 / 模板) ----- */
+.template-wrap { display: flex; flex-direction: column; gap: 10px; }
+.template-card {
+ background: #fff; border-radius: 10px; padding: 12px;
+ box-shadow: 0 1px 2px rgba(0,0,0,.04);
+}
+.template-head { display: flex; align-items: center; gap: 8px; }
+.template-name { font-size: 15px; font-weight: 600; color: var(--color-text); word-break: break-all; }
+.template-badge {
+ font-size: 10px; color: var(--color-muted); background: #f2f3f5;
+ border-radius: 4px; padding: 1px 5px; flex-shrink: 0;
+}
+.template-count { margin-left: auto; font-size: 11px; color: var(--color-muted); flex-shrink: 0; }
+.template-items { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
+.template-item-chip {
+ font-size: 11px; color: var(--color-muted); background: #f7f8fa;
+ border-radius: 4px; padding: 2px 7px; max-width: 100%;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.template-item-chip.more { color: var(--color-primary); background: #f0f5ff; }
+.template-actions {
+ display: flex; flex-wrap: wrap; gap: 14px; margin-top: 10px;
+ padding-top: 8px; border-top: 1px dashed var(--color-border);
+}
+
+/* 新建清单: 模板行 (点击后从右侧滑出选择面板) */
+.template-pick-row {
+ display: flex; align-items: center; gap: 8px; cursor: pointer;
+ border: 1px solid var(--color-border); border-radius: 8px; padding: 8px 10px;
+}
+.template-pick-row:active { background: #fafafa; }
+.template-pick-name { flex: 1; font-size: 14px; color: var(--color-text); }
+.template-pick-name.placeholder { color: #b8bcc4; }
+.template-pick-meta { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--color-muted); }
+.template-pick-arrow { font-size: 16px; line-height: 1; color: #c8c8c8; }
+
+/* 模板选择面板 (右侧滑出) */
+.tpl-picker { display: flex; flex-direction: column; height: 100%; padding: 14px 0 0; }
+.tpl-picker-head { display: flex; align-items: center; padding: 0 16px 6px; }
+.tpl-picker-title { font-size: 16px; font-weight: 700; color: var(--color-text); }
+.tpl-picker-close { margin-left: auto; font-size: 22px; line-height: 1; color: #c8c8c8; cursor: pointer; padding: 0 4px; }
+.tpl-picker-tip { font-size: 11px; color: var(--color-muted); padding: 0 16px 8px; }
+.tpl-picker-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0 10px 16px; }
+.tpl-picker-item {
+ display: flex; align-items: baseline; gap: 8px; padding: 11px 10px;
+ border-radius: 8px; cursor: pointer;
+}
+.tpl-picker-item:active { background: #f5f6f8; }
+.tpl-picker-item.active { background: #f0f5ff; }
+.tpl-picker-name {
+ flex: 1; font-size: 14px; font-weight: 600; color: var(--color-text);
+ display: inline-flex; align-items: center; gap: 6px; word-break: break-all;
+}
+.tpl-picker-item.active .tpl-picker-name { color: var(--color-primary); }
+.tpl-picker-sub { font-size: 11px; color: var(--color-muted); flex-shrink: 0; }
+.tpl-picker-empty { padding: 40px 0; text-align: center; font-size: 13px; color: var(--color-muted); }
+
+/* 模板编辑弹层 */
+.tpl-edit-items { display: flex; flex-direction: column; gap: 6px; }
+.tpl-edit-item { display: flex; align-items: center; gap: 8px; }
+.tpl-edit-index { width: 18px; text-align: right; font-size: 11px; color: var(--color-muted); flex-shrink: 0; }
+.tpl-edit-item .adm-input {
+ flex: 1; border: 1px solid var(--color-border); border-radius: 6px; padding: 6px 8px;
+}
+
/* ----- 表单弹窗 (打卡项 / 清单) ----- */
.form-popup { padding: 16px 16px 8px; }
.form-title { font-size: 16px; font-weight: 700; color: var(--color-text); margin-bottom: 12px; }