From c765755906c57e06072e13ee8130be459fdca82b Mon Sep 17 00:00:00 2001 From: cheney Date: Fri, 23 Oct 2026 16:40:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(UI):=20=E5=9B=9B=20Tab=20=E5=A4=B4?= =?UTF-8?q?=E9=83=A8=E7=BB=9F=E4=B8=80=20+=20=E6=B8=85=E5=8D=95=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=88=86=E7=B1=BB=20+=20=E5=AE=8F=E8=A7=82=20tab=20?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E4=B8=8E=E6=BA=A2=E5=87=BA=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 头部统一 - 新增 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 时自动把激活项滚到可视区中央 --- server/routes/checklists.js | 55 +++- src/App.jsx | 3 +- src/api/checkin.js | 2 + src/components/PageHeader.jsx | 12 + src/components/SwipeTabs.jsx | 67 ++++- src/pages/MacroPage.jsx | 30 +- src/pages/MePage.jsx | 3 +- src/pages/MonitorPage.jsx | 5 +- src/pages/checkin/CheckinPage.jsx | 7 +- src/pages/checkin/ListView.jsx | 443 +++++++++++++++++++++++------- src/styles/globals.css | 132 ++++++++- 11 files changed, 623 insertions(+), 136 deletions(-) create mode 100644 src/components/PageHeader.jsx diff --git a/server/routes/checklists.js b/server/routes/checklists.js index 2558708..76e3558 100644 --- a/server/routes/checklists.js +++ b/server/routes/checklists.js @@ -73,25 +73,40 @@ function withProgress(list) { // ---------- 模板 ---------- -// GET /api/checklists/templates +// 模板按名称字母顺序 (中文按拼音) 排序, 前后端共用同一比较器语义 +const NAME_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' }) +const byName = (a, b) => NAME_COLLATOR.compare(String(a.name || ''), String(b.name || '')) + +// GET /api/checklists/templates 模板列表 (含系统预置, 按字母顺序) router.get('/templates', async (_req, res, next) => { try { await ensureBuiltin() const rows = await prisma.checkListTemplate.findMany({ where: { OR: [{ builtin: true }, { userId: _req.user.sub }] }, - orderBy: [{ builtin: 'desc' }, { id: 'asc' }], }) - res.json({ items: rows }) + res.json({ items: rows.sort(byName) }) } catch (e) { next(e) } }) -// POST /api/checklists/templates 另存为模板 { name, scene, items: [{title, note}] } +// GET /api/checklists/templates/:id 模板详情 +router.get('/templates/:id', async (req, res, next) => { + try { + const row = await prisma.checkListTemplate.findFirst({ + where: { id: Number(req.params.id), OR: [{ builtin: true }, { userId: req.user.sub }] }, + }) + if (!row) return res.status(404).json({ error: '模板不存在' }) + res.json({ item: row }) + } catch (e) { next(e) } +}) + +// POST /api/checklists/templates 另存为模板 { name, scene?, items: [{title, note}] } router.post('/templates', async (req, res, next) => { try { const userId = req.user.sub const { name, scene = '', items = [] } = req.body || {} const cleanName = String(name || '').trim() if (!cleanName) return res.status(400).json({ error: '模板名称不能为空' }) + if (cleanName.length > 20) return res.status(400).json({ error: '模板名称不能超过 20 个字' }) const list = (Array.isArray(items) ? items : []) .map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') })) .filter((i) => i.title) @@ -103,12 +118,40 @@ router.post('/templates', async (req, res, next) => { } catch (e) { next(e) } }) -// DELETE /api/checklists/templates/:id +// PUT /api/checklists/templates/:id 改名称 / 检查项 (系统预置模板不可改) +router.put('/templates/:id', async (req, res, next) => { + try { + const userId = req.user.sub + const id = Number(req.params.id) + const exist = await prisma.checkListTemplate.findFirst({ where: { id, userId, builtin: false } }) + if (!exist) return res.status(404).json({ error: '模板不存在、无权限修改或是系统预置模板' }) + const { name, scene, items } = req.body || {} + const data = {} + if (name !== undefined) { + const n = String(name).trim() + if (!n) return res.status(400).json({ error: '模板名称不能为空' }) + if (n.length > 20) return res.status(400).json({ error: '模板名称不能超过 20 个字' }) + data.name = n + } + if (scene !== undefined) data.scene = String(scene) + if (items !== undefined) { + const list = (Array.isArray(items) ? items : []) + .map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') })) + .filter((i) => i.title) + if (!list.length) return res.status(400).json({ error: '模板至少需要一个检查项' }) + data.items = list + } + const row = await prisma.checkListTemplate.update({ where: { id }, data }) + res.json({ item: row }) + } catch (e) { next(e) } +}) + +// DELETE /api/checklists/templates/:id 仅自建模板可删 router.delete('/templates/:id', async (req, res, next) => { try { const userId = req.user.sub const id = Number(req.params.id) - const exist = await prisma.checkListTemplate.findFirst({ where: { id, userId } }) + const exist = await prisma.checkListTemplate.findFirst({ where: { id, userId, builtin: false } }) if (!exist) return res.status(404).json({ error: '模板不存在或无权删除' }) await prisma.checkListTemplate.delete({ where: { id } }) res.json({ ok: true }) diff --git a/src/App.jsx b/src/App.jsx index c99ffbb..e0e8763 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -36,7 +36,8 @@ export default function App() { {/* 打卡为默认首页 */} } /> } /> - {/* 宏观: 油价 / 经济数据 / 策略研究 / 经济文章 / 报警 / 新闻 (V0.2 原首页并入) */} + {/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */} + } /> } /> } /> } /> diff --git a/src/api/checkin.js b/src/api/checkin.js index ea0e7e1..e31c020 100644 --- a/src/api/checkin.js +++ b/src/api/checkin.js @@ -34,6 +34,8 @@ export const checklistApi = { removeItem: (itemId) => api(`/checklists/items/${itemId}`, { method: 'DELETE' }), checkAll: (id, done) => api(`/checklists/${id}/check-all`, { method: 'POST', body: { done } }), templates: () => api('/checklists/templates'), + template: (id) => api(`/checklists/templates/${id}`), createTemplate: (body) => api('/checklists/templates', { method: 'POST', body }), + updateTemplate: (id, body) => api(`/checklists/templates/${id}`, { method: 'PUT', body }), removeTemplate: (id) => api(`/checklists/templates/${id}`, { method: 'DELETE' }), } diff --git a/src/components/PageHeader.jsx b/src/components/PageHeader.jsx new file mode 100644 index 0000000..8c56b75 --- /dev/null +++ b/src/components/PageHeader.jsx @@ -0,0 +1,12 @@ +// 四个主 Tab 的统一头部 (V0.2.1 界面调整) +// - 高度为原 antd-mobile NavBar 的一半 (23px) +// - 标题小字 + 主色, 仅作定位用, 不抢内容视觉 +// - 「我」页不使用该头部 (页面顶部直接是头像区), 因此本组件只被 +// 打卡 / 宏观 / 监控 三页引用, 互不影响 +export default function PageHeader({ title }) { + return ( +
+ {title} +
+ ) +} diff --git a/src/components/SwipeTabs.jsx b/src/components/SwipeTabs.jsx index 5aa1b55..e885b2b 100644 --- a/src/components/SwipeTabs.jsx +++ b/src/components/SwipeTabs.jsx @@ -1,20 +1,83 @@ +import { useCallback, useEffect, useRef, useState } from 'react' import { Tabs } from 'antd-mobile' // 顶部分类 + 下方仅渲染当前激活 tab 的内容 // - 顶部: antd-mobile Tabs (点击切换) // - 下方: 同时挂载所有 tab, 通过 [hidden] 仅显示激活的那个 (保留各 tab 内部状态) +// - 右侧溢出提示: 当 tab 条还能向右滚动时, 右边缘显示渐变 + 箭头, +// 提示"右侧还有更多 tab"; 滚动到末尾后自动消失 // props: // tabs: [{ key, label, render }] // active: 当前激活 key // onChange(key): 切换回调 export default function SwipeTabs({ tabs, active, onChange }) { + const wrapRef = useRef(null) + const [more, setMore] = useState(false) + const [headerH, setHeaderH] = useState(45) + + // 判断右侧是否还有未展示的 tab (顺带记录 tab 条高度, 让提示层只覆盖页签行) + const measure = useCallback(() => { + const wrap = wrapRef.current + if (!wrap) return + const list = wrap.querySelector('.adm-tabs-tab-list') + const header = wrap.querySelector('.adm-tabs-header') + if (header && header.offsetHeight > 0) setHeaderH(header.offsetHeight) + if (!list) return + setMore(list.scrollLeft + list.clientWidth < list.scrollWidth - 2) + }, []) + + useEffect(() => { + const wrap = wrapRef.current + if (!wrap) return + const list = wrap.querySelector('.adm-tabs-tab-list') + const header = wrap.querySelector('.adm-tabs-header') + if (!list) return + + // 首次布局未稳定 (字体/宽度), 下一帧 + 300ms 各补测一次 + const raf = requestAnimationFrame(measure) + const timer = setTimeout(measure, 300) + list.addEventListener('scroll', measure, { passive: true }) + const ro = new ResizeObserver(measure) + ro.observe(list) + if (header) ro.observe(header) + return () => { + cancelAnimationFrame(raf) + clearTimeout(timer) + list.removeEventListener('scroll', measure) + ro.disconnect() + } + }, [measure, tabs.length]) + + // 切换 tab 后把激活项滚到可见区域中间, 避免选中项被藏在屏幕外 + useEffect(() => { + const wrap = wrapRef.current + if (!wrap) return + const list = wrap.querySelector('.adm-tabs-tab-list') + const activeEl = wrap.querySelector('.adm-tabs-tab-active') + if (!list || !activeEl) return + const target = activeEl.offsetLeft - (list.clientWidth - activeEl.offsetWidth) / 2 + list.scrollTo({ left: Math.max(0, target), behavior: 'smooth' }) + }, [active, tabs.length]) + return ( -
+
{tabs.map((t) => ( ))} + {more && ( +
{ + const list = wrapRef.current?.querySelector('.adm-tabs-tab-list') + if (list) list.scrollTo({ left: list.scrollLeft + list.clientWidth * 0.6, behavior: 'smooth' }) + }} + > + › +
+ )}
{tabs.map((t) => (
) -} +} diff --git a/src/pages/MacroPage.jsx b/src/pages/MacroPage.jsx index ce04898..9454691 100644 --- a/src/pages/MacroPage.jsx +++ b/src/pages/MacroPage.jsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useNavigate, useLocation, Routes, Route } from 'react-router-dom' -import { Badge, NavBar } from 'antd-mobile' +import { Badge } from 'antd-mobile' +import PageHeader from '../components/PageHeader.jsx' import SwipeTabs from '../components/SwipeTabs.jsx' import DataView from './macro/DataView.jsx' import StrategyView from './macro/StrategyView.jsx' @@ -11,33 +12,34 @@ import AlertsView from './macro/AlertsView.jsx' import NewsView from './macro/NewsView.jsx' import NewsDetail from './news/NewsDetail.jsx' -// 宏观: 顶栏 6 tab, 默认 油价 -// /macro → 显示 oil tab (本组件 activeFromPath 决定) +// 宏观: 顶栏 6 tab, 默认 新闻 +// /macro → 显示 news tab (activeFromPath 决定) +// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来, 置最左) +// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来, 置第二并作为默认) // /macro/oil → 油价 // /macro/data → 经济数据 // /macro/strategy → 策略研究 // /macro/articles → 经济文章 -// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来) -// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来) // 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id +// 说明: tab 数量已超出屏宽, SwipeTabs 会在右侧显示渐变 + 箭头提示可继续滑动 const TABS = [ + { key: 'alerts', label: '报警', path: '/macro/alerts' }, + { key: 'news', label: '新闻', path: '/macro/news' }, { key: 'oil', label: '油价', path: '/macro/oil' }, { key: 'data', label: '经济数据', path: '/macro/data' }, { key: 'strategy', label: '策略研究', path: '/macro/strategy' }, { key: 'articles', label: '经济文章', path: '/macro/articles' }, - { key: 'alerts', label: '报警', path: '/macro/alerts' }, - { key: 'news', label: '新闻', path: '/macro/news' }, ] function activeFromPath(pathname) { - if (pathname.startsWith('/macro/oil')) return 'oil' - if (pathname.startsWith('/macro/articles')) return 'articles' - if (pathname.startsWith('/macro/strategy')) return 'strategy' - if (pathname.startsWith('/macro/data')) return 'data' if (pathname.startsWith('/macro/alerts')) return 'alerts' if (pathname.startsWith('/macro/news')) return 'news' - // 父路由 /macro 走 Tab 列表第一个 (油价) - return 'oil' + if (pathname.startsWith('/macro/oil')) return 'oil' + if (pathname.startsWith('/macro/data')) return 'data' + if (pathname.startsWith('/macro/strategy')) return 'strategy' + if (pathname.startsWith('/macro/articles')) return 'articles' + // 父路由 /macro 走默认 tab (新闻) + return 'news' } export default function MacroPage() { @@ -82,7 +84,7 @@ export default function MacroPage() { return (
- +
) diff --git a/src/pages/MePage.jsx b/src/pages/MePage.jsx index 4ac9651..d98f81e 100644 --- a/src/pages/MePage.jsx +++ b/src/pages/MePage.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Avatar, Button, List, NavBar, ProgressBar, Switch, Toast } from 'antd-mobile' +import { Avatar, Button, List, ProgressBar, Switch, Toast } from 'antd-mobile' import { useAuth } from '../auth/AuthContext.jsx' import { APP_VERSION, BUILD_ID } from '../version.js' import { @@ -115,7 +115,6 @@ export default function MePage() { return (
-
- +
diff --git a/src/pages/checkin/CheckinPage.jsx b/src/pages/checkin/CheckinPage.jsx index d06134a..8287c17 100644 --- a/src/pages/checkin/CheckinPage.jsx +++ b/src/pages/checkin/CheckinPage.jsx @@ -1,12 +1,13 @@ import { useState } from 'react' -import { NavBar } from 'antd-mobile' +import PageHeader from '../../components/PageHeader.jsx' import SwipeTabs from '../../components/SwipeTabs.jsx' import HabitView from './HabitView.jsx' import CalendarView from './CalendarView.jsx' import ListView from './ListView.jsx' import { toDateStr } from './date.js' -// 打卡页: 一个页面三个视图 —— 打卡(环形进度) / 日历(今天·昨天·明天) / 清单(场景检查) +// 打卡页: 三个视图 —— 打卡(环形进度) / 日历(今天·昨天·明天) / 清单(检查清单 + 模板) +// 注: 打卡与日历的合并视图正在设计确认中, 确认后此处收敛为 日历(含打卡) / 清单 两个视图 export default function CheckinPage() { const [active, setActive] = useState('habit') const [date, setDate] = useState(() => toDateStr()) @@ -19,7 +20,7 @@ export default function CheckinPage() { return (
- 打卡 +
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; }