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 时自动把激活项滚到可视区中央
This commit is contained in:
@@ -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) => {
|
router.get('/templates', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await ensureBuiltin()
|
await ensureBuiltin()
|
||||||
const rows = await prisma.checkListTemplate.findMany({
|
const rows = await prisma.checkListTemplate.findMany({
|
||||||
where: { OR: [{ builtin: true }, { userId: _req.user.sub }] },
|
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) }
|
} 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) => {
|
router.post('/templates', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.sub
|
const userId = req.user.sub
|
||||||
const { name, scene = '', items = [] } = req.body || {}
|
const { name, scene = '', items = [] } = req.body || {}
|
||||||
const cleanName = String(name || '').trim()
|
const cleanName = String(name || '').trim()
|
||||||
if (!cleanName) return res.status(400).json({ error: '模板名称不能为空' })
|
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 : [])
|
const list = (Array.isArray(items) ? items : [])
|
||||||
.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') }))
|
.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') }))
|
||||||
.filter((i) => i.title)
|
.filter((i) => i.title)
|
||||||
@@ -103,12 +118,40 @@ router.post('/templates', async (req, res, next) => {
|
|||||||
} catch (e) { next(e) }
|
} 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) => {
|
router.delete('/templates/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.sub
|
const userId = req.user.sub
|
||||||
const id = Number(req.params.id)
|
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: '模板不存在或无权删除' })
|
if (!exist) return res.status(404).json({ error: '模板不存在或无权删除' })
|
||||||
await prisma.checkListTemplate.delete({ where: { id } })
|
await prisma.checkListTemplate.delete({ where: { id } })
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
|
|||||||
+2
-1
@@ -36,7 +36,8 @@ export default function App() {
|
|||||||
{/* 打卡为默认首页 */}
|
{/* 打卡为默认首页 */}
|
||||||
<Route path="/" element={<Navigate to={HOME_PATH} replace />} />
|
<Route path="/" element={<Navigate to={HOME_PATH} replace />} />
|
||||||
<Route path="/checkin" element={<CheckinPage />} />
|
<Route path="/checkin" element={<CheckinPage />} />
|
||||||
{/* 宏观: 油价 / 经济数据 / 策略研究 / 经济文章 / 报警 / 新闻 (V0.2 原首页并入) */}
|
{/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */}
|
||||||
|
<Route path="/macro" element={<Navigate to="/macro/news" replace />} />
|
||||||
<Route path="/macro/*" element={<MacroPage />} />
|
<Route path="/macro/*" element={<MacroPage />} />
|
||||||
<Route path="/monitor" element={<MonitorPage />} />
|
<Route path="/monitor" element={<MonitorPage />} />
|
||||||
<Route path="/monitor/server/:uuid" element={<ServerDetail />} />
|
<Route path="/monitor/server/:uuid" element={<ServerDetail />} />
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export const checklistApi = {
|
|||||||
removeItem: (itemId) => api(`/checklists/items/${itemId}`, { method: 'DELETE' }),
|
removeItem: (itemId) => api(`/checklists/items/${itemId}`, { method: 'DELETE' }),
|
||||||
checkAll: (id, done) => api(`/checklists/${id}/check-all`, { method: 'POST', body: { done } }),
|
checkAll: (id, done) => api(`/checklists/${id}/check-all`, { method: 'POST', body: { done } }),
|
||||||
templates: () => api('/checklists/templates'),
|
templates: () => api('/checklists/templates'),
|
||||||
|
template: (id) => api(`/checklists/templates/${id}`),
|
||||||
createTemplate: (body) => api('/checklists/templates', { method: 'POST', body }),
|
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' }),
|
removeTemplate: (id) => api(`/checklists/templates/${id}`, { method: 'DELETE' }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// 四个主 Tab 的统一头部 (V0.2.1 界面调整)
|
||||||
|
// - 高度为原 antd-mobile NavBar 的一半 (23px)
|
||||||
|
// - 标题小字 + 主色, 仅作定位用, 不抢内容视觉
|
||||||
|
// - 「我」页不使用该头部 (页面顶部直接是头像区), 因此本组件只被
|
||||||
|
// 打卡 / 宏观 / 监控 三页引用, 互不影响
|
||||||
|
export default function PageHeader({ title }) {
|
||||||
|
return (
|
||||||
|
<header className="page-header">
|
||||||
|
<span className="page-header-title">{title}</span>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Tabs } from 'antd-mobile'
|
import { Tabs } from 'antd-mobile'
|
||||||
|
|
||||||
// 顶部分类 + 下方仅渲染当前激活 tab 的内容
|
// 顶部分类 + 下方仅渲染当前激活 tab 的内容
|
||||||
// - 顶部: antd-mobile Tabs (点击切换)
|
// - 顶部: antd-mobile Tabs (点击切换)
|
||||||
// - 下方: 同时挂载所有 tab, 通过 [hidden] 仅显示激活的那个 (保留各 tab 内部状态)
|
// - 下方: 同时挂载所有 tab, 通过 [hidden] 仅显示激活的那个 (保留各 tab 内部状态)
|
||||||
|
// - 右侧溢出提示: 当 tab 条还能向右滚动时, 右边缘显示渐变 + 箭头,
|
||||||
|
// 提示"右侧还有更多 tab"; 滚动到末尾后自动消失
|
||||||
// props:
|
// props:
|
||||||
// tabs: [{ key, label, render }]
|
// tabs: [{ key, label, render }]
|
||||||
// active: 当前激活 key
|
// active: 当前激活 key
|
||||||
// onChange(key): 切换回调
|
// onChange(key): 切换回调
|
||||||
export default function SwipeTabs({ tabs, active, onChange }) {
|
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 (
|
return (
|
||||||
<div className="swipe-tabs-wrap">
|
<div className="swipe-tabs-wrap" ref={wrapRef}>
|
||||||
<Tabs activeKey={active} onChange={onChange}>
|
<Tabs activeKey={active} onChange={onChange}>
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<Tabs.Tab key={t.key} title={t.label} />
|
<Tabs.Tab key={t.key} title={t.label} />
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
{more && (
|
||||||
|
<div
|
||||||
|
className="tab-more-hint"
|
||||||
|
style={{ height: headerH }}
|
||||||
|
onClick={() => {
|
||||||
|
const list = wrapRef.current?.querySelector('.adm-tabs-tab-list')
|
||||||
|
if (list) list.scrollTo({ left: list.scrollLeft + list.clientWidth * 0.6, behavior: 'smooth' })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="tab-more-arrow">›</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<div key={t.key} className="tab-pane" hidden={t.key !== active}>
|
<div key={t.key} className="tab-pane" hidden={t.key !== active}>
|
||||||
|
|||||||
+16
-14
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
|
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 SwipeTabs from '../components/SwipeTabs.jsx'
|
||||||
import DataView from './macro/DataView.jsx'
|
import DataView from './macro/DataView.jsx'
|
||||||
import StrategyView from './macro/StrategyView.jsx'
|
import StrategyView from './macro/StrategyView.jsx'
|
||||||
@@ -11,33 +12,34 @@ import AlertsView from './macro/AlertsView.jsx'
|
|||||||
import NewsView from './macro/NewsView.jsx'
|
import NewsView from './macro/NewsView.jsx'
|
||||||
import NewsDetail from './news/NewsDetail.jsx'
|
import NewsDetail from './news/NewsDetail.jsx'
|
||||||
|
|
||||||
// 宏观: 顶栏 6 tab, 默认 油价
|
// 宏观: 顶栏 6 tab, 默认 新闻
|
||||||
// /macro → 显示 oil tab (本组件 activeFromPath 决定)
|
// /macro → 显示 news tab (activeFromPath 决定)
|
||||||
|
// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来, 置最左)
|
||||||
|
// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来, 置第二并作为默认)
|
||||||
// /macro/oil → 油价
|
// /macro/oil → 油价
|
||||||
// /macro/data → 经济数据
|
// /macro/data → 经济数据
|
||||||
// /macro/strategy → 策略研究
|
// /macro/strategy → 策略研究
|
||||||
// /macro/articles → 经济文章
|
// /macro/articles → 经济文章
|
||||||
// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来)
|
|
||||||
// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来)
|
|
||||||
// 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id
|
// 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id
|
||||||
|
// 说明: tab 数量已超出屏宽, SwipeTabs 会在右侧显示渐变 + 箭头提示可继续滑动
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
|
||||||
|
{ key: 'news', label: '新闻', path: '/macro/news' },
|
||||||
{ key: 'oil', label: '油价', path: '/macro/oil' },
|
{ key: 'oil', label: '油价', path: '/macro/oil' },
|
||||||
{ key: 'data', label: '经济数据', path: '/macro/data' },
|
{ key: 'data', label: '经济数据', path: '/macro/data' },
|
||||||
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' },
|
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' },
|
||||||
{ key: 'articles', label: '经济文章', path: '/macro/articles' },
|
{ key: 'articles', label: '经济文章', path: '/macro/articles' },
|
||||||
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
|
|
||||||
{ key: 'news', label: '新闻', path: '/macro/news' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function activeFromPath(pathname) {
|
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/alerts')) return 'alerts'
|
||||||
if (pathname.startsWith('/macro/news')) return 'news'
|
if (pathname.startsWith('/macro/news')) return 'news'
|
||||||
// 父路由 /macro 走 Tab 列表第一个 (油价)
|
if (pathname.startsWith('/macro/oil')) return '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() {
|
export default function MacroPage() {
|
||||||
@@ -82,7 +84,7 @@ export default function MacroPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-with-navbar">
|
<div className="page-with-navbar">
|
||||||
<NavBar back={null} />
|
<PageHeader title="宏观" />
|
||||||
<SwipeTabs tabs={tabs} active={active} onChange={onChange} />
|
<SwipeTabs tabs={tabs} active={active} onChange={onChange} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { useAuth } from '../auth/AuthContext.jsx'
|
||||||
import { APP_VERSION, BUILD_ID } from '../version.js'
|
import { APP_VERSION, BUILD_ID } from '../version.js'
|
||||||
import {
|
import {
|
||||||
@@ -115,7 +115,6 @@ export default function MePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="me-page">
|
<div className="me-page">
|
||||||
<NavBar back={null} className="me-navbar" />
|
|
||||||
<div className="me-profile">
|
<div className="me-profile">
|
||||||
<Avatar
|
<Avatar
|
||||||
className="me-avatar"
|
className="me-avatar"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { NavBar, Card, Empty, DotLoading } from 'antd-mobile'
|
import { Card, Empty, DotLoading } from 'antd-mobile'
|
||||||
import { api } from '../api/client.js'
|
import { api } from '../api/client.js'
|
||||||
|
import PageHeader from '../components/PageHeader.jsx'
|
||||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||||
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
|
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
|
||||||
import EnvView from './monitor/EnvView.jsx'
|
import EnvView from './monitor/EnvView.jsx'
|
||||||
@@ -46,7 +47,7 @@ export default function MonitorPage() {
|
|||||||
]
|
]
|
||||||
return (
|
return (
|
||||||
<div className="page-with-navbar">
|
<div className="page-with-navbar">
|
||||||
<NavBar back={null} />
|
<PageHeader title="监控" />
|
||||||
<div className="monitor-content">
|
<div className="monitor-content">
|
||||||
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
|
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { NavBar } from 'antd-mobile'
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
import SwipeTabs from '../../components/SwipeTabs.jsx'
|
import SwipeTabs from '../../components/SwipeTabs.jsx'
|
||||||
import HabitView from './HabitView.jsx'
|
import HabitView from './HabitView.jsx'
|
||||||
import CalendarView from './CalendarView.jsx'
|
import CalendarView from './CalendarView.jsx'
|
||||||
import ListView from './ListView.jsx'
|
import ListView from './ListView.jsx'
|
||||||
import { toDateStr } from './date.js'
|
import { toDateStr } from './date.js'
|
||||||
|
|
||||||
// 打卡页: 一个页面三个视图 —— 打卡(环形进度) / 日历(今天·昨天·明天) / 清单(场景检查)
|
// 打卡页: 三个视图 —— 打卡(环形进度) / 日历(今天·昨天·明天) / 清单(检查清单 + 模板)
|
||||||
|
// 注: 打卡与日历的合并视图正在设计确认中, 确认后此处收敛为 日历(含打卡) / 清单 两个视图
|
||||||
export default function CheckinPage() {
|
export default function CheckinPage() {
|
||||||
const [active, setActive] = useState('habit')
|
const [active, setActive] = useState('habit')
|
||||||
const [date, setDate] = useState(() => toDateStr())
|
const [date, setDate] = useState(() => toDateStr())
|
||||||
@@ -19,7 +20,7 @@ export default function CheckinPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-with-navbar">
|
<div className="page-with-navbar">
|
||||||
<NavBar back={null}>打卡</NavBar>
|
<PageHeader title="打卡" />
|
||||||
<div className="checkin-content">
|
<div className="checkin-content">
|
||||||
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
|
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+340
-103
@@ -1,24 +1,33 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Button, Dialog, DotLoading, Empty, Input, Popup, Segmented, Selector, Toast,
|
Button, Dialog, DotLoading, Empty, Input, Popup, Segmented, Toast,
|
||||||
} from 'antd-mobile'
|
} from 'antd-mobile'
|
||||||
import { Celebrate } from '../../components/RingProgress.jsx'
|
import { Celebrate } from '../../components/RingProgress.jsx'
|
||||||
import { checklistApi } from '../../api/checkin.js'
|
import { checklistApi } from '../../api/checkin.js'
|
||||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.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() {
|
export default function ListView() {
|
||||||
const [archived, setArchived] = useState(false)
|
const [tab, setTab] = useState('active') // active | archived | template
|
||||||
const [lists, setLists] = useState(null)
|
const [lists, setLists] = useState(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [openId, setOpenId] = useState(null)
|
const [openId, setOpenId] = useState(null)
|
||||||
const [newItem, setNewItem] = useState({})
|
const [newItem, setNewItem] = useState({})
|
||||||
const [bursts, setBursts] = useState({})
|
const [bursts, setBursts] = useState({})
|
||||||
const [templates, setTemplates] = useState([])
|
const [templates, setTemplates] = useState(null)
|
||||||
const [showForm, setShowForm] = useState(false)
|
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) => {
|
const load = useCallback((silent) => {
|
||||||
if (!silent) setErr('')
|
if (!silent) setErr('')
|
||||||
@@ -27,13 +36,16 @@ export default function ListView() {
|
|||||||
.catch((e) => { if (!silent) setErr(e.message) })
|
.catch((e) => { if (!silent) setErr(e.message) })
|
||||||
}, [archived])
|
}, [archived])
|
||||||
|
|
||||||
useEffect(() => { setLists(null); load(false) }, [load])
|
const loadTemplates = useCallback(() => {
|
||||||
useRefreshOnVisible(() => load(true))
|
checklistApi.templates()
|
||||||
|
.then((d) => setTemplates((d.items || []).slice().sort(byName)))
|
||||||
useEffect(() => {
|
.catch(() => setTemplates([]))
|
||||||
checklistApi.templates().then((d) => setTemplates(d.items || [])).catch(() => {})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { setLists(null); load(false) }, [load])
|
||||||
|
useRefreshOnVisible(() => { load(true); if (tab === 'template') loadTemplates() })
|
||||||
|
useEffect(() => { loadTemplates() }, [loadTemplates])
|
||||||
|
|
||||||
const withProgress = useCallback((list) => {
|
const withProgress = useCallback((list) => {
|
||||||
const items = list.items || []
|
const items = list.items || []
|
||||||
const done = items.filter((i) => i.done).length
|
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() {
|
async function createList() {
|
||||||
const title = String(form.title || '').trim()
|
const title = String(form.title || '').trim()
|
||||||
if (!title) return Toast.show({ content: '请填写清单名称' })
|
if (!title) return Toast.show({ content: '请填写清单名称' })
|
||||||
|
const tpl = templateById(form.templateId)
|
||||||
try {
|
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)
|
setShowForm(false)
|
||||||
setForm({ title: '', scene: '出差', templateId: '' })
|
setForm({ title: '', templateId: '' })
|
||||||
|
setTab('active')
|
||||||
load(true)
|
load(true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.show({ content: e.message || '创建失败' })
|
Toast.show({ content: e.message || '创建失败' })
|
||||||
@@ -131,15 +157,12 @@ export default function ListView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveAsTemplate(list) {
|
async function saveAsTemplate(list) {
|
||||||
|
const items = (list.items || []).map((i) => ({ title: i.title, note: i.note }))
|
||||||
|
if (!items.length) return Toast.show({ content: '空清单无法存为模板' })
|
||||||
try {
|
try {
|
||||||
await checklistApi.createTemplate({
|
await checklistApi.createTemplate({ name: list.title, scene: list.scene || '', items })
|
||||||
name: list.title,
|
|
||||||
scene: list.scene || '其他',
|
|
||||||
items: (list.items || []).map((i) => ({ title: i.title, note: i.note })),
|
|
||||||
})
|
|
||||||
Toast.show({ content: '已保存为模板' })
|
Toast.show({ content: '已保存为模板' })
|
||||||
const d = await checklistApi.templates()
|
loadTemplates()
|
||||||
setTemplates(d.items || [])
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.show({ content: e.message || '保存失败' })
|
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 sorted = useMemo(() => lists || [], [lists])
|
||||||
|
const templateCount = (t) => (Array.isArray(t.items) ? t.items.length : 0)
|
||||||
|
const selectedTpl = templateById(form.templateId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="list-view">
|
<div className="list-view">
|
||||||
<div className="list-view-head">
|
<div className="list-view-head">
|
||||||
<Segmented
|
<Segmented
|
||||||
options={[{ label: '进行中', value: 'active' }, { label: '已归档', value: 'archived' }]}
|
options={[
|
||||||
value={archived ? 'archived' : 'active'}
|
{ label: '进行中', value: 'active' },
|
||||||
onChange={(v) => setArchived(v === 'archived')}
|
{ label: '已归档', value: 'archived' },
|
||||||
|
{ label: '模板', value: 'template' },
|
||||||
|
]}
|
||||||
|
value={tab}
|
||||||
|
onChange={(v) => setTab(v)}
|
||||||
/>
|
/>
|
||||||
<Button size="mini" color="primary" onClick={() => setShowForm(true)}>+ 新建</Button>
|
{tab === 'template'
|
||||||
|
? <Button size="mini" color="primary" onClick={newTemplate}>+ 新建模板</Button>
|
||||||
|
: <Button size="mini" color="primary" onClick={() => openCreateForm()}>+ 新建</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => load(false)}>重试</span></div>}
|
{/* ---------- 模板分类 ---------- */}
|
||||||
{lists === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
{tab === 'template' && (
|
||||||
{lists !== null && !err && sorted.length === 0 && (
|
<>
|
||||||
<Empty description={archived ? '暂无归档清单' : '还没有检查清单'} />
|
{templates === null && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||||
)}
|
{templates !== null && templates.length === 0 && (
|
||||||
|
<Empty description="还没有模板">
|
||||||
<div className="checklist-wrap">
|
<Button color="primary" size="small" onClick={newTemplate}>创建第一个模板</Button>
|
||||||
{sorted.map((list) => {
|
</Empty>
|
||||||
const open = openId === list.id
|
)}
|
||||||
return (
|
<div className="template-wrap">
|
||||||
<div key={list.id} className={'checklist-card' + (list.completed ? ' done' : '')}>
|
{(templates || []).map((tpl) => (
|
||||||
<div className="checklist-head" onClick={() => setOpenId(open ? null : list.id)}>
|
<div key={tpl.id} className="template-card">
|
||||||
<div className="checklist-title-row">
|
<div className="template-head">
|
||||||
<span className="checklist-title">{list.title}</span>
|
<span className="template-name">{tpl.name}</span>
|
||||||
{list.scene && <span className="checklist-scene">{list.scene}</span>}
|
{tpl.builtin && <span className="template-badge">预置</span>}
|
||||||
|
<span className="template-count">{templateCount(tpl)} 项</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="checklist-progress-row">
|
<div className="template-items">
|
||||||
<div className="checklist-bar">
|
{(Array.isArray(tpl.items) ? tpl.items : []).slice(0, 4).map((it, i) => (
|
||||||
<div
|
<span key={i} className="template-item-chip">{it.title}</span>
|
||||||
className="checklist-bar-fill"
|
))}
|
||||||
style={{ width: Math.round((list.percent || 0) * 100) + '%' }}
|
{templateCount(tpl) > 4 && (
|
||||||
/>
|
<span className="template-item-chip more">+{templateCount(tpl) - 4}</span>
|
||||||
</div>
|
)}
|
||||||
<span className="checklist-count">{list.done}/{list.total}</span>
|
</div>
|
||||||
<span className={'checklist-arrow' + (open ? ' open' : '')}>›</span>
|
<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>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{open && (
|
{/* ---------- 清单列表 ---------- */}
|
||||||
<div className="checklist-body">
|
{tab !== 'template' && (
|
||||||
{(list.items || []).map((item) => (
|
<>
|
||||||
<div key={item.id} className={'checklist-item' + (item.done ? ' done' : '')}>
|
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => load(false)}>重试</span></div>}
|
||||||
<span
|
{lists === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||||
className="check-box"
|
{lists !== null && !err && sorted.length === 0 && (
|
||||||
onClick={(e) => { e.stopPropagation(); toggleItem(list.id, item) }}
|
<Empty description={archived ? '暂无归档清单' : '还没有检查清单'}>
|
||||||
>
|
{!archived && <Button color="primary" size="small" onClick={() => openCreateForm()}>新建清单</Button>}
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none">
|
</Empty>
|
||||||
<path d="M4 12.5l5 5L20 6.5" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
)}
|
||||||
</svg>
|
|
||||||
</span>
|
<div className="checklist-wrap">
|
||||||
<span className="checklist-item-title" onClick={() => toggleItem(list.id, item)}>
|
{sorted.map((list) => {
|
||||||
{item.title}
|
const open = openId === list.id
|
||||||
{item.note && <em>{item.note}</em>}
|
return (
|
||||||
</span>
|
<div key={list.id} className={'checklist-card' + (list.completed ? ' done' : '')}>
|
||||||
<span className="checklist-item-del" onClick={() => removeItem(list.id, item.id)}>×</span>
|
<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>
|
||||||
))}
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<div className="checklist-actions">
|
{open && (
|
||||||
<span className="habit-link" onClick={() => checkAll(list.id, true)}>全选</span>
|
<div className="checklist-body">
|
||||||
<span className="habit-link" onClick={() => checkAll(list.id, false)}>全不选</span>
|
{(list.items || []).map((item) => (
|
||||||
<span className="habit-link" onClick={() => duplicate(list)}>复制</span>
|
<div key={item.id} className={'checklist-item' + (item.done ? ' done' : '')}>
|
||||||
<span className="habit-link" onClick={() => saveAsTemplate(list)}>存为模板</span>
|
<span
|
||||||
<span className="habit-link" onClick={() => toggleArchive(list)}>{list.archived ? '恢复' : '归档'}</span>
|
className="check-box"
|
||||||
<span className="habit-link danger" onClick={() => removeList(list)}>删除</span>
|
onClick={(e) => { e.stopPropagation(); toggleItem(list.id, item) }}
|
||||||
</div>
|
>
|
||||||
|
<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>
|
||||||
)}
|
)
|
||||||
<Celebrate fire={bursts[list.id] || 0} color="#00b578" />
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
</>
|
||||||
})}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* ---------- 新建清单 (名称 + 模板, 无场景) ---------- */}
|
||||||
<Popup
|
<Popup
|
||||||
visible={showForm}
|
visible={showForm}
|
||||||
onMaskClick={() => setShowForm(false)}
|
onMaskClick={() => setShowForm(false)}
|
||||||
@@ -262,23 +413,24 @@ export default function ListView() {
|
|||||||
<div className="form-title">新建检查清单</div>
|
<div className="form-title">新建检查清单</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>名称</label>
|
<label>名称</label>
|
||||||
<Input placeholder="例如:出差 · 杭州" value={form.title} onChange={(v) => setForm((f) => ({ ...f, title: v }))} />
|
<Input
|
||||||
</div>
|
placeholder="例如:出差 · 杭州"
|
||||||
<div className="form-field">
|
value={form.title}
|
||||||
<label>场景</label>
|
maxLength={20}
|
||||||
<Selector
|
onChange={(v) => setForm((f) => ({ ...f, title: v }))}
|
||||||
options={SCENES.map((s) => ({ label: s, value: s }))}
|
|
||||||
value={[form.scene]}
|
|
||||||
onChange={(v) => setForm((f) => ({ ...f, scene: v[0] }))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>从模板生成(可选)</label>
|
<label>模板</label>
|
||||||
<Selector
|
<div className="template-pick-row" onClick={() => setPickerOpen(true)}>
|
||||||
options={[{ label: '不使用模板', value: '' }, ...templates.map((t) => ({ label: t.name, value: String(t.id) }))]}
|
<span className={selectedTpl ? 'template-pick-name' : 'template-pick-name placeholder'}>
|
||||||
value={[form.templateId]}
|
{selectedTpl ? selectedTpl.name : '不使用模板(空白清单)'}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, templateId: v[0] }))}
|
</span>
|
||||||
/>
|
<span className="template-pick-meta">
|
||||||
|
{selectedTpl ? `${templateCount(selectedTpl)} 项` : ''}
|
||||||
|
<span className="template-pick-arrow">›</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<Button block color="primary" onClick={createList}>创建</Button>
|
<Button block color="primary" onClick={createList}>创建</Button>
|
||||||
@@ -286,6 +438,91 @@ export default function ListView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+129
-3
@@ -67,12 +67,31 @@ input, textarea, select { font: inherit; }
|
|||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 带有 antd-mobile NavBar 的页面 (Home/Monitor/Macro/Article) */
|
/* 带有页面头部的页面 (Checkin/Monitor/Macro) */
|
||||||
/* height:100% 让页面占满 app-main 高度, 内部 tab-content 独立滚动,
|
/* 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 { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
||||||
.page-with-navbar .adm-error-block { flex: 1; }
|
.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 定位 */
|
/* 底部 antd-mobile TabBar 定位 */
|
||||||
.app-tabbar.adm-tab-bar {
|
.app-tabbar.adm-tab-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -92,11 +111,53 @@ input, textarea, select { font: inherit; }
|
|||||||
|
|
||||||
/* 宏观页 SwipeTabs */
|
/* 宏观页 SwipeTabs */
|
||||||
.swipe-tabs-wrap {
|
.swipe-tabs-wrap {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
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 {
|
.tab-content {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
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; }
|
.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-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-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-avatar { background: rgba(255,255,255,.2); color: #fff; font-size: 28px; }
|
||||||
.me-name { font-size: 18px; font-weight: 600; }
|
.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-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; }
|
.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-popup { padding: 16px 16px 8px; }
|
||||||
.form-title { font-size: 16px; font-weight: 700; color: var(--color-text); margin-bottom: 12px; }
|
.form-title { font-size: 16px; font-weight: 700; color: var(--color-text); margin-bottom: 12px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user